13 Commits
Author SHA1 Message Date
Pakobbix 03400ad5d5 Merge pull request 'Fix: comprehensive bug review — restore refactor regressions & fix bugs' (#24) from fix-comprehensive-bug-review into main
Reviewed-on: #24
2026-08-16 19:52:33 +00:00
ARIA 34553c18f7 Fix: comprehensive bug review — restore refactor regressions & fix critical/major/minor bugs
Critical:
- responseExtractor.js: fix import depth (7x ../ -> 6x) that broke the module
  graph for encounters, separate/external generation, and auto-avatars
- settingsListeners.js: restore ~25 undefined functions, ~40 lost event
  bindings, and the entire 'Initialize UI state' block dropped by the c14c141
  refactor; add initializeSettingsUIState()
- Restore all External API mode handlers (base-url, api-key, model,
  max-tokens, temperature, key-visibility, test-connection) + value init
- validator.js: convert CJS->ESM (crashed under type:module), fix
  glob.sync->globSync (v13 API), fix '!key in obj' precedence bugs

Major:
- sillytavern.js: add missing updateMessageBlock import
- infoBox.js: add missing saveSettings import
- userStats.js: fix double-escaped quantity regex

Minor:
- config.js: settingsVersion 6->7, restore showLockIcons, fix apiKey comment
- events.js: off() tracking + unregister/re-register on disable/enable
- escapeHtml: escape quotes (XSS) in all 3 copies
- template.html: data-prompt dialogue-coloring -> dialogueColoring
- trackerEditor.js: stale externalApiOnly -> sendAllEnabledOnRefresh
- suppression.js: fix '[object Object]' on empty instruct value
- persistence.js: await migrateToV3JSON (loadSettings now async)
- weatherEffects.js: JSON.parse -> repairJSON
- mobile.js: fix corrupted comment line
- Add missing debugMode setting; settings.html v3.7.2 -> v3.7.4

Dead code removed:
- Unused imports/vars (promptBuilder, jsonPromptHelpers, encounterPrompts,
  injector), withChangeDetection, trackJQueryHandler/cleanupJQueryEvents,
  renderThoughtsSidebarOnly, updateCheckpointButtonInMenu, addDiceQuickReply,
  setupRefreshButtonDrag/setupDebugButtonDrag (~430 lines)

Verified: 78/78 tests pass, style.css in sync, validator runs clean.
2026-08-16 21:42:14 +02:00
Pakobbix 6f906d0d97 Merge pull request 'Branch cleanup — remove 9 stale remote branches' (#23) from issue-22-branch-cleanup into main
Reviewed-on: #23
2026-07-13 10:17:01 +00:00
ARIA 3b78f284a1 Fixes #22: Document branch cleanup — removed 9 stale remote branches 2026-07-13 12:15:46 +02:00
Pakobbix 6e816e1436 Merge pull request 'Fixes #20: After change errors (JSON parse + equipment tab)' (#21) from issue-20-fix-json-parse-equipment into main
Reviewed-on: #21
2026-07-12 16:22:01 +00:00
ARIA 28ef82aae7 Fix: remove broken import in desktop.js that caused 404 and extension load failure 2026-07-12 18:14:59 +02:00
ARIA 59b125fd2a Fixes #20: Call updateSectionVisibility() during init to show equipment tab
The equipment section has display:none in CSS and updateSectionVisibility()
is the only function that calls .show() on it. It was never called during
initialization, only when settings toggles changed. Added the call to the
main init flow so the equipment tab becomes visible on page load.
2026-07-12 17:56:35 +02:00
ARIA 4bd29a207a Fixes #20: Fix JSON parse errors and empty equipment tab
- Replace JSON.parse() with repairJSON() in desktop.js and mobile.js
  to handle malformed JSON from infoBox/userStats data
- Add defensive initialization in renderEquipment(): ensure showEquipment
  defaults to true and equipment data structure exists
- Add explicit renderEquipment() call during initialization
2026-07-12 17:33:08 +02:00
Pakobbix e4bb48d9ea Update README.md 2026-07-12 15:09:11 +00:00
Pakobbix 8b5cf75396 Merge pull request 'Fixes #18: Update Readme & AGENTS.md' (#19) from issue-18-update-readme-agents into main
Reviewed-on: #19
2026-07-12 15:06:50 +00:00
ARIA c938916fd8 Fixes #18: Update README & AGENTS.md documentation
- README: Add Equipment System feature, Code Quality, Performance,
  Memory Management, Testing, Development Commands, and Tech Stack sections
- AGENTS.md: Update source tree with CSS modules, equipment constants,
  renderUtils, settings consolidation; add development commands,
  performance optimizations, memory management notes, validator factory;
  update file reference table

Based on PRs: #1 #3 #6 #8 #10 #12 #14 #17
2026-07-12 17:03:37 +02:00
Pakobbix f00475cb65 Merge pull request 'Fixes #16: Code quality enhancement Part 3' (#17) from issue-16-code-quality-enhancements into main
Reviewed-on: #17
2026-07-12 13:31:05 +00:00
ARIA 28802b1ad7 Fixes #16: Code quality enhancements Part 3
- Consolidate settings defaults: config.js is now single source of truth,
  state.js imports from config.js (eliminates ~300 lines of duplication)
- Validator factory: createStringValidator() unifies sanitizeLocationName/
  sanitizeItemName with configurable rules (blocked names, max length, rejects)
- Refactor promptBuilder: Extract getCharacterCardsInfo into buildNarratorCardInfo,
  buildGroupCardInfo, buildSingleCardInfo, appendCharacterFields
- Error handling: jsonRepair.js silent failures now log to console.debug
- Testing: Fix ESM support (babel.config.json + jest.config.cjs transform),
  add security.test.js with 24 tests for validator factory and security utilities
- Total: 78 tests passing (was 54)
2026-07-12 15:24:58 +02:00
41 changed files with 1651 additions and 1337 deletions
+97 -27
View File
@@ -2,18 +2,19 @@
## What this is ## What this is
A **browser extension for SillyTavern** (AI roleplay frontend). It is NOT a standalone Node.js application. All JavaScript runs in the browser context inside SillyTavern. There is **no build step** — files are loaded directly by SillyTavern's extension loader. A **browser extension for SillyTavern** (AI roleplay frontend). It is NOT a standalone Node.js application. All JavaScript runs in the browser context inside SillyTavern. There is **no bundler** JS files are loaded directly by SillyTavern's extension loader. CSS modules are concatenated via a build script.
--- ---
## Quick start ## Quick start
- **Entry point**: `index.js` (loaded by SillyTavern via `manifest.json`) - **Entry point**: `index.js` (loaded by SillyTavern via `manifest.json`)
- **CSS**: `style.css` (single file, ~12,300 lines) - **CSS**: Modular — 28 files in `src/styles/`, concatenated via `npm run build:css` (or `npm run build:css:min` for production)
- **HTML templates**: `template.html` (main panel), `settings.html` (Extensions tab settings drawer) - **HTML templates**: `template.html` (main panel), `settings.html` (Extensions tab settings drawer)
- **Runtime**: Browser only. All code executes inside SillyTavern's page context. - **Runtime**: Browser only. All code executes inside SillyTavern's page context.
- **jQuery**: Available globally as `$` / `jQuery`. Used extensively for DOM manipulation. - **jQuery**: Available globally as `$` / `jQuery`. Used extensively for DOM manipulation.
- **ES modules**: All `src/` files use `import`/`export` (browser ES module syntax). - **ES modules**: All `src/` files use `import`/`export` (browser ES module syntax).
- **Testing**: Jest + Babel ESM support — 78 tests (`npm test`)
--- ---
@@ -76,11 +77,12 @@ The deeper relative imports in `src/` files (e.g., `../../../../../../script.js`
``` ```
src/ src/
├── core/ # Core infrastructure ├── core/ # Core infrastructure
│ ├── config.js # Extension name, folder path, default settings │ ├── config.js # Extension name, folder path, default settings (single source of truth)
│ ├── state.js # Centralized state variables + getters/setters │ ├── state.js # Centralized state variables + getters/setters (imports defaults from config.js)
│ ├── persistence.js # Save/load settings, chat data, migrations │ ├── persistence.js # Save/load settings, chat data, migrations
│ ├── events.js # SillyTavern event system wrapper │ ├── events.js # SillyTavern event system wrapper
── i18n.js # Translation system (fetches JSON, applies data-i18n-key) ── i18n.js # Translation system (fetches JSON, applies data-i18n-key)
│ └── settingsPanel.js # Extracted settings panel UI (from index.js refactor)
├── i18n/ # Translation JSON files ├── i18n/ # Translation JSON files
│ ├── en.json # English (reference, 513 lines) │ ├── en.json # English (reference, 513 lines)
│ ├── fr.json # French │ ├── fr.json # French
@@ -88,10 +90,39 @@ src/
│ ├── zh-cn.json # Simplified Chinese │ ├── zh-cn.json # Simplified Chinese
│ ├── zh-tw.json # Traditional Chinese │ ├── zh-tw.json # Traditional Chinese
│ └── validator.js # Node.js script to validate translation consistency │ └── validator.js # Node.js script to validate translation consistency
├── styles/ # CSS modules (28 files, concatenated via build script)
│ ├── _01_variables.css # CSS custom properties
│ ├── _02_panel.css # Main panel layout
│ ├── _03_components.css # Buttons, inputs, common components
│ ├── _04_user-stats.css # User stats panel
│ ├── _05_info-box.css # Info box dashboard
│ ├── _06_thoughts.css # Character thoughts
│ ├── _07_settings.css # Settings modal
│ ├── _08_themes.css # Theme definitions (extended custom properties)
│ ├── _09_dice-modal.css # Dice roller modal
│ ├── _10_tracker-editor.css # Tracker configuration editor
│ ├── _11_settings-modal.css # Settings modal styles
│ ├── _12_thought-bubbles.css # Floating thought bubbles
│ ├── _13_mobile.css # Mobile responsive styles
│ ├── _14_inventory.css # Inventory views
│ ├── _15_inventory-views.css # Inventory detail views
│ ├── _16_desktop-tabs.css # Desktop tab navigation
│ ├── _17_quests.css # Quest tracking
│ ├── _18_equipment.css # Equipment grid
│ ├── _19_encounters.css # Combat encounters
│ ├── _20_plot-buttons.css # Plot progression buttons
│ ├── _21_music-player.css # Spotify music player
│ ├── _22_weather.css # Weather effects
│ ├── _23_holiday-promo.css # Holiday promotions
│ ├── _24_feature-toggles.css # Feature toggle switches
│ ├── _25_import-dialog.css # Import/export dialog
│ ├── _26_fab-widgets.css # Mobile FAB widgets
│ ├── _27_strip-widgets.css # Collapsed panel strip widgets
│ └── _28_equipment-modal.css # Equipment edit modal
├── systems/ # Feature systems (organized by concern) ├── systems/ # Feature systems (organized by concern)
│ ├── generation/ # AI prompt generation, parsing, API calls │ ├── generation/ # AI prompt generation, parsing, API calls
│ │ ├── promptBuilder.js # Builds tracker prompts for AI │ │ ├── promptBuilder.js # Builds tracker prompts for AI (refactored with focused helpers)
│ │ ├── parser.js # Parses AI responses to extract tracker data │ │ ├── parser.js # Parses AI responses (~50 pre-compiled regex patterns)
│ │ ├── apiClient.js # Makes API calls for separate/external generation │ │ ├── apiClient.js # Makes API calls for separate/external generation
│ │ ├── injector.js # Injects tracker prompts into generation context │ │ ├── injector.js # Injects tracker prompts into generation context
│ │ ├── lockManager.js # Manages locked tracker fields │ │ ├── lockManager.js # Manages locked tracker fields
@@ -106,7 +137,8 @@ src/
│ │ ├── inventory.js # Renders inventory section │ │ ├── inventory.js # Renders inventory section
│ │ ├── equipment.js # Renders equipment section │ │ ├── equipment.js # Renders equipment section
│ │ ├── quests.js # Renders quests section │ │ ├── quests.js # Renders quests section
│ │ ── musicPlayer.js # Renders Spotify music player │ │ ── musicPlayer.js # Renders Spotify music player
│ │ └── renderUtils.js # Rendering utilities (updateIfChanged cache, rAF batching)
│ ├── ui/ # UI components and helpers │ ├── ui/ # UI components and helpers
│ │ ├── theme.js # Theme system (default, sci-fi, fantasy, cyberpunk, custom) │ │ ├── theme.js # Theme system (default, sci-fi, fantasy, cyberpunk, custom)
│ │ ├── modals.js # Settings modal, dice modal, deprecation modal │ │ ├── modals.js # Settings modal, dice modal, deprecation modal
@@ -119,7 +151,10 @@ src/
│ │ ├── encounterUI.js # Combat encounter modal │ │ ├── encounterUI.js # Combat encounter modal
│ │ ├── snowflakes.js # Snowflake animation effect │ │ ├── snowflakes.js # Snowflake animation effect
│ │ ├── weatherEffects.js # Dynamic weather visual effects │ │ ├── weatherEffects.js # Dynamic weather visual effects
│ │ ── alternatePresentCharacters.js # Below-chat character panel │ │ ── alternatePresentCharacters.js # Below-chat character panel
│ │ └── settingsListeners.js # Settings event listeners (extracted from index.js)
│ ├── equipment/ # Equipment system
│ │ └── constants.js # Equipment slot definitions, type limits, stat config
│ ├── integration/ # SillyTavern event integration │ ├── integration/ # SillyTavern event integration
│ │ ├── sillytavern.js # Event handlers: message sent/received, swipe, etc. │ │ ├── sillytavern.js # Event handlers: message sent/received, swipe, etc.
│ │ └── thoughtBasedExpressions.js # Integrates with ST Character Expressions │ │ └── thoughtBasedExpressions.js # Integrates with ST Character Expressions
@@ -144,11 +179,11 @@ src/
├── imageUrls.js # Image URL utilities ├── imageUrls.js # Image URL utilities
├── itemParser.js # Item string parsing ├── itemParser.js # Item string parsing
├── jsonMigration.js # v2 to v3 JSON format migration ├── jsonMigration.js # v2 to v3 JSON format migration
├── jsonRepair.js # JSON repair for malformed AI responses ├── jsonRepair.js # JSON repair for malformed AI responses (14 pre-compiled regex)
├── migration.js # Settings migration helpers ├── migration.js # Settings migration helpers
├── presentCharacters.js # Present character data helpers ├── presentCharacters.js # Present character data helpers
├── responseExtractor.js # Extract tracker data from AI responses ├── responseExtractor.js # Extract tracker data from AI responses
├── security.js # Input validation and sanitization ├── security.js # Input validation and sanitization (validator factory)
├── sillyTavernExpressions.js # ST expression mapping ├── sillyTavernExpressions.js # ST expression mapping
├── thoughtBasedExpressionPortraits.js # Expression-to-portrait mapping ├── thoughtBasedExpressionPortraits.js # Expression-to-portrait mapping
└── transformations.js # Data transformation utilities └── transformations.js # Data transformation utilities
@@ -163,7 +198,7 @@ src/
5. **External API mode**: Uses user-configured external API endpoint 5. **External API mode**: Uses user-configured external API endpoint
6. **AI responds**`MESSAGE_RECEIVED` event fires 6. **AI responds**`MESSAGE_RECEIVED` event fires
7. **Extension hooks** `onMessageReceived``parser.js` extracts tracker data 7. **Extension hooks** `onMessageReceived``parser.js` extracts tracker data
8. **Rendering**: `renderUserStats()`, `renderInfoBox()`, `renderThoughts()`, etc. update the UI 8. **Rendering**: `renderUserStats()`, `renderInfoBox()`, `renderThoughts()`, etc. update the UI (batched via rAF)
9. **Persistence**: Tracker data is saved to `chat_metadata` per chat, and per-swipe 9. **Persistence**: Tracker data is saved to `chat_metadata` per chat, and per-swipe
### State management ### State management
@@ -178,6 +213,8 @@ All extension state lives in `src/core/state.js` as module-level `let` variables
- `pendingDiceRoll` — dice roll result to inject into next generation - `pendingDiceRoll` — dice roll result to inject into next generation
- DOM element caches: `$panelContainer`, `$userStatsContainer`, etc. - DOM element caches: `$panelContainer`, `$userStatsContainer`, etc.
**Settings consolidation**: `config.js` is the single source of truth for default settings. `state.js` imports defaults from `config.js` (~300 lines eliminated from duplication).
### Persistence ### Persistence
`src/core/persistence.js` handles: `src/core/persistence.js` handles:
@@ -187,20 +224,35 @@ All extension state lives in `src/core/state.js` as module-level `let` variables
- Settings versioning: automatic migration via `CURRENT_SETTINGS_VERSION` (currently 7) - Settings versioning: automatic migration via `CURRENT_SETTINGS_VERSION` (currently 7)
- Deferred saves: batches chat data saves to avoid excessive writes - Deferred saves: batches chat data saves to avoid excessive writes
### Memory management
- `clearDomCache()`: Invalidates jQuery object cache on panel rebuild
- Event tracking: All `on()`/`once()` handlers tracked for cleanup via `unregisterAllEvents()`
- `clearDebugLogs()`: Called on `CHAT_CHANGED` to prevent unbounded debug log growth
### Performance optimizations
- **Rendering**: `requestAnimationFrame` batching in `rerenderRpgState()`, `updateIfChanged()` cache comparison in `renderUtils.js`, CSS `content-visibility: auto` on inventory/equipment containers
- **Parser**: ~64 regex patterns pre-compiled as module-level constants, format detection caching with `lastDetectedFormat` (3-hit threshold)
- **CSS**: Minification via `--minify` flag, extended CSS custom properties, theme refactoring (379 → ~220 lines)
--- ---
## Development commands ## Development commands
```bash ```bash
# Validate translation files (check missing keys, type mismatches, empty values) # Build CSS from modular sources
npm run validate_locale_once npm run build:css # Concatenate CSS modules into style.css
npm run build:css:min # Minified production build (~50% smaller)
# Watch mode: re-validate on file changes # Testing
npm run validate_locale npm test # Run Jest test suite (78 tests)
# Translation validation
npm run validate_locale # Watch mode: re-validate on file changes
npm run validate_locale_once # Single validation run
``` ```
No build, lint, test, or typecheck commands exist. The code runs directly in the browser.
--- ---
## i18n system ## i18n system
@@ -256,6 +308,14 @@ All CSS classes use `rpg-` prefix to avoid conflicts with SillyTavern and other
- `.rpg-panel`, `.rpg-section`, `.rpg-stat-bar`, `.rpg-btn-primary`, etc. - `.rpg-panel`, `.rpg-section`, `.rpg-stat-bar`, `.rpg-btn-primary`, etc.
- CSS custom properties: `--rpg-bg`, `--rpg-accent`, `--rpg-text`, `--rpg-highlight`, `--rpg-border`, `--rpg-shadow` - CSS custom properties: `--rpg-bg`, `--rpg-accent`, `--rpg-text`, `--rpg-highlight`, `--rpg-border`, `--rpg-shadow`
### CSS build pipeline
CSS modules in `src/styles/` are concatenated in numeric order by `scripts/build-css.js`. Modules follow the naming convention `_NN_name.css` where NN is the load order. After modifying any CSS module, run `npm run build:css` to regenerate `style.css`.
### Validator factory
Input sanitization uses `createStringValidator()` from `src/utils/security.js` — a factory pattern that unifies `sanitizeLocationName`/`sanitizeItemName` with configurable rules (max length, allowed characters, etc.).
### Panel structure ### Panel structure
The main panel (`template.html`) has these sections in order: The main panel (`template.html`) has these sections in order:
@@ -290,7 +350,7 @@ The `skipInjectionsForGuided` setting controls tracker injection behavior during
2. **Relative import depth matters**: Files in `src/` use deeper relative paths (`../../../../../../script.js`) than `index.js` (`../../../../script.js`). When moving files between directories, adjust accordingly. 2. **Relative import depth matters**: Files in `src/` use deeper relative paths (`../../../../../../script.js`) than `index.js` (`../../../../script.js`). When moving files between directories, adjust accordingly.
3. **Settings merge**: On load, saved settings are deep-merged with defaults from `src/core/state.js`. New settings fields should be added to both the state module (runtime defaults) AND `src/core/config.js` (documentation defaults). 3. **Settings merge**: On load, saved settings are deep-merged with defaults. `config.js` is the single source of truth for default settings — `state.js` imports from it. New settings fields should be added to `config.js`.
4. **Chat metadata**: Per-chat tracker data lives in `chat_metadata.rpg_companion`. Per-swipe data lives on individual chat message objects in the `swipe_store`. 4. **Chat metadata**: Per-chat tracker data lives in `chat_metadata.rpg_companion`. Per-swipe data lives on individual chat message objects in the `swipe_store`.
@@ -302,9 +362,13 @@ The `skipInjectionsForGuided` setting controls tracker injection behavior during
8. **External API key**: Stored in `localStorage` as `rpg_companion_external_api_key`, NOT in extension settings (for security). 8. **External API key**: Stored in `localStorage` as `rpg_companion_external_api_key`, NOT in extension settings (for security).
9. **The `package.json` has no runtime dependencies**. The `devDependencies` (chokidar, fs-extra, glob) are only for the i18n validator. 9. **Dev dependencies**: `package.json` has devDependencies (Jest, Babel, chokidar, fs-extra, glob, execa) for testing and i18n validation. No runtime dependencies — the extension runs entirely in the browser.
10. **Deprecation status**: The README states this extension is deprecated in favor of Marinara Engine. The code shows a deprecation modal that displays on first load. 10. **CSS rebuild required**: After modifying any file in `src/styles/`, run `npm run build:css` to regenerate `style.css`. SillyTavern loads `style.css`, not the individual modules.
11. **Deprecation status**: The README states this extension is deprecated in favor of Marinara Engine. The code shows a deprecation modal that displays on first load.
12. **ESM testing**: Jest uses Babel for ESM transformation (`babel.config.json` + `jest.config.cjs`). All test files use ES module syntax.
--- ---
@@ -312,19 +376,25 @@ The `skipInjectionsForGuided` setting controls tracker injection behavior during
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `index.js` | Main entry point. All imports, UI init, event registration, startup logic. | | `index.js` | Main entry point. Imports, UI init, event registration, startup logic (~456 lines after refactor). |
| `manifest.json` | SillyTavern extension manifest (name, loading order, JS/CSS files). | | `manifest.json` | SillyTavern extension manifest (name, loading order, JS/CSS files). |
| `style.css` | All CSS (~12,300 lines). Themes, animations, responsive breakpoints. | | `style.css` | Built CSS output (concatenated from `src/styles/` modules). Do not edit directly. |
| `template.html` | Main panel HTML template. Rendered by SillyTavern's `renderExtensionTemplateAsync`. | | `template.html` | Main panel HTML template. Rendered by SillyTavern's `renderExtensionTemplateAsync`. |
| `settings.html` | Extensions tab settings drawer. Minimal — most settings are in the panel modal. | | `settings.html` | Extensions tab settings drawer. Minimal — most settings are in the panel modal. |
| `src/core/state.js` | Central state module. All extension-wide variables live here. | | `scripts/build-css.js` | CSS build script. Concatenates modules, supports `--minify` flag. |
| `src/core/state.js` | Central state module. Imports defaults from config.js. All extension-wide variables. |
| `src/core/persistence.js` | Settings/chat data save/load, version migrations. | | `src/core/persistence.js` | Settings/chat data save/load, version migrations. |
| `src/core/config.js` | Extension name, path detection, default settings documentation. | | `src/core/config.js` | Extension name, path detection, default settings (single source of truth). |
| `src/core/events.js` | SillyTavern event system wrapper with bulk register/unregister. | | `src/core/events.js` | SillyTavern event system wrapper with bulk register/unregister. |
| `src/core/i18n.js` | Translation loader and DOM applier. | | `src/core/i18n.js` | Translation loader and DOM applier. |
| `src/core/settingsPanel.js` | Extracted settings panel UI (from index.js refactor, PR #6). |
| `src/systems/integration/sillytavern.js` | All SillyTavern event handlers (message lifecycle, swipes, chat changes). | | `src/systems/integration/sillytavern.js` | All SillyTavern event handlers (message lifecycle, swipes, chat changes). |
| `src/systems/generation/promptBuilder.js` | Builds AI prompts for tracker generation. | | `src/systems/generation/promptBuilder.js` | Builds AI prompts for tracker generation (refactored with focused helpers). |
| `src/systems/generation/parser.js` | Parses AI responses to extract tracker JSON. | | `src/systems/generation/parser.js` | Parses AI responses to extract tracker JSON (~50 pre-compiled regex patterns). |
| `src/systems/generation/apiClient.js` | API client for separate/external generation modes. | | `src/systems/generation/apiClient.js` | API client for separate/external generation modes. |
| `src/systems/generation/injector.js` | Injects tracker prompts into SillyTavern's generation context. | | `src/systems/generation/injector.js` | Injects tracker prompts into SillyTavern's generation context. |
| `src/systems/rendering/renderUtils.js` | Rendering utilities: `updateIfChanged()` cache, rAF batching. |
| `src/systems/equipment/constants.js` | Equipment slot definitions, type limits, stat configuration. |
| `src/utils/security.js` | Input validation, sanitization, validator factory (`createStringValidator()`). |
| `src/utils/jsonRepair.js` | JSON repair for malformed AI responses (14 pre-compiled regex patterns). |
| `src/i18n/validator.js` | Node.js script for validating translation consistency. | | `src/i18n/validator.js` | Node.js script for validating translation consistency. |
+13
View File
@@ -0,0 +1,13 @@
# Branch Cleanup — July 13, 2026
The following stale remote branches were deleted, leaving only `main`:
- `SpicyMarinara-patch-1`
- `feature/equipment-system`
- `pr-109`
- `revert-111-main`
- `revert-116-revert-111-main`
- `revert-36-feat/v2-widget-dashboard-system`
- `revert-40-feat/responsive-dashboard-layout`
- `revert-59-main`
- `test-pr90-pr91-combined`
+47 -14
View File
@@ -2,16 +2,8 @@
An immersive RPG extension for browsers that tracks character stats, scene information, and character thoughts in a beautiful, customizable UI panel. All automated! Works with any preset. Choose between Together or Separate generation modes for context and generation control. An immersive RPG extension for browsers that tracks character stats, scene information, and character thoughts in a beautiful, customizable UI panel. All automated! Works with any preset. Choose between Together or Separate generation modes for context and generation control.
[![My Discord](https://img.shields.io/badge/Discord-Join%20Server-7289da)](https://discord.com/invite/KdAkTg94ME) [![Marinara Discord](https://img.shields.io/badge/Discord-Join%20Server-7289da)](https://discord.com/invite/KdAkTg94ME)
[![Support Me](https://img.shields.io/badge/Ko--fi-Support%20Creator-ff5e5b)](https://ko-fi.com/marinara_spaghetti) [![Support Original Author](https://img.shields.io/badge/Ko--fi-Support%20Creator-ff5e5b)](https://ko-fi.com/marinara_spaghetti)
## 🆕 What's New
### DEPRECATED
Moving on to developing the Marinara Engine frontend, the extension will now be maintained by the community!
<https://github.com/Pasta-Devs/Marinara-Engine>
## 📥 Installation ## 📥 Installation
@@ -21,7 +13,7 @@ Moving on to developing the Marinara Engine frontend, the extension will now be
3. Go to Install extension 3. Go to Install extension
4. Copy-paste this link: <https://github.com/SpicyMarinara/rpg-companion-sillytavern> 4. Copy-paste this link: <https://gitea.zephyre.one/Pakobbix/rpg-companion-sillytavern>
5. Press Install for all users/Install just for me 5. Press Install for all users/Install just for me
@@ -41,6 +33,7 @@ Moving on to developing the Marinara Engine frontend, the extension will now be
- **🎭 Floating Thought Bubbles**: Optional thought bubbles positioned next to character avatars in chat - **🎭 Floating Thought Bubbles**: Optional thought bubbles positioned next to character avatars in chat
- **🎲 Classic RPG Stats**: STR, DEX, CON, INT, WIS, CHA attributes with dice roll support - **🎲 Classic RPG Stats**: STR, DEX, CON, INT, WIS, CHA attributes with dice roll support
- **📦 Advanced Inventory System**: Multi-location storage (On Person, Stored locations, Assets) with v2 format - **📦 Advanced Inventory System**: Multi-location storage (On Person, Stored locations, Assets) with v2 format
- **⚔️ Equipment System**: 19 equipment slots (Helmet, Necklace, Body Armor, Gloves, Pants, Shoes, 10 Rings, 3 Accessories) with slot-type validation, stat bonuses (+99 max), and automatic slot assignment
- **🎯 Character Stats**: Track health, energy, or any custom stats for each present character with color interpolation - **🎯 Character Stats**: Track health, energy, or any custom stats for each present character with color interpolation
- **📜 Immersive HTML**: Enhance the immersion by including creative HTML/CSS/JS elements in your roleplay - **📜 Immersive HTML**: Enhance the immersion by including creative HTML/CSS/JS elements in your roleplay
- **➡️ Plot Progression**: Progress the plot with randomized events or natural progression with a click of a button - **➡️ Plot Progression**: Progress the plot with randomized events or natural progression with a click of a button
@@ -226,6 +219,49 @@ Choose from 6 beautiful themes:
If you ever have an awesome idea to do your own SillyTavern extension, don't. If you ever have an awesome idea to do your own SillyTavern extension, don't.
### Code Quality
- **index.js refactored**: 1567 lines → 456 lines (73% reduction) through modular extraction
- **CSS modularized**: 28 logical modules in `src/styles/` with automated build script
- **Settings consolidation**: `config.js` as single source of truth — state.js imports from it (~300 lines eliminated)
- **Validator factory pattern**: `createStringValidator()` unifies `sanitizeLocationName`/`sanitizeItemName`
- **Prompt builder refactored**: `getCharacterCardsInfo` extracted into focused helper functions
- **Error visibility**: `jsonRepair.js` silent failures now log to `console.debug`
### Performance
- **Rendering**: `requestAnimationFrame` batching in `rerenderRpgState()`, `updateIfChanged()` cache in `renderUtils.js`, CSS `content-visibility: auto` on inventory/equipment containers
- **Parser**: Regex pre-compilation (~64 patterns in `parser.js` + `jsonRepair.js` as module-level constants), format detection caching (`lastDetectedFormat` with 3-hit threshold), `clearFormatCache()` for invalidation
- **CSS**: Minification support (`--minify` flag in build script), extended CSS custom properties, theme refactoring (379 → ~220 lines), `style.css` reduced from 305.4 KB → 293.9 KB (3.8%), minified build adds ~50%+ further reduction
### Memory Management
- **`clearDomCache()`**: Invalidates jQuery object cache on panel rebuild to prevent stale references
- **Event tracking**: All `on()`/`once()` handlers tracked for cleanup via `unregisterAllEvents()`
- **`clearDebugLogs()`**: Called on `CHAT_CHANGED` to prevent unbounded debug log growth
### Testing
- **78 tests passing** (up from 54) using Jest with Babel ESM support
- ESM compatibility via `babel.config.json` + `jest.config.cjs` transform configuration
- Security-focused test suite (`security.test.js`, 24 tests)
### Development Commands
```bash
npm run build:css # Concatenate CSS modules
npm run build:css:min # Minified production build
npm test # Run Jest test suite
npm run validate_locale # Watch mode translation validation
npm run validate_locale_once
```
### Tech Stack
- `"type": "module"` — ES modules throughout
- Jest + Babel for testing with full ESM support
- Modular CSS build pipeline with minification support
## 🐛 Troubleshooting ## 🐛 Troubleshooting
### Extension doesn't appear ### Extension doesn't appear
@@ -291,6 +327,3 @@ SpicyMarinara, Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDea
--- ---
Made with ❤️ by Marinara Made with ❤️ by Marinara
PS I'm looking for a job or a sponsor to fund my custom AI frontend, contact me if interested:
[mgrabower97@gmail.com](mailto:mgrabower97@gmail.com)
+196
View File
@@ -0,0 +1,196 @@
import { createStringValidator, sanitizeLocationName, sanitizeItemName, validateStoredInventory, cleanItemString, MAX_ITEMS_PER_SECTION } from '../src/utils/security.js';
describe('createStringValidator', () => {
test('creates a validator that trims whitespace', () => {
const validator = createStringValidator({
label: 'test',
maxLength: 100,
checkBlockedNames: false
});
expect(validator(' hello ')).toBe('hello');
});
test('returns null for non-string input', () => {
const validator = createStringValidator({
label: 'test',
maxLength: 100
});
expect(validator(null)).toBeNull();
expect(validator(undefined)).toBeNull();
expect(validator(123)).toBeNull();
expect(validator('')).toBeNull();
});
test('truncates strings exceeding maxLength', () => {
const validator = createStringValidator({
label: 'test',
maxLength: 5
});
const result = validator('hello world');
expect(result).toBe('hello');
expect(result.length).toBe(5);
});
test('rejects blocked property names when checkBlockedNames is true', () => {
const validator = createStringValidator({
label: 'property',
maxLength: 100,
checkBlockedNames: true
});
expect(validator('__proto__')).toBeNull();
expect(validator('constructor')).toBeNull();
expect(validator('prototype')).toBeNull();
});
test('allows blocked property names when checkBlockedNames is false', () => {
const validator = createStringValidator({
label: 'item',
maxLength: 100,
checkBlockedNames: false
});
// __proto__ is allowed when checkBlockedNames is false
expect(validator('__proto__')).toBe('__proto__');
});
test('rejects additional values via additionalRejects', () => {
const validator = createStringValidator({
label: 'item',
maxLength: 100,
additionalRejects: ['none', 'null', 'undefined'],
checkBlockedNames: false
});
expect(validator('none')).toBeNull();
expect(validator('NONE')).toBeNull();
expect(validator('None')).toBeNull();
expect(validator('Sword')).toBe('Sword');
});
test('is case-insensitive for blocked names', () => {
const validator = createStringValidator({
label: 'test',
maxLength: 100,
checkBlockedNames: true
});
expect(validator('__PROTO__')).toBeNull();
expect(validator('__Proto__')).toBeNull();
expect(validator('CONSTRUCTOR')).toBeNull();
});
test('returns valid input unchanged', () => {
const validator = createStringValidator({
label: 'test',
maxLength: 100
});
expect(validator('ValidName')).toBe('ValidName');
expect(validator('another-valid-name')).toBe('another-valid-name');
});
});
describe('sanitizeLocationName', () => {
test('allows valid location names', () => {
expect(sanitizeLocationName('Home')).toBe('Home');
expect(sanitizeLocationName('Tavern')).toBe('Tavern');
expect(sanitizeLocationName('Forest Clearing')).toBe('Forest Clearing');
});
test('blocks dangerous property names', () => {
expect(sanitizeLocationName('__proto__')).toBeNull();
expect(sanitizeLocationName('constructor')).toBeNull();
expect(sanitizeLocationName('prototype')).toBeNull();
});
test('truncates overly long names', () => {
const longName = 'A'.repeat(250);
const result = sanitizeLocationName(longName);
expect(result.length).toBe(200);
});
test('returns null for invalid input', () => {
expect(sanitizeLocationName(null)).toBeNull();
expect(sanitizeLocationName('')).toBeNull();
expect(sanitizeLocationName(' ')).toBeNull();
});
});
describe('sanitizeItemName', () => {
test('allows valid item names', () => {
expect(sanitizeItemName('Sword')).toBe('Sword');
expect(sanitizeItemName('Health Potion')).toBe('Health Potion');
});
test('rejects "none" as an item name', () => {
expect(sanitizeItemName('none')).toBeNull();
expect(sanitizeItemName('None')).toBeNull();
expect(sanitizeItemName('NONE')).toBeNull();
});
test('truncates overly long names', () => {
const longName = 'A'.repeat(600);
const result = sanitizeItemName(longName);
expect(result.length).toBe(500);
});
test('returns null for invalid input', () => {
expect(sanitizeItemName(null)).toBeNull();
expect(sanitizeItemName('')).toBeNull();
});
});
describe('validateStoredInventory', () => {
test('returns cleaned inventory object', () => {
const input = { Home: 'Sword, Shield' };
const result = validateStoredInventory(input);
expect(result).toEqual({ Home: 'Sword, Shield' });
});
test('removes dangerous keys', () => {
const input = { Home: 'Sword' };
// __proto__ as a key is blocked by sanitizeLocationName
Object.setPrototypeOf(input, Object.create(null));
input['__proto__'] = 'malicious';
const result = validateStoredInventory(input);
// Result should not have __proto__ as an own property
expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(false);
expect(result).toHaveProperty('Home');
});
test('returns empty object for invalid input', () => {
expect(validateStoredInventory(null)).toEqual({});
expect(validateStoredInventory(undefined)).toEqual({});
expect(validateStoredInventory('string')).toEqual({});
expect(validateStoredInventory([])).toEqual({});
});
test('skips non-string values', () => {
const input = { Home: 123, Barn: 'Hay' };
const result = validateStoredInventory(input);
expect(result).not.toHaveProperty('Home');
expect(result).toHaveProperty('Barn');
});
});
describe('cleanItemString', () => {
test('returns clean item string', () => {
expect(cleanItemString('Sword, Shield')).toBe('Sword, Shield');
});
test('item sanitizer does not block __proto__ (by design - checkBlockedNames is false)', () => {
// sanitizeItemName has checkBlockedNames: false, so __proto__ is allowed as item name
// Only sanitizeLocationName blocks __proto__ (for object keys)
const result = cleanItemString('Sword, Shield');
expect(result).toContain('Sword');
});
test('strips markdown formatting', () => {
const result = cleanItemString('**Sword**, *Shield*');
expect(result).not.toContain('**');
expect(result).not.toContain('*');
});
});
describe('MAX_ITEMS_PER_SECTION', () => {
test('is a positive number', () => {
expect(MAX_ITEMS_PER_SECTION).toBeGreaterThan(0);
expect(typeof MAX_ITEMS_PER_SECTION).toBe('number');
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"presets": [
["@babel/preset-env", {
"targets": {
"node": "current"
}
}]
]
}
+188 -164
View File
@@ -55,7 +55,7 @@ import {
clearDebugLogs clearDebugLogs
} from './src/core/state.js'; } from './src/core/state.js';
import { loadSettings, saveSettings, saveChatData, loadChatData, updateMessageSwipeData, commitTrackerDataFromPriorMessage } from './src/core/persistence.js'; import { loadSettings, saveSettings, saveChatData, loadChatData, updateMessageSwipeData, commitTrackerDataFromPriorMessage } from './src/core/persistence.js';
import { registerAllEvents, on as onEvent } from './src/core/events.js'; import { registerAllEvents, on as onEvent, unregisterAllEvents } from './src/core/events.js';
import { addExtensionSettings } from './src/core/settingsPanel.js'; import { addExtensionSettings } from './src/core/settingsPanel.js';
// Generation & Parsing modules // Generation & Parsing modules
@@ -108,7 +108,6 @@ import {
setupDiceRoller, setupDiceRoller,
setupSettingsPopup, setupSettingsPopup,
updateDiceDisplay, updateDiceDisplay,
addDiceQuickReply,
getSettingsModal, getSettingsModal,
showWelcomeModalIfNeeded, showWelcomeModalIfNeeded,
showDeprecationModalIfNeeded showDeprecationModalIfNeeded
@@ -187,7 +186,7 @@ import {
} from './src/systems/integration/sillytavern.js'; } from './src/systems/integration/sillytavern.js';
// Settings UI event listeners (extracted from initUI) // Settings UI event listeners (extracted from initUI)
import { bindSettingsListeners, updateWeatherSubOptionsVisibility } from './src/systems/ui/settingsListeners.js'; import { bindSettingsListeners, initializeSettingsUIState } from './src/systems/ui/settingsListeners.js';
// Set up thought-based expressions refresh handler // Set up thought-based expressions refresh handler
setThoughtBasedExpressionsRefreshHandler(() => { setThoughtBasedExpressionsRefreshHandler(() => {
@@ -208,168 +207,11 @@ function updateDynamicLabels() {
} }
/** /**
* Initializes the UI for the extension. * Registers all SillyTavern event handlers for the extension.
* Idempotent per enable cycle: call unregisterAllEvents() before re-registering
* (e.g. when the extension is re-enabled after being disabled).
*/ */
async function initUI() { function registerExtensionEvents() {
await i18n.init();
if (!extensionSettings.enabled) {
return;
}
// Load and inject the HTML template
const templateHtml = await renderExtensionTemplateAsync(extensionName, 'template');
$('body').append(templateHtml);
// Add mobile toggle button (FAB)
const theme = extensionSettings.theme || 'default';
const mobileToggleHtml = `
<button id="rpg-mobile-toggle" class="rpg-mobile-toggle" data-theme="${theme}" title="Toggle RPG Panel">
<i class="fa-solid fa-dice-d20"></i>
</button>
`;
$('body').append(mobileToggleHtml);
if (window.innerWidth > 1000) {
$('#rpg-mobile-toggle').hide();
}
// Cache UI elements using state setters
// Clear stale DOM references first to prevent memory leaks on panel rebuild
clearDomCache();
setPanelContainer($('#rpg-companion-panel'));
setUserStatsContainer($('#rpg-user-stats'));
setInfoBoxContainer($('#rpg-info-box'));
setThoughtsContainer($('#rpg-thoughts'));
setInventoryContainer($('#rpg-inventory'));
setEquipmentContainer($('#rpg-equipment'));
setQuestsContainer($('#rpg-quests'));
setMusicPlayerContainer($('#rpg-music-player'));
// Re-apply translations to catch all new elements from the template
i18n.applyTranslations(document.body);
// Bind all settings event listeners (extracted to settingsListeners.js)
bindSettingsListeners($);
// Initialize mobile UI
setupMobileToggle();
constrainFabToViewport();
setupMobileTabs();
setupMobileKeyboardHandling();
setupContentEditableScrolling();
// Initialize desktop UI
setupDesktopTabs();
// Initialize collapse toggle
setupCollapseToggle();
// Initialize dice roller
setupDiceRoller();
// Initialize settings popup
setupSettingsPopup();
// Initialize tracker editor
initTrackerEditor();
// Initialize prompts editor
initPromptsEditor();
// Initialize plot buttons
setupPlotButtons(sendPlotProgression, openEncounterModal);
// Initialize classic stats buttons
setupClassicStatsButtons();
// Initialize inventory event listeners
initInventoryEventListeners();
initEquipmentEventListeners();
// Initialize chapter checkpoint UI
initChapterCheckpointUI();
injectCheckpointButton();
// Expose weather effect functions globally for cross-module access
if (!window.RPGCompanion) {
window.RPGCompanion = {};
}
window.RPGCompanion.updateWeatherEffect = updateWeatherEffect;
}
// Main initialization
jQuery(async () => {
try {
console.log('[RPG Companion] Starting initialization...');
// Load settings with validation
try {
loadSettings();
} catch (error) {
console.error('[RPG Companion] Settings load failed, continuing with defaults:', error);
}
// Check if migration to v3 JSON format is needed
try {
if (extensionSettings.settingsVersion < 3) {
await migrateToV3JSON();
updateExtensionSettings({ settingsVersion: 3 });
await saveSettings();
}
} catch (error) {
console.error('[RPG Companion] Migration to v3 failed:', error);
}
// Initialize i18n early for the settings panel
await i18n.init();
// Set up a central listener for language changes to update dynamic UI parts
i18n.addEventListener('languageChanged', updateDynamicLabels);
// Add extension settings to Extensions tab
try {
await addExtensionSettings($, renderExtensionTemplateAsync, clearExtensionPrompts, updateChatThoughts, cleanupCheckpointUI, clearThoughtBasedExpressionsCache, toggleDynamicWeather, initUI, loadChatData, scheduleChatStateRehydration, initThoughtBasedExpressions, injectCheckpointButton, updateAllCheckpointIndicators, removeAlternatePresentCharactersPanel);
} catch (error) {
console.error('[RPG Companion] Failed to add extension settings tab:', error);
}
// Initialize UI
try {
await initUI();
} catch (error) {
console.error('[RPG Companion] UI initialization failed:', error);
throw error;
}
// Load chat-specific data for current chat
try {
loadChatData();
scheduleChatStateRehydration();
initThoughtBasedExpressions();
updateFabWidgets();
updateStripWidgets();
} catch (error) {
console.error('[RPG Companion] Chat data load failed, using defaults:', error);
}
// Import cleaning regexes
try { await ensureHtmlCleaningRegex(st_extension_settings, saveSettingsDebounced); } catch (error) { console.error('[RPG Companion] HTML regex import failed:', error); }
try { await ensureTrackerCleaningRegex(st_extension_settings, saveSettingsDebounced); } catch (error) { console.error('[RPG Companion] Tracker cleaning regex import failed:', error); }
try { await ensureJsonCleaningRegex(st_extension_settings, saveSettingsDebounced); } catch (error) { console.error('[RPG Companion] JSON cleaning regex setup failed:', error); }
// Detect conflicting regex scripts
try {
detectConflictingRegexScripts(st_extension_settings);
} catch (error) {
console.error('[RPG Companion] Conflict detection failed:', error);
}
// Initialize history injection event listeners
try { initHistoryInjection(); } catch (error) { console.error('[RPG Companion] History injection init failed:', error); }
// Register all event listeners
try {
registerAllEvents({ registerAllEvents({
[event_types.MESSAGE_SENT]: onMessageSent, [event_types.MESSAGE_SENT]: onMessageSent,
[event_types.GENERATION_STARTED]: onGenerationStarted, [event_types.GENERATION_STARTED]: onGenerationStarted,
@@ -427,6 +269,188 @@ jQuery(async () => {
clearThoughtBasedExpressionsCache(); clearThoughtBasedExpressionsCache();
setTimeout(() => onThoughtBasedExpressionsChatChanged(), 0); setTimeout(() => onThoughtBasedExpressionsChatChanged(), 0);
}); });
}
/**
* Initializes the UI for the extension.
*/
async function initUI() {
await i18n.init();
if (!extensionSettings.enabled) {
return;
}
// Load and inject the HTML template
const templateHtml = await renderExtensionTemplateAsync(extensionName, 'template');
$('body').append(templateHtml);
// Add mobile toggle button (FAB)
const theme = extensionSettings.theme || 'default';
const mobileToggleHtml = `
<button id="rpg-mobile-toggle" class="rpg-mobile-toggle" data-theme="${theme}" title="Toggle RPG Panel">
<i class="fa-solid fa-dice-d20"></i>
</button>
`;
$('body').append(mobileToggleHtml);
if (window.innerWidth > 1000) {
$('#rpg-mobile-toggle').hide();
}
// Cache UI elements using state setters
// Clear stale DOM references first to prevent memory leaks on panel rebuild
clearDomCache();
setPanelContainer($('#rpg-companion-panel'));
setUserStatsContainer($('#rpg-user-stats'));
setInfoBoxContainer($('#rpg-info-box'));
setThoughtsContainer($('#rpg-thoughts'));
setInventoryContainer($('#rpg-inventory'));
setEquipmentContainer($('#rpg-equipment'));
setQuestsContainer($('#rpg-quests'));
setMusicPlayerContainer($('#rpg-music-player'));
// Re-apply translations to catch all new elements from the template
i18n.applyTranslations(document.body);
// Bind all settings event listeners (extracted to settingsListeners.js)
bindSettingsListeners($);
// Sync settings modal inputs with saved settings, then apply startup UI state
initializeSettingsUIState();
updatePanelVisibility();
updateSectionVisibility();
updateGenerationModeUI();
applyTheme();
applyPanelPosition();
toggleCustomColors();
toggleAnimations();
updateFeatureTogglesVisibility();
togglePlotButtons();
initWeatherEffects();
// Initialize mobile UI
setupMobileToggle();
constrainFabToViewport();
setupMobileTabs();
setupMobileKeyboardHandling();
setupContentEditableScrolling();
// Initialize desktop UI
setupDesktopTabs();
// Initialize collapse toggle
setupCollapseToggle();
// Initialize dice roller
setupDiceRoller();
// Initialize settings popup
setupSettingsPopup();
// Initialize tracker editor
initTrackerEditor();
// Initialize prompts editor
initPromptsEditor();
// Initialize plot buttons
setupPlotButtons(sendPlotProgression, openEncounterModal);
// Initialize classic stats buttons
setupClassicStatsButtons();
// Initialize inventory event listeners
initInventoryEventListeners();
initEquipmentEventListeners();
// Initialize chapter checkpoint UI
initChapterCheckpointUI();
injectCheckpointButton();
// Expose weather effect functions globally for cross-module access
if (!window.RPGCompanion) {
window.RPGCompanion = {};
}
window.RPGCompanion.updateWeatherEffect = updateWeatherEffect;
}
// Main initialization
jQuery(async () => {
try {
console.log('[RPG Companion] Starting initialization...');
// Load settings with validation
try {
await loadSettings();
} catch (error) {
console.error('[RPG Companion] Settings load failed, continuing with defaults:', error);
}
// Check if migration to v3 JSON format is needed
try {
if (extensionSettings.settingsVersion < 3) {
await migrateToV3JSON();
updateExtensionSettings({ settingsVersion: 3 });
await saveSettings();
}
} catch (error) {
console.error('[RPG Companion] Migration to v3 failed:', error);
}
// Initialize i18n early for the settings panel
await i18n.init();
// Set up a central listener for language changes to update dynamic UI parts
i18n.addEventListener('languageChanged', updateDynamicLabels);
// Add extension settings to Extensions tab
try {
await addExtensionSettings($, renderExtensionTemplateAsync, clearExtensionPrompts, updateChatThoughts, cleanupCheckpointUI, clearThoughtBasedExpressionsCache, toggleDynamicWeather, initUI, loadChatData, scheduleChatStateRehydration, initThoughtBasedExpressions, injectCheckpointButton, updateAllCheckpointIndicators, removeAlternatePresentCharactersPanel, registerExtensionEvents, unregisterAllEvents, initHistoryInjection);
} catch (error) {
console.error('[RPG Companion] Failed to add extension settings tab:', error);
}
// Initialize UI
try {
await initUI();
} catch (error) {
console.error('[RPG Companion] UI initialization failed:', error);
throw error;
}
// Load chat-specific data for current chat
try {
loadChatData();
scheduleChatStateRehydration();
initThoughtBasedExpressions();
updateFabWidgets();
updateStripWidgets();
// Ensure section visibility matches settings (equipment is hidden by CSS, needs explicit .show())
updateSectionVisibility();
renderEquipment();
} catch (error) {
console.error('[RPG Companion] Chat data load failed, using defaults:', error);
}
// Import cleaning regexes
try { await ensureHtmlCleaningRegex(st_extension_settings, saveSettingsDebounced); } catch (error) { console.error('[RPG Companion] HTML regex import failed:', error); }
try { await ensureTrackerCleaningRegex(st_extension_settings, saveSettingsDebounced); } catch (error) { console.error('[RPG Companion] Tracker cleaning regex import failed:', error); }
try { await ensureJsonCleaningRegex(st_extension_settings, saveSettingsDebounced); } catch (error) { console.error('[RPG Companion] JSON cleaning regex setup failed:', error); }
// Detect conflicting regex scripts
try {
detectConflictingRegexScripts(st_extension_settings);
} catch (error) {
console.error('[RPG Companion] Conflict detection failed:', error);
}
// Initialize history injection event listeners
try { initHistoryInjection(); } catch (error) { console.error('[RPG Companion] History injection init failed:', error); }
// Register all event listeners
try {
registerExtensionEvents();
} catch (error) { } catch (error) {
console.error('[RPG Companion] Event registration failed:', error); console.error('[RPG Companion] Event registration failed:', error);
throw error; throw error;
+4
View File
@@ -1,4 +1,8 @@
module.exports = { module.exports = {
testEnvironment: 'node', testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.js'], testMatch: ['**/__tests__/**/*.test.js'],
transform: {
'^.+\\.js$': 'babel-jest',
},
transformIgnorePatterns: ['/node_modules/'],
}; };
+1 -1
View File
@@ -58,7 +58,7 @@
</div> </div>
<div style="margin-top: 10px; text-align: center; opacity: 0.6; font-size: 0.85em;"> <div style="margin-top: 10px; text-align: center; opacity: 0.6; font-size: 0.85em;">
v3.7.2 v3.7.4
</div> </div>
</div> </div>
</div> </div>
+283 -15
View File
@@ -13,17 +13,20 @@ export const extensionName = 'third-party/rpg-companion-sillytavern';
* This supports both global (public/extensions) and user-specific (data/default-user/extensions) installations * This supports both global (public/extensions) and user-specific (data/default-user/extensions) installations
*/ */
const currentScriptPath = import.meta.url; const currentScriptPath = import.meta.url;
const isUserExtension = currentScriptPath.includes('/data/') || currentScriptPath.includes('\\data\\'); const isUserExtension = currentScriptPath.includes('/data/') || currentScriptPath.includes('\\\\data\\\\');
export const extensionFolderPath = isUserExtension export const extensionFolderPath = isUserExtension
? `data/default-user/extensions/${extensionName}` ? `data/default-user/extensions/${extensionName}`
: `scripts/extensions/${extensionName}`; : `scripts/extensions/${extensionName}`;
/** /**
* Default extension settings * Default extension settings — single source of truth for all setting defaults.
* Imported by state.js to initialize extensionSettings.
*/ */
export const defaultSettings = { export const defaultSettings = {
settingsVersion: 7, // Version number for settings migrations
enabled: true, enabled: true,
autoUpdate: true, debugMode: false, // Enable verbose debug logging (developer flag)
autoUpdate: false,
updateDepth: 4, // How many messages to include in the context updateDepth: 4, // How many messages to include in the context
generationMode: 'together', // 'separate' or 'together' - whether to generate with main response or separately generationMode: 'together', // 'separate' or 'together' - whether to generate with main response or separately
showUserStats: true, showUserStats: true,
@@ -33,37 +36,94 @@ export const defaultSettings = {
enableThoughtBasedExpressions: false, enableThoughtBasedExpressions: false,
hideDefaultExpressionDisplay: false, hideDefaultExpressionDisplay: false,
showInventory: true, // Show inventory section (v2 system) showInventory: true, // Show inventory section (v2 system)
showEquipment: true, // Show equipment section
showQuests: true, // Show quests section showQuests: true, // Show quests section
showLockIcons: true, // Show lock/unlock icons on tracker items showLockIcons: true, // Show lock/unlock icons on tracker items
showThoughtsInChat: true, // Show thoughts overlay in chat showThoughtsInChat: true, // Show thoughts overlay in chat
thoughtsInChatStyle: 'corner', // 'corner' or 'inline' thoughtsInChatStyle: 'corner', // 'corner' or 'inline'
narratorMode: false, // Use character card as narrator instead of fixed character references
customNarratorPrompt: '', // Custom narrator mode prompt text (empty = use default)
customContextInstructionsPrompt: '', // Custom context instructions prompt text (empty = use default)
enableHtmlPrompt: false, // Enable immersive HTML prompt injection enableHtmlPrompt: false, // Enable immersive HTML prompt injection
customHtmlPrompt: '', // Custom HTML prompt text (empty = use default)
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)
enableOmniscienceFilter: false, // Enable omniscience filter with <ofilter> tags
customOmnisciencePrompt: '', // Custom omniscience filter 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) enableSpotifyMusic: false, // Enable Spotify music integration (asks AI for Spotify URLs)
customSpotifyPrompt: '', // Custom Spotify prompt text (empty = use default) customSpotifyPrompt: '', // Custom Spotify prompt text (empty = use default)
// Controls when the extension skips injecting tracker instructions/examples/HTML
// into generations that appear to be user-injected instructions. Valid values: enableDynamicWeather: true, // Enable dynamic weather effects based on Info Box weather field (v2: enabled by default)
// - 'none' -> never skip (legacy behavior: always inject) weatherBackground: true, // Show weather effects in background (behind chat)
// - 'guided' -> skip for any guided / instruct or quiet_prompt generation weatherForeground: false, // Show weather effects in foreground (on top of chat)
// - 'impersonation' -> skip only for impersonation-style guided generations dismissedHolidayPromo: false, // User dismissed the holiday promotion banner
// This setting helps compatibility with other extensions like GuidedGenerations. showHtmlToggle: true, // Show Immersive HTML toggle in main panel
skipInjectionsForGuided: 'none', showDialogueColoringToggle: true, // Show Dialogue Coloring toggle in main panel (enabled by default)
enablePlotButtons: true, // Show plot progression buttons above chat input showDeceptionToggle: true, // Show Deception System toggle in main panel
saveTrackerHistory: false, // Save tracker data in chat history for each message showOmniscienceToggle: true, // Show Omniscience Filter 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
showNarratorMode: true, // Show Narrator Mode toggle in main panel
showAutoAvatars: true, // Show Auto-generate Avatars toggle in main panel
skipInjectionsForGuided: 'none', // skip injections for instruct injections and quiet prompts (GuidedGenerations compatibility)
enableRandomizedPlot: true, // Show randomized plot progression button above chat input
enableNaturalPlot: true, // Show natural plot progression button above chat input
// 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' panelPosition: 'right', // 'left', 'right', or 'top'
theme: 'default', // Theme: default, sci-fi, fantasy, cyberpunk, custom theme: 'default', // Theme: default, sci-fi, fantasy, cyberpunk, custom
customColors: { customColors: {
bg: '#1a1a2e', bg: '#1a1a2e',
bgOpacity: 100,
accent: '#16213e', accent: '#16213e',
accentOpacity: 100,
text: '#eaeaea', text: '#eaeaea',
highlight: '#e94560' textOpacity: 100,
highlight: '#e94560',
highlightOpacity: 100
}, },
statBarColorLow: '#cc3333', // Color for low stat values (red) statBarColorLow: '#cc3333', // Color for low stat values (red)
statBarColorLowOpacity: 100,
statBarColorHigh: '#33cc66', // Color for high stat values (green) statBarColorHigh: '#33cc66', // Color for high stat values (green)
statBarColorHighOpacity: 100,
enableAnimations: true, // Enable smooth animations for stats and content updates enableAnimations: true, // Enable smooth animations for stats and content updates
mobileFabPosition: { mobileFabPosition: {
top: 'calc(var(--topBarBlockSize) + 60px)', top: 'calc(var(--topBarBlockSize) + 60px)',
right: '12px' right: '12px'
}, // Saved position for mobile FAB button }, // Saved position for mobile FAB button
// 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: { userStats: {
health: 100, health: 100,
satiety: 100, satiety: 100,
@@ -72,14 +132,162 @@ export const defaultSettings = {
arousal: 0, arousal: 0,
mood: '😐', mood: '😐',
conditions: 'None', conditions: 'None',
/** @type {InventoryV2} */ skills: [],
inventory: { inventory: {
version: 2, version: 2,
onPerson: "None", onPerson: "None",
clothing: "None",
stored: {}, stored: {},
assets: "None" assets: "None"
},
equipment: {
items: [], // Array of {id, name, type, slot, stats: {str: 2, dex: 1, ...}, description}
slots: {
helmet: null,
ring1: null,
ring2: null,
ring3: null,
ring4: null,
ring5: null,
ring6: null,
ring7: null,
ring8: null,
ring9: null,
ring10: null,
necklace: null,
bodyArmor: null,
pants: null,
shoes: null,
gloves: null,
accessory1: null,
accessory2: null,
accessory3: null
}
} }
}, },
statNames: {
health: 'Health',
satiety: 'Satiety',
energy: 'Energy',
hygiene: 'Hygiene',
arousal: 'Arousal'
},
// 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, 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, 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
persistInHistory: false // Persist status in historical messages
},
// Optional skills field
skillsSection: {
enabled: false,
label: 'Skills', // User-editable
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', 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: {
// Fixed fields (always shown)
showEmoji: true,
showName: true,
// Relationship fields configuration
relationships: {
enabled: true,
// Relationship to emoji mapping (shown on character portraits)
relationshipEmojis: {
'Lover': '❤️',
'Friend': '⭐',
'Ally': '🤝',
'Enemy': '⚔️',
'Neutral': '⚖️'
}
},
// Legacy fields kept for backward compatibility
relationshipFields: ['Lover', 'Friend', 'Ally', 'Enemy', 'Neutral'],
relationshipEmojis: {
'Lover': '❤️',
'Friend': '⭐',
'Ally': '🤝',
'Enemy': '⚔️',
'Neutral': '⚖️'
},
// 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)', 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 from character\'s POV, up to three sentences long)',
persistInHistory: false
},
// Character stats toggle (optional feature)
characterStats: {
enabled: false,
customStats: [
{ id: 'health', name: 'Health', enabled: true },
{ id: 'arousal', name: 'Arousal', enabled: true }
]
}
}
},
quests: {
main: "None", // Current main quest title
optional: [] // Array of optional quest titles
},
infoBox: JSON.stringify({
date: { value: new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) },
weather: { emoji: '☀️', forecast: 'Clear skies' },
temperature: { value: 20, unit: 'C' },
time: { start: '00:00', end: '00:00' },
location: { value: 'Unknown Location' }
}, null, 2),
characterThoughts: JSON.stringify({
characters: []
}, null, 2),
level: 1, // User's character level
classicStats: { classicStats: {
str: 10, str: 10,
dex: 10, dex: 10,
@@ -89,5 +297,65 @@ export const defaultSettings = {
cha: 10 cha: 10
}, },
lastDiceRoll: null, // Store last dice roll result lastDiceRoll: null, // Store last dice roll result
collapsedInventoryLocations: [] // Array of collapsed storage location names showDiceDisplay: true, // Show the "Last Roll" display in the panel
collapsedInventoryLocations: [], // Array of collapsed storage location names
inventoryViewModes: {
onPerson: 'list', // 'list' or 'grid' view mode for On Person section
stored: 'list', // 'list' or 'grid' view mode for Stored section
assets: 'list' // 'list' or 'grid' view mode for Assets section
},
npcAvatars: {}, // Store custom avatar images for NPCs (key: character name, value: base64 data URI)
// Combat encounter settings
encounterSettings: {
enabled: true, // Show Start Encounter button above chat input
historyDepth: 8, // Number of recent messages to include in combat initialization
autoSaveLogs: false // Save detailed combat logs to file
},
// Auto avatar generation settings
autoGenerateAvatars: true, // Master toggle for auto-generating avatars
avatarLLMCustomInstruction: '', // Custom instruction for LLM prompt generation
// External API settings for 'external' generation mode
externalApiSettings: {
baseUrl: '', // OpenAI-compatible API base URL (e.g., "https://api.openai.com/v1")
// apiKey is NOT stored here for security. It is stored in localStorage('rpg_companion_external_api_key')
model: '', // Model identifier (e.g., "gpt-4o-mini")
maxTokens: 8192, // Maximum tokens for generation
temperature: 0.7 // Temperature setting for generation
},
// Lock state for tracker items (v3 JSON format feature)
lockedItems: {
stats: [], // Array of locked stat IDs (e.g., ["health", "satiety"])
skills: [], // Array of locked skill names (e.g., ["Cooking", "Swordsmanship"])
inventory: {
onPerson: [], // Array of locked item indices (e.g., [0, 2])
clothing: [], // Array of locked item indices
stored: {}, // Object with location keys, each containing array of locked indices (e.g., {"Home": [0, 1]})
assets: [] // Array of locked asset indices
},
quests: {
main: false, // Boolean for main quest lock
optional: [] // Array of locked optional quest indices (e.g., [0, 2])
},
infoBox: {
date: false, // Boolean for date widget lock
weather: false, // Boolean for weather widget lock
temperature: false, // Boolean for temperature widget lock
time: false, // Boolean for time widget lock
location: false, // Boolean for location widget lock
recentEvents: false // Boolean for recent events widget lock
},
characters: {} // Object mapping character names to their locked fields (e.g., {"Sarah": {relationship: true, thoughts: false}})
},
// 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
}
}; };
+12
View File
@@ -42,6 +42,18 @@ export function once(eventType, handler) {
*/ */
export function off(eventType, handler) { export function off(eventType, handler) {
eventSource.off(eventType, handler); eventSource.off(eventType, handler);
// Remove from tracking so unregisterAllEvents() doesn't hold a stale reference
const handlers = registeredHandlers.get(eventType);
if (handlers) {
const index = handlers.indexOf(handler);
if (index !== -1) {
handlers.splice(index, 1);
}
if (handlers.length === 0) {
registeredHandlers.delete(eventType);
}
}
} }
/** /**
+2 -2
View File
@@ -559,7 +559,7 @@ function validateSettings(settings) {
* Loads the extension settings from the global settings object. * Loads the extension settings from the global settings object.
* Automatically migrates v1 inventory to v2 format if needed. * Automatically migrates v1 inventory to v2 format if needed.
*/ */
export function loadSettings() { export async function loadSettings() {
try { try {
const context = getContext(); const context = getContext();
const extension_settings = context.extension_settings || context.extensionSettings; const extension_settings = context.extension_settings || context.extensionSettings;
@@ -601,7 +601,7 @@ export function loadSettings() {
// Migration to version 3: Convert text trackers to JSON format // Migration to version 3: Convert text trackers to JSON format
if (currentVersion < 3) { if (currentVersion < 3) {
// console.log('[RPG Companion] Migrating settings to version 3 (JSON tracker format)'); // console.log('[RPG Companion] Migrating settings to version 3 (JSON tracker format)');
migrateToV3JSON(); await migrateToV3JSON();
extensionSettings.settingsVersion = 3; extensionSettings.settingsVersion = 3;
settingsChanged = true; settingsChanged = true;
} }
+10 -1
View File
@@ -25,8 +25,11 @@ import { i18n } from './i18n.js';
* @param {Function} injectCheckpointButton - Add checkpoint buttons * @param {Function} injectCheckpointButton - Add checkpoint buttons
* @param {Function} updateAllCheckpointIndicators - Update checkpoint button states * @param {Function} updateAllCheckpointIndicators - Update checkpoint button states
* @param {Function} removeAlternatePresentCharactersPanel - Remove alt characters panel * @param {Function} removeAlternatePresentCharactersPanel - Remove alt characters panel
* @param {Function} registerExtensionEvents - (Re)register all SillyTavern event handlers
* @param {Function} unregisterAllEvents - Unregister all tracked SillyTavern event handlers
* @param {Function} initHistoryInjection - (Re)register history injection listeners
*/ */
export async function addExtensionSettings($, renderExtensionTemplateAsync, clearExtensionPrompts, updateChatThoughts, cleanupCheckpointUI, clearThoughtBasedExpressionsCache, toggleDynamicWeather, initUI, loadChatData, scheduleChatStateRehydration, initThoughtBasedExpressions, injectCheckpointButton, updateAllCheckpointIndicators, removeAlternatePresentCharactersPanel) { export async function addExtensionSettings($, renderExtensionTemplateAsync, clearExtensionPrompts, updateChatThoughts, cleanupCheckpointUI, clearThoughtBasedExpressionsCache, toggleDynamicWeather, initUI, loadChatData, scheduleChatStateRehydration, initThoughtBasedExpressions, injectCheckpointButton, updateAllCheckpointIndicators, removeAlternatePresentCharactersPanel, registerExtensionEvents, unregisterAllEvents, initHistoryInjection) {
const settingsHtml = await renderExtensionTemplateAsync(extensionName, 'settings'); const settingsHtml = await renderExtensionTemplateAsync(extensionName, 'settings');
$('#extensions_settings2').append(settingsHtml); $('#extensions_settings2').append(settingsHtml);
@@ -43,12 +46,18 @@ export async function addExtensionSettings($, renderExtensionTemplateAsync, clea
clearThoughtBasedExpressionsCache(); clearThoughtBasedExpressionsCache();
toggleDynamicWeather(false); toggleDynamicWeather(false);
// Unregister all tracked event handlers to avoid leaks while disabled
unregisterAllEvents();
$('#rpg-companion-panel').remove(); $('#rpg-companion-panel').remove();
$('#rpg-mobile-toggle').remove(); $('#rpg-mobile-toggle').remove();
$('#rpg-collapse-toggle').remove(); $('#rpg-collapse-toggle').remove();
$('#rpg-plot-buttons').remove(); $('#rpg-plot-buttons').remove();
removeAlternatePresentCharactersPanel(); removeAlternatePresentCharactersPanel();
} else if (extensionSettings.enabled && !wasEnabled) { } else if (extensionSettings.enabled && !wasEnabled) {
// Re-register event handlers that were unregistered on disable
registerExtensionEvents();
initHistoryInjection();
await initUI(); await initUI();
loadChatData(); loadChatData();
scheduleChatStateRehydration(); scheduleChatStateRehydration();
+4 -367
View File
@@ -3,347 +3,16 @@
* Centralizes all extension state variables * Centralizes all extension state variables
*/ */
import { defaultSettings } from './config.js';
// Type imports // Type imports
/** @typedef {import('../types/inventory.js').InventoryV2} InventoryV2 */ /** @typedef {import('../types/inventory.js').InventoryV2} InventoryV2 */
/** /**
* Extension settings - persisted to SillyTavern settings * Extension settings - persisted to SillyTavern settings
* Initialized from config.js defaultSettings (single source of truth)
*/ */
export let extensionSettings = { export let extensionSettings = { ...defaultSettings };
settingsVersion: 6, // Version number for settings migrations
enabled: true,
autoUpdate: false,
updateDepth: 4, // How many messages to include in the context
generationMode: 'together', // 'separate' or 'together' - whether to generate with main response or separately
showUserStats: true,
showInfoBox: true,
showCharacterThoughts: true,
showAlternatePresentCharactersPanel: false,
enableThoughtBasedExpressions: false,
hideDefaultExpressionDisplay: false,
showInventory: true, // Show inventory section (v2 system)
showEquipment: true, // Show equipment section
showQuests: true, // Show quests section
showThoughtsInChat: true, // Show thoughts overlay in chat
thoughtsInChatStyle: 'corner', // 'corner' or 'inline'
narratorMode: false, // Use character card as narrator instead of fixed character references
customNarratorPrompt: '', // Custom narrator mode prompt text (empty = use default)
customContextInstructionsPrompt: '', // Custom context instructions prompt text (empty = use default)
enableHtmlPrompt: false, // Enable immersive HTML prompt injection
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)
enableOmniscienceFilter: false, // Enable omniscience filter with <ofilter> tags
customOmnisciencePrompt: '', // Custom omniscience filter 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
showOmniscienceToggle: true, // Show Omniscience Filter 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
showNarratorMode: true, // Show Narrator Mode toggle in main panel
showAutoAvatars: true, // Show Auto-generate Avatars toggle in main panel
skipInjectionsForGuided: 'none', // skip injections for instruct injections and quiet prompts (GuidedGenerations compatibility)
enableRandomizedPlot: true, // Show randomized plot progression button above chat input
enableNaturalPlot: true, // Show natural plot progression button above chat input
// 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: {
bg: '#1a1a2e',
bgOpacity: 100,
accent: '#16213e',
accentOpacity: 100,
text: '#eaeaea',
textOpacity: 100,
highlight: '#e94560',
highlightOpacity: 100
},
statBarColorLow: '#cc3333', // Color for low stat values (red)
statBarColorLowOpacity: 100,
statBarColorHigh: '#33cc66', // Color for high stat values (green)
statBarColorHighOpacity: 100,
enableAnimations: true, // Enable smooth animations for stats and content updates
mobileFabPosition: {
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: {
health: 100,
satiety: 100,
energy: 100,
hygiene: 100,
arousal: 0,
mood: '😐',
conditions: 'None',
skills: [],
inventory: {
version: 2,
onPerson: "None",
clothing: "None",
stored: {},
assets: "None"
},
equipment: {
items: [], // Array of {id, name, type, slot, stats: {str: 2, dex: 1, ...}, description}
slots: {
helmet: null,
ring1: null,
ring2: null,
ring3: null,
ring4: null,
ring5: null,
ring6: null,
ring7: null,
ring8: null,
ring9: null,
ring10: null,
necklace: null,
bodyArmor: null,
pants: null,
shoes: null,
gloves: null,
accessory1: null,
accessory2: null,
accessory3: null
}
}
},
statNames: {
health: 'Health',
satiety: 'Satiety',
energy: 'Energy',
hygiene: 'Hygiene',
arousal: 'Arousal'
},
// 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, 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, 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
persistInHistory: false // Persist status in historical messages
},
// Optional skills field
skillsSection: {
enabled: false,
label: 'Skills', // User-editable
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', 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: {
// Fixed fields (always shown)
showEmoji: true,
showName: true,
// Relationship fields configuration
relationships: {
enabled: true,
// Relationship to emoji mapping (shown on character portraits)
relationshipEmojis: {
'Lover': '❤️',
'Friend': '⭐',
'Ally': '🤝',
'Enemy': '⚔️',
'Neutral': '⚖️'
}
},
// Legacy fields kept for backward compatibility
relationshipFields: ['Lover', 'Friend', 'Ally', 'Enemy', 'Neutral'],
relationshipEmojis: {
'Lover': '❤️',
'Friend': '⭐',
'Ally': '🤝',
'Enemy': '⚔️',
'Neutral': '⚖️'
},
// 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)', 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 from character\'s POV, up to three sentences long)',
persistInHistory: false
},
// Character stats toggle (optional feature)
characterStats: {
enabled: false,
customStats: [
{ id: 'health', name: 'Health', enabled: true },
{ id: 'arousal', name: 'Arousal', enabled: true }
]
}
}
},
quests: {
main: "None", // Current main quest title
optional: [] // Array of optional quest titles
},
infoBox: JSON.stringify({
date: { value: new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) },
weather: { emoji: '☀️', forecast: 'Clear skies' },
temperature: { value: 20, unit: 'C' },
time: { start: '00:00', end: '00:00' },
location: { value: 'Unknown Location' }
}, null, 2),
characterThoughts: JSON.stringify({
characters: []
}, null, 2),
level: 1, // User's character level
classicStats: {
str: 10,
dex: 10,
con: 10,
int: 10,
wis: 10,
cha: 10
},
lastDiceRoll: null, // Store last dice roll result
showDiceDisplay: true, // Show the "Last Roll" display in the panel
collapsedInventoryLocations: [], // Array of collapsed storage location names
inventoryViewModes: {
onPerson: 'list', // 'list' or 'grid' view mode for On Person section
stored: 'list', // 'list' or 'grid' view mode for Stored section
assets: 'list' // 'list' or 'grid' view mode for Assets section
},
npcAvatars: {}, // Store custom avatar images for NPCs (key: character name, value: base64 data URI)
// Combat encounter settings
encounterSettings: {
enabled: true, // Show Start Encounter button above chat input
historyDepth: 8, // Number of recent messages to include in combat initialization
autoSaveLogs: false // Save detailed combat logs to file
},
// Auto avatar generation settings
autoGenerateAvatars: true, // Master toggle for auto-generating avatars
avatarLLMCustomInstruction: '', // Custom instruction for LLM prompt generation
// External API settings for 'external' generation mode
externalApiSettings: {
baseUrl: '', // OpenAI-compatible API base URL (e.g., "https://api.openai.com/v1")
// apiKey is NOT stored here for security. It is stored in localStorage('rpg_companion_api_key')
model: '', // Model identifier (e.g., "gpt-4o-mini")
maxTokens: 8192, // Maximum tokens for generation
temperature: 0.7 // Temperature setting for generation
},
// Lock state for tracker items (v3 JSON format feature)
lockedItems: {
stats: [], // Array of locked stat IDs (e.g., ["health", "satiety"])
skills: [], // Array of locked skill names (e.g., ["Cooking", "Swordsmanship"])
inventory: {
onPerson: [], // Array of locked item indices (e.g., [0, 2])
clothing: [], // Array of locked item indices
stored: {}, // Object with location keys, each containing array of locked indices (e.g., {"Home": [0, 1]})
assets: [] // Array of locked asset indices
},
quests: {
main: false, // Boolean for main quest lock
optional: [] // Array of locked optional quest indices (e.g., [0, 2])
},
infoBox: {
date: false, // Boolean for date widget lock
weather: false, // Boolean for weather widget lock
temperature: false, // Boolean for temperature widget lock
time: false, // Boolean for time widget lock
location: false, // Boolean for location widget lock
recentEvents: false // Boolean for recent events widget lock
},
characters: {} // Object mapping character names to their locked fields (e.g., {"Sarah": {relationship: true, thoughts: false}})
},
// 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
}
};
/** /**
* Last generated data from AI response * Last generated data from AI response
@@ -618,36 +287,4 @@ export function clearDebugLogs() {
debugLogs.length = 0; debugLogs.length = 0;
} }
/**
* Tracks jQuery delegated event handlers for cleanup.
* Each handler is stored with its namespace for targeted unbinding.
*/
const jqueryEventHandlers = [];
/**
* Register a jQuery delegated event handler for tracking.
* @param {string} event - Event type with optional namespace (e.g., 'click.rpgCompanion')
* @param {string} selector - Delegated selector
* @param {Function} handler - Event handler function
* @returns {Function} The same handler for chaining
*/
export function trackJQueryHandler(event, selector, handler) {
jqueryEventHandlers.push({ event, selector, handler });
return handler;
}
/**
* Unbinds all tracked jQuery delegated event handlers.
* Call this when the extension is disabled or the panel is destroyed.
* @param {Function} $ - jQuery function (passed from caller)
*/
export function cleanupJQueryEvents($) {
for (const { event, selector, handler } of jqueryEventHandlers) {
if (handler) {
$(document).off(event, selector, handler);
} else {
$(document).off(event, selector);
}
}
jqueryEventHandlers.length = 0;
}
+11 -8
View File
@@ -1,7 +1,10 @@
const fs = require('fs-extra'); import fs from 'fs-extra';
const path = require('path'); import path from 'path';
const chokidar = require('chokidar'); import { fileURLToPath } from 'url';
const glob = require('glob'); import chokidar from 'chokidar';
import { globSync } from 'glob';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const COMPILED_DIR = __dirname // path.join(__dirname, 'compiled'); const COMPILED_DIR = __dirname // path.join(__dirname, 'compiled');
@@ -11,7 +14,7 @@ function findUnlocalizedText() {
console.log(`\n🔎 Scanning for unlocalized text in ${srcDir}...`); console.log(`\n🔎 Scanning for unlocalized text in ${srcDir}...`);
const files = glob.sync(`${srcDir}/**/*.{html,js,jsx}`, { const files = globSync(`${srcDir}/**/*.{html,js,jsx}`, {
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**'] ignore: ['**/node_modules/**', '**/dist/**', '**/build/**']
}); });
@@ -101,7 +104,7 @@ function validateTranslations() {
// Get all keys from reference locale // Get all keys from reference locale
const referenceKeys = Object.keys(translations[referenceLocale]); const referenceKeys = Object.keys(translations[referenceLocale]);
console.log(`🔢 Reference locale has ${referenceKeys.size} unique keys`); console.log(`🔢 Reference locale has ${referenceKeys.length} unique keys`);
// Track statistics // Track statistics
const stats = { const stats = {
@@ -127,7 +130,7 @@ function validateTranslations() {
// Check for missing keys // Check for missing keys
for (const key of referenceKeys) { for (const key of referenceKeys) {
if (!key in translations[locale]) { if (!(key in translations[locale])) {
stats.missingKeys[locale].push(key); stats.missingKeys[locale].push(key);
} else { } else {
// Check for type mismatches // Check for type mismatches
@@ -146,7 +149,7 @@ function validateTranslations() {
// Check for extra keys // Check for extra keys
for (const key of localeKeys) { for (const key of localeKeys) {
if (!key in translations[referenceLocale]) { if (!(key in translations[referenceLocale])) {
stats.extraKeys[locale].push(key); stats.extraKeys[locale].push(key);
} }
} }
+6 -3
View File
@@ -35,7 +35,10 @@ export const SLOTS_LIST = Object.entries(EQUIPMENT_CATEGORIES).flatMap(([type, d
*/ */
export function escapeHtml(text) { export function escapeHtml(text) {
if (!text) return ''; if (!text) return '';
const div = document.createElement('div'); return String(text)
div.textContent = text; .replace(/&/g, '&amp;')
return div.innerHTML; .replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
} }
+1 -10
View File
@@ -115,13 +115,4 @@ export function clearDiceRoll() {
updateDiceDisplay(); updateDiceDisplay();
} }
/**
* Adds the Roll Dice quick reply button.
*/
export function addDiceQuickReply() {
// Create quick reply button if Quick Replies exist
if (window.quickReplyApi) {
// Quick Reply API integration would go here
// For now, the dice display in the sidebar serves as the button
}
}
@@ -11,7 +11,6 @@ import { currentEncounter } from '../features/encounterState.js';
import { repairJSON } from '../../utils/jsonRepair.js'; import { repairJSON } from '../../utils/jsonRepair.js';
import { isPresentCharactersEnabled } from '../../utils/presentCharacters.js'; import { isPresentCharactersEnabled } from '../../utils/presentCharacters.js';
import { buildInventorySummary, generateTrackerInstructions, generateTrackerExample } from './promptBuilder.js'; import { buildInventorySummary, generateTrackerInstructions, generateTrackerExample } from './promptBuilder.js';
import { applyLocks } from './lockManager.js';
/** /**
* Gets character information from the current chat * Gets character information from the current chat
-4
View File
@@ -39,10 +39,6 @@ let currentSuppressionState = false;
// Type imports // Type imports
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */ /** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
// Track the latest user message we committed for to prevent duplicate commits
// when GENERATION_STARTED can fire multiple times for the same turn.
let lastCommittedUserMessageSignature = null;
// Store context map for prompt injection (used by event handlers) // Store context map for prompt injection (used by event handlers)
let pendingContextMap = new Map(); let pendingContextMap = new Map();
+1 -4
View File
@@ -3,8 +3,7 @@
* Helper functions for building JSON format tracker prompts * Helper functions for building JSON format tracker prompts
*/ */
import { extensionSettings, committedTrackerData } from '../../core/state.js'; import { extensionSettings } from '../../core/state.js';
import { getContext } from '../../../../../../extensions.js';
import { getWeatherKeywordsAsPromptString } from '../ui/weatherEffects.js'; import { getWeatherKeywordsAsPromptString } from '../ui/weatherEffects.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
@@ -39,7 +38,6 @@ function toFieldKey(name) {
* @returns {string} JSON format instruction for user stats * @returns {string} JSON format instruction for user stats
*/ */
export function buildUserStatsJSONInstruction() { export function buildUserStatsJSONInstruction() {
const userName = getContext().name1;
const trackerConfig = extensionSettings.trackerConfig; const trackerConfig = extensionSettings.trackerConfig;
const userStatsConfig = trackerConfig?.userStats; const userStatsConfig = trackerConfig?.userStats;
const enabledStats = userStatsConfig?.customStats?.filter(s => s && s.enabled && s.name) || []; const enabledStats = userStatsConfig?.customStats?.filter(s => s && s.enabled && s.name) || [];
@@ -184,7 +182,6 @@ export function buildInfoBoxJSONInstruction() {
* @returns {string} JSON format instruction for present characters * @returns {string} JSON format instruction for present characters
*/ */
export function buildCharactersJSONInstruction() { export function buildCharactersJSONInstruction() {
const userName = getContext().name1;
const presentCharsConfig = extensionSettings.trackerConfig?.presentCharacters; const presentCharsConfig = extensionSettings.trackerConfig?.presentCharacters;
const enabledFields = presentCharsConfig?.customFields?.filter(f => f && f.enabled && f.name) || []; const enabledFields = presentCharsConfig?.customFields?.filter(f => f && f.enabled && f.name) || [];
const relationshipsEnabled = presentCharsConfig?.relationships?.enabled !== false; const relationshipsEnabled = presentCharsConfig?.relationships?.enabled !== false;
+66 -53
View File
@@ -4,8 +4,8 @@
*/ */
import { getContext } from '../../../../../../extensions.js'; import { getContext } from '../../../../../../extensions.js';
import { chat, getCurrentChatDetails, characters, this_chid } from '../../../../../../../script.js'; import { chat, characters, this_chid } from '../../../../../../../script.js';
import { selected_group, getGroupMembers, getGroupChat, groups } from '../../../../../../group-chats.js'; import { selected_group, getGroupMembers, groups } from '../../../../../../group-chats.js';
import { extensionSettings, committedTrackerData, FEATURE_FLAGS } from '../../core/state.js'; import { extensionSettings, committedTrackerData, FEATURE_FLAGS } from '../../core/state.js';
import { import {
buildUserStatsJSONInstruction, buildUserStatsJSONInstruction,
@@ -78,83 +78,97 @@ async function getCharacterCardsInfo() {
// Narrator mode: use character card as narrator context, infer characters from story context // Narrator mode: use character card as narrator context, infer characters from story context
if (extensionSettings.narratorMode) { if (extensionSettings.narratorMode) {
if (this_chid !== undefined && characters && characters[this_chid]) { if (this_chid !== undefined && characters && characters[this_chid]) {
const character = characters[this_chid]; characterInfo = buildNarratorCardInfo(characters[this_chid]);
characterInfo += 'You are acting as the narrator for this story. The narrator card provides context for the story tone and style:\n\n';
characterInfo += `<narrator>\n`;
if (character.description) {
characterInfo += `${character.description}\n`;
}
if (character.personality) {
characterInfo += `${character.personality}\n`;
}
characterInfo += `</narrator>\n\n`;
// Use custom narrator prompt if available, otherwise use default
const narratorPrompt = extensionSettings.customNarratorPrompt || DEFAULT_NARRATOR_PROMPT;
characterInfo += narratorPrompt + '\n\n';
} }
return characterInfo; return characterInfo;
} }
// Check if in group chat // Check if in group chat
if (selected_group) { if (selected_group) {
// Find the current group directly from the groups array
const group = groups.find(g => g.id === selected_group);
const groupMembers = getGroupMembers(selected_group); const groupMembers = getGroupMembers(selected_group);
if (groupMembers && groupMembers.length > 0) { if (groupMembers && groupMembers.length > 0) {
characterInfo += 'Characters in this roleplay:\n\n'; const disabledMembers = groups.find(g => g.id === selected_group)?.disabled_members || [];
characterInfo = buildGroupCardInfo(groupMembers, disabledMembers);
}
} else if (this_chid !== undefined && characters && characters[this_chid]) {
// Single character chat
characterInfo = buildSingleCardInfo(characters[this_chid]);
}
// Filter out disabled (muted) members return characterInfo;
const disabledMembers = group?.disabled_members || []; }
// console.log('[RPG Companion] 🔍 Group ID:', selected_group, '| Disabled members:', disabledMembers);
/**
* Builds narrator mode character card information.
* @param {Object} character - Character card data
* @returns {string} Formatted narrator info
*/
function buildNarratorCardInfo(character) {
let info = '';
info += 'You are acting as the narrator for this story. The narrator card provides context for the story tone and style:\n\n';
info += `<narrator>\n`;
info += appendCharacterFields(character);
info += `</narrator>\n\n`;
info += (extensionSettings.customNarratorPrompt || DEFAULT_NARRATOR_PROMPT) + '\n\n';
return info;
}
/**
* Builds character card information for a group chat.
* @param {Array} groupMembers - Array of group member objects
* @param {Array} disabledMembers - Array of disabled (muted) member avatar filenames
* @returns {string} Formatted group character info
*/
function buildGroupCardInfo(groupMembers, disabledMembers) {
let info = 'Characters in this roleplay:\n\n';
let characterIndex = 0; let characterIndex = 0;
groupMembers.forEach((member) => { groupMembers.forEach((member) => {
if (!member || !member.name) return; if (!member || !member.name) return;
// Skip muted characters - check against avatar filename // Skip muted characters
if (member.avatar && disabledMembers.includes(member.avatar)) { if (member.avatar && disabledMembers.includes(member.avatar)) {
// console.log(`[RPG Companion] ❌ Skipping muted: ${member.name} (${member.avatar})`);
return; return;
} }
characterIndex++; characterIndex++;
characterInfo += `<character${characterIndex}="${member.name}">\n`; info += `<character${characterIndex}="${member.name}">\n`;
info += appendCharacterFields(member);
if (member.description) { info += `</character${characterIndex}>\n\n`;
characterInfo += `${member.description}\n`;
}
if (member.personality) {
characterInfo += `${member.personality}\n`;
}
characterInfo += `</character${characterIndex}>\n\n`;
}); });
}
} else if (this_chid !== undefined && characters && characters[this_chid]) {
// Single character chat
const character = characters[this_chid];
characterInfo += 'Character in this roleplay:\n\n'; return info;
characterInfo += `<character="${character.name}">\n`; }
/**
* Builds character card information for a single character chat.
* @param {Object} character - Character card data
* @returns {string} Formatted single character info
*/
function buildSingleCardInfo(character) {
let info = 'Character in this roleplay:\n\n';
info += `<character="${character.name}">\n`;
info += appendCharacterFields(character);
info += `</character>\n\n`;
return info;
}
/**
* Appends description and personality fields from a character object.
* Shared helper used by narrator, group, and single character info builders.
* @param {Object} character - Character object with optional description/personality fields
* @returns {string} Appended fields (empty string if neither present)
*/
function appendCharacterFields(character) {
let fields = '';
if (character.description) { if (character.description) {
characterInfo += `${character.description}\n`; fields += `${character.description}\n`;
} }
if (character.personality) { if (character.personality) {
characterInfo += `${character.personality}\n`; fields += `${character.personality}\n`;
} }
return fields;
characterInfo += `</character>\n\n`;
}
return characterInfo;
} }
/** /**
@@ -1232,7 +1246,6 @@ export async function generateSeparateUpdatePrompt() {
// /hide command automatically handles checkpoint filtering // /hide command automatically handles checkpoint filtering
// Add chat history as separate user/assistant messages with per-message historical context // Add chat history as separate user/assistant messages with per-message historical context
const recentMessages = chat.slice(-depth); const recentMessages = chat.slice(-depth);
const startIndex = chat.length - depth;
const position = historyPersistence?.injectionPosition || 'assistant_message_end'; const position = historyPersistence?.injectionPosition || 'assistant_message_end';
// Build a map of which messages should get context based on position setting // Build a map of which messages should get context based on position setting
+1 -1
View File
@@ -26,7 +26,7 @@ export function evaluateSuppression(extensionSettings, context, data) {
let instructContent = ''; let instructContent = '';
if (instructObj) { if (instructObj) {
if (typeof instructObj === 'object') { if (typeof instructObj === 'object') {
instructContent = String(instructObj.value || instructObj || ''); instructContent = String(instructObj.value || '');
} else { } else {
instructContent = String(instructObj); instructContent = String(instructObj);
} }
+1 -1
View File
@@ -4,7 +4,7 @@
*/ */
import { getContext } from '../../../../../../extensions.js'; import { getContext } from '../../../../../../extensions.js';
import { chat, chat_metadata, user_avatar, setExtensionPrompt, extension_prompt_types } from '../../../../../../../script.js'; import { chat, chat_metadata, user_avatar, setExtensionPrompt, extension_prompt_types, updateMessageBlock } from '../../../../../../../script.js';
// Core modules // Core modules
import { import {
@@ -5,7 +5,6 @@
import { extensionSettings, lastGeneratedData, committedTrackerData } from '../../core/state.js'; import { extensionSettings, lastGeneratedData, committedTrackerData } from '../../core/state.js';
import { saveSettings, saveChatData, updateMessageSwipeData } from '../../core/persistence.js'; import { saveSettings, saveChatData, updateMessageSwipeData } from '../../core/persistence.js';
import { buildInventorySummary } from '../generation/promptBuilder.js';
import { buildUserStatsText } from '../rendering/userStats.js'; import { buildUserStatsText } from '../rendering/userStats.js';
import { renderInventory, getLocationId } from '../rendering/inventory.js'; import { renderInventory, getLocationId } from '../rendering/inventory.js';
import { parseItems, serializeItems } from '../../utils/itemParser.js'; import { parseItems, serializeItems } from '../../utils/itemParser.js';
+19
View File
@@ -146,10 +146,29 @@ function generateEquipmentHTML() {
* Gets data from state/settings and updates DOM directly. * Gets data from state/settings and updates DOM directly.
*/ */
export function renderEquipment() { export function renderEquipment() {
// Ensure showEquipment defaults to true if undefined
if (extensionSettings.showEquipment === undefined) {
extensionSettings.showEquipment = true;
}
if (!$equipmentContainer || !extensionSettings.showEquipment) { if (!$equipmentContainer || !extensionSettings.showEquipment) {
return; return;
} }
// Ensure equipment data structure exists (defensive initialization)
if (!extensionSettings.userStats?.equipment) {
extensionSettings.userStats = extensionSettings.userStats || {};
extensionSettings.userStats.equipment = {
items: [],
slots: {
helmet: null, ring1: null, ring2: null, ring3: null, ring4: null,
ring5: null, ring6: null, ring7: null, ring8: null, ring9: null, ring10: null,
necklace: null, bodyArmor: null, pants: null, shoes: null, gloves: null,
accessory1: null, accessory2: null, accessory3: null
}
};
}
const html = generateEquipmentHTML(); const html = generateEquipmentHTML();
updateIfChanged($equipmentContainer, html, 'rpg-equipment'); updateIfChanged($equipmentContainer, html, 'rpg-equipment');
+1 -1
View File
@@ -10,7 +10,7 @@ import {
committedTrackerData, committedTrackerData,
$infoBoxContainer $infoBoxContainer
} from '../../core/state.js'; } from '../../core/state.js';
import { saveChatData, setMessageSwipeTrackerField } from '../../core/persistence.js'; import { saveChatData, saveSettings, setMessageSwipeTrackerField } from '../../core/persistence.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { isItemLocked } from '../generation/lockManager.js'; import { isItemLocked } from '../generation/lockManager.js';
import { repairJSON } from '../../utils/jsonRepair.js'; import { repairJSON } from '../../utils/jsonRepair.js';
+6 -3
View File
@@ -645,7 +645,10 @@ export function renderInventory() {
*/ */
function escapeHtml(text) { function escapeHtml(text) {
if (!text) return ''; if (!text) return '';
const div = document.createElement('div'); return String(text)
div.textContent = text; .replace(/&/g, '&amp;')
return div.innerHTML; .replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
} }
+7 -3
View File
@@ -57,9 +57,13 @@ function getLockIconHtml(tracker, path) {
* @returns {string} Escaped HTML * @returns {string} Escaped HTML
*/ */
function escapeHtml(text) { function escapeHtml(text) {
const div = document.createElement('div'); if (!text) return '';
div.textContent = text; return String(text)
return div.innerHTML; .replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
} }
/** /**
-15
View File
@@ -131,21 +131,6 @@ export function clearAllCaches() {
htmlCache.clear(); htmlCache.clear();
} }
/**
* Wraps an existing render function with change detection.
* The wrapped function generates HTML, compares it to the cache,
* and only updates the DOM if content changed.
*
* @param {Function} renderFn - Function that returns { $container, html } or calls updateIfChanged internally
* @param {string} cacheKey - Cache key for this render target
* @returns {Function} Wrapped render function
*/
export function withChangeDetection(renderFn, cacheKey) {
return function () {
return renderFn();
};
}
/** /**
* Batches multiple DOM updates into a single reflow by using * Batches multiple DOM updates into a single reflow by using
* document fragment pattern. Reduces layout thrashing. * document fragment pattern. Reduces layout thrashing.
-24
View File
@@ -1301,30 +1301,6 @@ export function updateCharacterField(characterName, field, value) {
// Note: Don't call renderThoughts() here - it would overwrite the user's edits // Note: Don't call renderThoughts() here - it would overwrite the user's edits
} }
/**
* Renders only the sidebar thoughts panel without updating chat bubbles
*/
function renderThoughtsSidebarOnly() {
if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) {
return;
}
// This is a simplified version that only updates the sidebar
// Copy the rendering logic from renderThoughts but skip the updateChatThoughts call
const thoughtsData = lastGeneratedData.characterThoughts || committedTrackerData.characterThoughts;
if (!thoughtsData) {
$thoughtsContainer.html('<div class="rpg-inventory-empty">' + (i18n.getTranslation('thoughts.empty') || 'No character data generated yet') + '</div>');
return;
}
// Re-render sidebar content (this would be the full logic from renderThoughts)
// For now, just call renderThoughts but set a flag
const originalShowInChat = extensionSettings.showThoughtsInChat;
extensionSettings.showThoughtsInChat = false;
renderThoughts();
extensionSettings.showThoughtsInChat = originalShowInChat;
}
/** /**
* Updates or removes thoughts shown in chat. * Updates or removes thoughts shown in chat.
* Renders either the original corner bubbles or inline dropdown cards. * Renders either the original corner bubbles or inline dropdown cards.
+1 -1
View File
@@ -143,7 +143,7 @@ function updateUserStatsData() {
if (!itemString) return []; if (!itemString) return [];
const items = itemString.split(',').map(s => s.trim()).filter(s => s); const items = itemString.split(',').map(s => s.trim()).filter(s => s);
return items.map(item => { return items.map(item => {
const qtyMatch = item.match(/^(\\d+)x\\s+(.+)$/); const qtyMatch = item.match(/^(\d+)x\s+(.+)$/);
if (qtyMatch) { if (qtyMatch) {
return { name: qtyMatch[2].trim(), quantity: parseInt(qtyMatch[1]) }; return { name: qtyMatch[2].trim(), quantity: parseInt(qtyMatch[1]) };
} }
-27
View File
@@ -339,31 +339,4 @@ function processExpandedButton(messageBlock) {
} }
/**
* Update the checkpoint button in an existing menu
* @param {HTMLElement} menu - The extraMesButtons or mes_buttons container
* @param {number} messageId - The message index
*/
function updateCheckpointButtonInMenu(menu, messageId) {
if (!menu) return;
// Find the checkpoint button (either dropdown or expanded)
const existingButton = menu.querySelector('.rpg-checkpoint-button, .rpg-checkpoint-button-expanded');
if (!existingButton) return;
const isCheckpoint = isCheckpointMessage(messageId);
// Update icon
const icon = existingButton.querySelector('i');
if (icon) {
icon.className = isCheckpoint ? 'fa-solid fa-bookmark' : 'fa-regular fa-bookmark';
icon.style.color = isCheckpoint ? '#4a9eff' : '';
}
// Update tooltip
existingButton.title = isCheckpoint
? 'Clear Chapter Start'
: 'Set Chapter Start — When bookmarked, this message will count as the first message in the chat history, skipping earlier ones.';
const translationKey = isCheckpoint ? 'checkpoint.clearChapterStart' : 'checkpoint.setChapterStart';
existingButton.setAttribute('data-i18n', translationKey);
}
+3 -2
View File
@@ -6,6 +6,7 @@
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { extensionSettings, lastGeneratedData, committedTrackerData } from '../../core/state.js'; import { extensionSettings, lastGeneratedData, committedTrackerData } from '../../core/state.js';
import { hexToRgba } from './theme.js'; import { hexToRgba } from './theme.js';
import { repairJSON } from '../../utils/jsonRepair.js';
/** /**
* Helper to parse time string and calculate clock hand angles * Helper to parse time string and calculate clock hand angles
@@ -50,7 +51,7 @@ export function updateStripWidgets() {
let infoData = null; let infoData = null;
if (infoBox) { if (infoBox) {
try { try {
infoData = typeof infoBox === 'string' ? JSON.parse(infoBox) : infoBox; infoData = typeof infoBox === 'string' ? repairJSON(infoBox) : infoBox;
} catch (e) { } catch (e) {
console.warn('[RPG Strip Widgets] Failed to parse infoBox:', e); console.warn('[RPG Strip Widgets] Failed to parse infoBox:', e);
} }
@@ -124,7 +125,7 @@ export function updateStripWidgets() {
const userStatsData = lastGeneratedData?.userStats || committedTrackerData?.userStats; const userStatsData = lastGeneratedData?.userStats || committedTrackerData?.userStats;
if (userStatsData) { if (userStatsData) {
try { try {
const parsedStats = typeof userStatsData === 'string' ? JSON.parse(userStatsData) : userStatsData; const parsedStats = typeof userStatsData === 'string' ? repairJSON(userStatsData) : userStatsData;
if (parsedStats?.stats) { if (parsedStats?.stats) {
allStats = parsedStats.stats; allStats = parsedStats.stats;
} }
+8 -435
View File
@@ -9,6 +9,7 @@ import { closeMobilePanelWithAnimation, updateCollapseToggleIcon } from './layou
import { setupDesktopTabs, removeDesktopTabs } from './desktop.js'; import { setupDesktopTabs, removeDesktopTabs } from './desktop.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { hexToRgba } from './theme.js'; import { hexToRgba } from './theme.js';
import { repairJSON } from '../../utils/jsonRepair.js';
/** /**
* Updates the text labels of the mobile navigation tabs based on the current language. * Updates the text labels of the mobile navigation tabs based on the current language.
@@ -503,7 +504,10 @@ export function setupMobileToggle() {
// top: $panel.css('top'), // top: $panel.css('top'),
// bottom: $panel.css('top'), // bottom: $panel.css('top'),
// transform: $panel.css('transform'), // transform: $panel.css('transform'),
// visibility: $panel.css('visibility')\n // }\n // });\n setupMobileTabs(); // visibility: $panel.css('visibility')
// }
// });
setupMobileTabs();
// Set initial icon for mobile // Set initial icon for mobile
updateCollapseToggleIcon(); updateCollapseToggleIcon();
// Show mobile toggle on mobile viewport // Show mobile toggle on mobile viewport
@@ -860,437 +864,6 @@ export function setupContentEditableScrolling() {
}); });
} }
/**
* Sets up the mobile refresh button with drag functionality.
* Same pattern as mobile toggle button.
* Tap = refresh, drag = reposition
*/
export function setupRefreshButtonDrag() {
const $refreshBtn = $('#rpg-manual-update-mobile');
if ($refreshBtn.length === 0) {
console.warn('[RPG Mobile] Refresh button not found in DOM');
return;
}
// console.log('[RPG Mobile] setupRefreshButtonDrag called');
// Load and apply saved position
if (extensionSettings.mobileRefreshPosition) {
const pos = extensionSettings.mobileRefreshPosition;
// console.log('[RPG Mobile] Loading saved refresh button position:', pos);
// Apply saved position
if (pos.top) $refreshBtn.css('top', pos.top);
if (pos.right) $refreshBtn.css('right', pos.right);
if (pos.bottom) $refreshBtn.css('bottom', pos.bottom);
if (pos.left) $refreshBtn.css('left', pos.left);
// Constrain to viewport after position is applied
requestAnimationFrame(() => constrainFabToViewport($refreshBtn));
}
// Touch/drag state
let isDragging = false;
let touchStartTime = 0;
let touchStartX = 0;
let touchStartY = 0;
let buttonStartX = 0;
let buttonStartY = 0;
const LONG_PRESS_DURATION = 200;
const MOVE_THRESHOLD = 10;
let rafId = null;
let pendingX = null;
let pendingY = null;
// Update position using requestAnimationFrame
function updatePosition() {
if (pendingX !== null && pendingY !== null) {
$refreshBtn.css({
left: pendingX + 'px',
top: pendingY + 'px',
right: 'auto',
bottom: 'auto'
});
pendingX = null;
pendingY = null;
}
rafId = null;
}
// Touch start
$refreshBtn.on('touchstart', function(e) {
const touch = e.originalEvent.touches[0];
touchStartTime = Date.now();
touchStartX = touch.clientX;
touchStartY = touch.clientY;
const offset = $refreshBtn.offset();
buttonStartX = offset.left;
buttonStartY = offset.top;
isDragging = false;
});
// Touch move
$refreshBtn.on('touchmove', function(e) {
const touch = e.originalEvent.touches[0];
const deltaX = touch.clientX - touchStartX;
const deltaY = touch.clientY - touchStartY;
const timeSinceStart = Date.now() - touchStartTime;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (!isDragging && (timeSinceStart > LONG_PRESS_DURATION || distance > MOVE_THRESHOLD)) {
isDragging = true;
$refreshBtn.addClass('dragging');
}
if (isDragging) {
e.preventDefault();
let newX = buttonStartX + deltaX;
let newY = buttonStartY + deltaY;
const buttonWidth = $refreshBtn.outerWidth();
const buttonHeight = $refreshBtn.outerHeight();
const minX = 10;
const maxX = window.innerWidth - buttonWidth - 10;
const minY = 10;
const maxY = window.innerHeight - buttonHeight - 10;
newX = Math.max(minX, Math.min(maxX, newX));
newY = Math.max(minY, Math.min(maxY, newY));
pendingX = newX;
pendingY = newY;
if (!rafId) {
rafId = requestAnimationFrame(updatePosition);
}
}
});
// Touch end
$refreshBtn.on('touchend', function(e) {
if (isDragging) {
// Save new position
const offset = $refreshBtn.offset();
const newPosition = {
left: offset.left + 'px',
top: offset.top + 'px'
};
extensionSettings.mobileRefreshPosition = newPosition;
saveSettings();
setTimeout(() => {
$refreshBtn.removeClass('dragging');
}, 50);
// Set flag to prevent click handler from firing
$refreshBtn.data('just-dragged', true);
setTimeout(() => {
$refreshBtn.data('just-dragged', false);
}, 100);
isDragging = false;
}
});
// Mouse support for desktop
let mouseDown = false;
$refreshBtn.on('mousedown', function(e) {
e.preventDefault();
touchStartTime = Date.now();
touchStartX = e.clientX;
touchStartY = e.clientY;
const offset = $refreshBtn.offset();
buttonStartX = offset.left;
buttonStartY = offset.top;
mouseDown = true;
isDragging = false;
});
$(document).on('mousemove', function(e) {
if (!mouseDown) return;
const deltaX = e.clientX - touchStartX;
const deltaY = e.clientY - touchStartY;
const timeSinceStart = Date.now() - touchStartTime;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (!isDragging && (timeSinceStart > LONG_PRESS_DURATION || distance > MOVE_THRESHOLD)) {
isDragging = true;
$refreshBtn.addClass('dragging');
}
if (isDragging) {
let newX = buttonStartX + deltaX;
let newY = buttonStartY + deltaY;
const buttonWidth = $refreshBtn.outerWidth();
const buttonHeight = $refreshBtn.outerHeight();
const minX = 10;
const maxX = window.innerWidth - buttonWidth - 10;
const minY = 10;
const maxY = window.innerHeight - buttonHeight - 10;
newX = Math.max(minX, Math.min(maxX, newX));
newY = Math.max(minY, Math.min(maxY, newY));
pendingX = newX;
pendingY = newY;
if (!rafId) {
rafId = requestAnimationFrame(updatePosition);
}
}
});
$(document).on('mouseup', function(e) {
if (mouseDown && isDragging) {
const offset = $refreshBtn.offset();
const newPosition = {
left: offset.left + 'px',
top: offset.top + 'px'
};
extensionSettings.mobileRefreshPosition = newPosition;
saveSettings();
setTimeout(() => {
$refreshBtn.removeClass('dragging');
}, 50);
$refreshBtn.data('just-dragged', true);
setTimeout(() => {
$refreshBtn.data('just-dragged', false);
}, 100);
}
mouseDown = false;
isDragging = false;
});
}
/**
* Sets up drag functionality for the debug toggle FAB button
* Same pattern as refresh button drag
*/
export function setupDebugButtonDrag() {
const $debugBtn = $('#rpg-debug-toggle');
if ($debugBtn.length === 0) {
console.warn('[RPG Mobile] Debug button not found in DOM');
return;
}
// console.log('[RPG Mobile] setupDebugButtonDrag called');
// Load and apply saved position
if (extensionSettings.debugFabPosition) {
const pos = extensionSettings.debugFabPosition;
// console.log('[RPG Mobile] Loading saved debug button position:', pos);
// Apply saved position
if (pos.top) $debugBtn.css('top', pos.top);
if (pos.right) $debugBtn.css('right', pos.right);
if (pos.bottom) $debugBtn.css('bottom', pos.bottom);
if (pos.left) $debugBtn.css('left', pos.left);
// Constrain to viewport after position is applied
requestAnimationFrame(() => constrainFabToViewport($debugBtn));
}
// Touch/drag state
let isDragging = false;
let touchStartTime = 0;
let touchStartX = 0;
let touchStartY = 0;
let buttonStartX = 0;
let buttonStartY = 0;
const LONG_PRESS_DURATION = 200;
const MOVE_THRESHOLD = 10;
let rafId = null;
let pendingX = null;
let pendingY = null;
// Update position using requestAnimationFrame
function updatePosition() {
if (pendingX !== null && pendingY !== null) {
$debugBtn.css({
left: pendingX + 'px',
top: pendingY + 'px',
right: 'auto',
bottom: 'auto'
});
pendingX = null;
pendingY = null;
}
rafId = null;
}
// Touch start
$debugBtn.on('touchstart', function(e) {
const touch = e.originalEvent.touches[0];
touchStartTime = Date.now();
touchStartX = touch.clientX;
touchStartY = touch.clientY;
const offset = $debugBtn.offset();
buttonStartX = offset.left;
buttonStartY = offset.top;
isDragging = false;
});
// Touch move
$debugBtn.on('touchmove', function(e) {
const touch = e.originalEvent.touches[0];
const deltaX = touch.clientX - touchStartX;
const deltaY = touch.clientY - touchStartY;
const timeSinceStart = Date.now() - touchStartTime;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (!isDragging && (timeSinceStart > LONG_PRESS_DURATION || distance > MOVE_THRESHOLD)) {
isDragging = true;
$debugBtn.addClass('dragging');
}
if (isDragging) {
e.preventDefault();
let newX = buttonStartX + deltaX;
let newY = buttonStartY + deltaY;
const buttonWidth = $debugBtn.outerWidth();
const buttonHeight = $debugBtn.outerHeight();
const minX = 10;
const maxX = window.innerWidth - buttonWidth - 10;
const minY = 10;
const maxY = window.innerHeight - buttonHeight - 10;
newX = Math.max(minX, Math.min(maxX, newX));
newY = Math.max(minY, Math.min(maxY, newY));
pendingX = newX;
pendingY = newY;
if (!rafId) {
rafId = requestAnimationFrame(updatePosition);
}
}
});
// Touch end
$debugBtn.on('touchend', function(e) {
if (isDragging) {
// Save new position
const offset = $debugBtn.offset();
const newPosition = {
left: offset.left + 'px',
top: offset.top + 'px'
};
extensionSettings.debugFabPosition = newPosition;
saveSettings();
setTimeout(() => {
$debugBtn.removeClass('dragging');
}, 50);
// Set flag to prevent click handler from firing
$debugBtn.data('just-dragged', true);
setTimeout(() => {
$debugBtn.data('just-dragged', false);
}, 100);
isDragging = false;
}
});
// Mouse support for desktop
let mouseDown = false;
$debugBtn.on('mousedown', function(e) {
e.preventDefault();
touchStartTime = Date.now();
touchStartX = e.clientX;
touchStartY = e.clientY;
const offset = $debugBtn.offset();
buttonStartX = offset.left;
buttonStartY = offset.top;
mouseDown = true;
isDragging = false;
});
$(document).on('mousemove.rpgDebugDrag', function(e) {
if (!mouseDown) return;
const deltaX = e.clientX - touchStartX;
const deltaY = e.clientY - touchStartY;
const timeSinceStart = Date.now() - touchStartTime;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (!isDragging && (timeSinceStart > LONG_PRESS_DURATION || distance > MOVE_THRESHOLD)) {
isDragging = true;
$debugBtn.addClass('dragging');
}
if (isDragging) {
let newX = buttonStartX + deltaX;
let newY = buttonStartY + deltaY;
const buttonWidth = $debugBtn.outerWidth();
const buttonHeight = $debugBtn.outerHeight();
const minX = 10;
const maxX = window.innerWidth - buttonWidth - 10;
const minY = 10;
const maxY = window.innerHeight - buttonHeight - 10;
newX = Math.max(minX, Math.min(maxX, newX));
newY = Math.max(minY, Math.min(maxY, newY));
pendingX = newX;
pendingY = newY;
if (!rafId) {
rafId = requestAnimationFrame(updatePosition);
}
}
});
$(document).on('mouseup.rpgDebugDrag', function(e) {
if (mouseDown && isDragging) {
const offset = $debugBtn.offset();
const newPosition = {
left: offset.left + 'px',
top: offset.top + 'px'
};
extensionSettings.debugFabPosition = newPosition;
saveSettings();
setTimeout(() => {
$debugBtn.removeClass('dragging');
}, 50);
$debugBtn.data('just-dragged', true);
setTimeout(() => {
$debugBtn.data('just-dragged', false);
}, 100);
}
mouseDown = false;
isDragging = false;
});
}
// ============================================ // ============================================
// FAB WIDGETS - Info display around FAB button // FAB WIDGETS - Info display around FAB button
// ============================================ // ============================================
@@ -1322,7 +895,7 @@ export function updateFabWidgets() {
let infoData = null; let infoData = null;
if (infoBox) { if (infoBox) {
try { try {
infoData = typeof infoBox === 'string' ? JSON.parse(infoBox) : infoBox; infoData = typeof infoBox === 'string' ? repairJSON(infoBox) : infoBox;
} catch (e) { } catch (e) {
console.warn('[RPG FAB Widgets] Failed to parse infoBox:', e); console.warn('[RPG FAB Widgets] Failed to parse infoBox:', e);
} }
@@ -1332,7 +905,7 @@ export function updateFabWidgets() {
let statsData = null; let statsData = null;
if (userStats) { if (userStats) {
try { try {
statsData = typeof userStats === 'string' ? JSON.parse(userStats) : userStats; statsData = typeof userStats === 'string' ? repairJSON(userStats) : userStats;
} catch (e) { } catch (e) {
console.warn('[RPG FAB Widgets] Failed to parse userStats:', e); console.warn('[RPG FAB Widgets] Failed to parse userStats:', e);
} }
@@ -1444,7 +1017,7 @@ export function updateFabWidgets() {
let allStats = []; let allStats = [];
try { try {
const userStatsJson = extensionSettings.userStats; const userStatsJson = extensionSettings.userStats;
const parsedUserStats = typeof userStatsJson === 'string' ? JSON.parse(userStatsJson) : userStatsJson; const parsedUserStats = typeof userStatsJson === 'string' ? repairJSON(userStatsJson) : userStatsJson;
if (parsedUserStats?.stats) { if (parsedUserStats?.stats) {
allStats = parsedUserStats.stats; allStats = parsedUserStats.stats;
} }
+1 -10
View File
@@ -26,8 +26,7 @@ import { renderEquipment } from '../rendering/equipment.js';
import { import {
rollDice as rollDiceCore, rollDice as rollDiceCore,
clearDiceRoll as clearDiceRollCore, clearDiceRoll as clearDiceRollCore,
updateDiceDisplay as updateDiceDisplayCore, updateDiceDisplay as updateDiceDisplayCore
addDiceQuickReply as addDiceQuickReplyCore
} from '../features/dice.js'; } from '../features/dice.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
@@ -578,14 +577,6 @@ export function updateDiceDisplay() {
updateDiceDisplayCore(); updateDiceDisplayCore();
} }
/**
* Adds the Roll Dice quick reply button.
* Backwards compatible wrapper for dice module.
*/
export function addDiceQuickReply() {
addDiceQuickReplyCore();
}
/** /**
* Returns the SettingsModal instance for external use * Returns the SettingsModal instance for external use
* @returns {SettingsModal} The global SettingsModal instance * @returns {SettingsModal} The global SettingsModal instance
+657 -171
View File
@@ -4,12 +4,39 @@
* Extracted from index.js initUI() to reduce main entry point size. * Extracted from index.js initUI() to reduce main entry point size.
*/ */
import { extensionSettings, updateExtensionSettings } from '../../core/state.js'; import { getContext } from '../../../../../../extensions.js';
import { saveSettings } from '../../core/persistence.js'; import { extensionSettings, $musicPlayerContainer } from '../../core/state.js';
import { saveSettings, commitTrackerDataFromPriorMessage } from '../../core/persistence.js';
/* eslint-disable no-unused-vars */ import {
/* These functions receive jQuery-wrapped DOM elements from initUI() */ applyPanelPosition,
/* eslint-disable no-unused-vars */ updateGenerationModeUI,
updateSectionVisibility,
togglePlotButtons
} from './layout.js';
import { renderThoughts, updateChatThoughts } from '../rendering/thoughts.js';
import { renderUserStats } from '../rendering/userStats.js';
import { renderInfoBox } from '../rendering/infoBox.js';
import { renderInventory } from '../rendering/inventory.js';
import { renderEquipment } from '../rendering/equipment.js';
import { renderQuests } from '../rendering/quests.js';
import { renderMusicPlayer } from '../rendering/musicPlayer.js';
import { toggleDynamicWeather } from './weatherEffects.js';
import {
applyTheme,
updateSettingsPopupTheme,
toggleCustomColors,
applyCustomTheme,
updateFeatureTogglesVisibility
} from './theme.js';
import {
onAlternatePresentCharactersVisibilityChanged,
onThoughtBasedExpressionsSettingChanged,
onHideDefaultExpressionDisplaySettingChanged
} from '../integration/thoughtBasedExpressions.js';
import { updateStripWidgets } from './desktop.js';
import { updateFabWidgets } from './mobile.js';
import { updateDiceDisplay, getSettingsModal } from './modals.js';
import { updateRPGData, testExternalAPIConnection } from '../generation/apiClient.js';
/** /**
* Bind all settings panel event listeners. * Bind all settings panel event listeners.
@@ -29,6 +56,7 @@ export function bindSettingsListeners($) {
extensionSettings.panelPosition = String($(this).val()); extensionSettings.panelPosition = String($(this).val());
saveSettings(); saveSettings();
applyPanelPosition(); applyPanelPosition();
// Recreate thought bubbles to update their position
updateChatThoughts(); updateChatThoughts();
}); });
@@ -47,11 +75,11 @@ export function bindSettingsListeners($) {
}); });
// Section visibility toggles // Section visibility toggles
bindSectionToggle($, 'rpg-toggle-user-stats', 'showUserStats', () => {}); bindSectionToggle($, 'rpg-toggle-user-stats', 'showUserStats');
bindSectionToggle($, 'rpg-toggle-info-box', 'showInfoBox', () => {}); bindSectionToggle($, 'rpg-toggle-info-box', 'showInfoBox');
bindSectionToggle($, 'rpg-toggle-inventory', 'showInventory', () => {}); bindSectionToggle($, 'rpg-toggle-inventory', 'showInventory');
bindSectionToggle($, 'rpg-toggle-equipment', 'showEquipment', () => {}); bindSectionToggle($, 'rpg-toggle-equipment', 'showEquipment');
bindSectionToggle($, 'rpg-toggle-quests', 'showQuests', () => {}); bindSectionToggle($, 'rpg-toggle-quests', 'showQuests');
// Thoughts section with render callback // Thoughts section with render callback
$('#rpg-toggle-thoughts').on('change', function() { $('#rpg-toggle-thoughts').on('change', function() {
@@ -160,25 +188,12 @@ export function bindSettingsListeners($) {
saveSettings(); saveSettings();
}); });
// Dismiss holiday promo
$('#rpg-dismiss-promo').on('click', function() {
extensionSettings.dismissedHolidayPromo = true;
saveSettings();
$('#rpg-holiday-promo').fadeOut(300);
});
// Skip injections for guided mode // Skip injections for guided mode
$('#rpg-skip-guided-mode').on('change', function() { $('#rpg-skip-guided-mode').on('change', function() {
extensionSettings.skipInjectionsForGuided = String($(this).val()); extensionSettings.skipInjectionsForGuided = String($(this).val());
saveSettings(); saveSettings();
}); });
// Save tracker history
$('#rpg-save-tracker-history').on('change', function() {
extensionSettings.saveTrackerHistory = $(this).prop('checked');
saveSettings();
});
// Randomized plot // Randomized plot
$('#rpg-toggle-randomized-plot').on('change', function() { $('#rpg-toggle-randomized-plot').on('change', function() {
extensionSettings.enableRandomizedPlot = $(this).prop('checked'); extensionSettings.enableRandomizedPlot = $(this).prop('checked');
@@ -200,7 +215,7 @@ export function bindSettingsListeners($) {
} }
extensionSettings.encounterSettings.enabled = $(this).prop('checked'); extensionSettings.encounterSettings.enabled = $(this).prop('checked');
saveSettings(); saveSettings();
togglePlotButtons(); togglePlotButtons(); // This also controls encounter button visibility
}); });
// Encounter history depth // Encounter history depth
@@ -222,171 +237,642 @@ export function bindSettingsListeners($) {
saveSettings(); saveSettings();
}); });
// Combat narrative style settings // Feature toggle visibility controls
bindCombatNarrativeSetting($, 'rpg-combat-tense', 'tense'); $('#rpg-toggle-show-html-toggle').on('change', function() {
bindCombatNarrativeSetting($, 'rpg-combat-person', 'person'); extensionSettings.showHtmlToggle = $(this).prop('checked');
bindCombatNarrativeSetting($, 'rpg-combat-narration', 'narration');
bindCombatNarrativeSetting($, 'rpg-combat-pov', 'pov');
// Summary narrative style settings
bindSummaryNarrativeSetting($, 'rpg-summary-tense', 'tense');
bindSummaryNarrativeSetting($, 'rpg-summary-person', 'person');
bindSummaryNarrativeSetting($, 'rpg-summary-narration', 'narration');
bindSummaryNarrativeSetting($, 'rpg-summary-pov', 'pov');
// Theme selector
$('#rpg-theme-select').on('change', function() {
extensionSettings.theme = String($(this).val());
saveSettings();
applyTheme();
updateSettingsPopupTheme();
});
// Custom theme toggle
$('#rpg-toggle-custom-theme').on('change', function() {
extensionSettings.enableCustomTheme = $(this).prop('checked');
saveSettings();
toggleCustomColors();
});
// Custom theme color pickers
$('#rpg-custom-bg-color').on('input', function() {
extensionSettings.customBgColor = $(this).val();
saveSettings();
applyCustomTheme();
});
$('#rpg-custom-accent-color').on('input', function() {
extensionSettings.customAccentColor = $(this).val();
saveSettings();
applyCustomTheme();
});
$('#rpg-custom-text-color').on('input', function() {
extensionSettings.customTextColor = $(this).val();
saveSettings();
applyCustomTheme();
});
$('#rpg-custom-highlight-color').on('input', function() {
extensionSettings.customHighlightColor = $(this).val();
saveSettings();
applyCustomTheme();
});
$('#rpg-custom-border-color').on('input', function() {
extensionSettings.customBorderColor = $(this).val();
saveSettings();
applyCustomTheme();
});
// Custom reset button
$('#rpg-reset-custom-theme').on('click', function() {
extensionSettings.customBgColor = '';
extensionSettings.customAccentColor = '';
extensionSettings.customTextColor = '';
extensionSettings.customHighlightColor = '';
extensionSettings.customBorderColor = '';
saveSettings();
applyTheme();
updateSettingsPopupTheme();
});
// Animations toggle
$('#rpg-toggle-animations').on('change', function() {
extensionSettings.enableAnimations = $(this).prop('checked');
saveSettings();
toggleAnimations();
});
// Panel width
$('#rpg-panel-width').on('change', function() {
extensionSettings.panelWidth = $(this).val();
saveSettings();
updatePanelVisibility();
});
// Feature toggles visibility
$('#rpg-toggle-feature-toggles').on('change', function() {
extensionSettings.showFeatureToggles = $(this).prop('checked');
saveSettings(); saveSettings();
updateFeatureTogglesVisibility(); updateFeatureTogglesVisibility();
}); });
// Weather sub-options visibility toggle $('#rpg-toggle-show-dialogue-coloring-toggle').on('change', function() {
$('#rpg-toggle-dynamic-weather-suboptions').on('change', function() { extensionSettings.showDialogueColoringToggle = $(this).prop('checked');
extensionSettings.showDynamicWeatherToggle = $(this).prop('checked');
saveSettings(); saveSettings();
updateFeatureTogglesVisibility();
});
$('#rpg-toggle-show-deception-toggle').on('change', function() {
extensionSettings.showDeceptionToggle = $(this).prop('checked');
saveSettings();
updateFeatureTogglesVisibility();
});
$('#rpg-toggle-show-omniscience-toggle').on('change', function() {
extensionSettings.showOmniscienceToggle = $(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();
updateFeatureTogglesVisibility();
});
$('#rpg-toggle-show-dynamic-weather-toggle').on('change', function() {
extensionSettings.showDynamicWeatherToggle = $(this).prop('checked');
// Also disable the feature when hiding the toggle
if (!extensionSettings.showDynamicWeatherToggle) {
extensionSettings.enableDynamicWeather = false;
$('#rpg-toggle-dynamic-weather').prop('checked', false);
toggleDynamicWeather(false);
}
saveSettings();
updateFeatureTogglesVisibility();
updateWeatherSubOptionsVisibility(); updateWeatherSubOptionsVisibility();
}); });
// Strip widgets // Weather sub-options (background and foreground) - radio buttons
bindStripWidgetToggle($, 'rpg-strip-widget-weather', 'weatherIcon'); $('#rpg-toggle-weather-background').on('change', function() {
bindStripWidgetToggle($, 'rpg-strip-widget-clock', 'clock'); if ($(this).prop('checked')) {
bindStripWidgetToggle($, 'rpg-strip-widget-date', 'date'); extensionSettings.weatherBackground = true;
bindStripWidgetToggle($, 'rpg-strip-widget-location', 'location'); extensionSettings.weatherForeground = false;
bindStripWidgetToggle($, 'rpg-strip-widget-stats', 'stats');
bindStripWidgetToggle($, 'rpg-strip-widget-attributes', 'attributes');
// Mobile panel position
$('#rpg-mobile-position-select').on('change', function() {
extensionSettings.mobilePanelPosition = String($(this).val());
saveSettings(); saveSettings();
updateMobilePanelPosition(); // 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() {
extensionSettings.showNarratorMode = $(this).prop('checked');
// Also disable the feature when hiding the toggle
if (!extensionSettings.showNarratorMode) {
extensionSettings.narratorMode = false;
$('#rpg-toggle-narrator').prop('checked', false);
}
saveSettings();
updateFeatureTogglesVisibility();
});
$('#rpg-toggle-show-auto-avatars').on('change', function() {
extensionSettings.showAutoAvatars = $(this).prop('checked');
// Also disable the feature when hiding the toggle
if (!extensionSettings.showAutoAvatars) {
extensionSettings.autoGenerateAvatars = false;
$('#rpg-toggle-auto-avatars-panel').prop('checked', false);
}
saveSettings();
updateFeatureTogglesVisibility();
});
// Auto avatar generation panel toggle
$('#rpg-toggle-auto-avatars-panel').on('change', function() {
extensionSettings.autoGenerateAvatars = $(this).prop('checked');
saveSettings();
// Re-render thoughts to update tooltips (regenerate vs delete)
renderThoughts();
});
// Dice display toggle
$('#rpg-toggle-dice-display').on('change', function() {
extensionSettings.showDiceDisplay = $(this).prop('checked');
saveSettings();
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();
});
// Manual update button
$('#rpg-manual-update').on('click', async function() {
if (!extensionSettings.enabled) {
return;
}
const currentChat = getContext().chat;
let lastAssistantIndex = -1;
for (let i = currentChat.length - 1; i >= 0; i--) {
if (!currentChat[i].is_user && !currentChat[i].is_system) {
lastAssistantIndex = i;
break;
}
}
if (lastAssistantIndex !== -1) {
commitTrackerDataFromPriorMessage(lastAssistantIndex);
}
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;
}
const currentChat = getContext().chat;
let lastAssistantIndex = -1;
for (let i = currentChat.length - 1; i >= 0; i--) {
if (!currentChat[i].is_user && !currentChat[i].is_system) {
lastAssistantIndex = i;
break;
}
}
if (lastAssistantIndex !== -1) {
commitTrackerDataFromPriorMessage(lastAssistantIndex);
}
await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory);
});
// Stat bar colors
$('#rpg-stat-bar-color-low').on('change', function() {
extensionSettings.statBarColorLow = String($(this).val());
saveSettings();
renderUserStats(); // Re-render with new colors
});
$('#rpg-stat-bar-color-low-opacity').on('input', function() {
const opacity = Number($(this).val());
extensionSettings.statBarColorLowOpacity = opacity;
$('#rpg-stat-bar-color-low-opacity-value').text(opacity + '%');
renderUserStats();
}).on('change', function() {
saveSettings();
});
$('#rpg-stat-bar-color-high').on('change', function() {
extensionSettings.statBarColorHigh = String($(this).val());
saveSettings();
renderUserStats(); // Re-render with new colors
});
$('#rpg-stat-bar-color-high-opacity').on('input', function() {
const opacity = Number($(this).val());
extensionSettings.statBarColorHighOpacity = opacity;
$('#rpg-stat-bar-color-high-opacity-value').text(opacity + '%');
renderUserStats();
}).on('change', function() {
saveSettings();
});
// Theme selection
$('#rpg-theme-select').on('change', function() {
extensionSettings.theme = String($(this).val());
saveSettings();
applyTheme();
toggleCustomColors();
updateSettingsPopupTheme(getSettingsModal()); // Update popup theme instantly
updateChatThoughts(); // Recreate thought bubbles with new theme
});
// Custom color pickers
$('#rpg-custom-bg').on('change', function() {
extensionSettings.customColors.bg = String($(this).val());
saveSettings();
if (extensionSettings.theme === 'custom') {
applyCustomTheme();
updateSettingsPopupTheme(getSettingsModal());
updateChatThoughts();
}
});
$('#rpg-custom-bg-opacity').on('input', function() {
const opacity = Number($(this).val());
extensionSettings.customColors.bgOpacity = opacity;
$('#rpg-custom-bg-opacity-value').text(opacity + '%');
if (extensionSettings.theme === 'custom') {
applyCustomTheme();
updateSettingsPopupTheme(getSettingsModal());
updateChatThoughts();
}
}).on('change', function() {
saveSettings();
});
$('#rpg-custom-accent').on('change', function() {
extensionSettings.customColors.accent = String($(this).val());
saveSettings();
if (extensionSettings.theme === 'custom') {
applyCustomTheme();
updateSettingsPopupTheme(getSettingsModal());
updateChatThoughts();
}
});
$('#rpg-custom-accent-opacity').on('input', function() {
const opacity = Number($(this).val());
extensionSettings.customColors.accentOpacity = opacity;
$('#rpg-custom-accent-opacity-value').text(opacity + '%');
if (extensionSettings.theme === 'custom') {
applyCustomTheme();
updateSettingsPopupTheme(getSettingsModal());
updateChatThoughts();
}
}).on('change', function() {
saveSettings();
});
$('#rpg-custom-text').on('change', function() {
extensionSettings.customColors.text = String($(this).val());
saveSettings();
if (extensionSettings.theme === 'custom') {
applyCustomTheme();
updateSettingsPopupTheme(getSettingsModal());
updateChatThoughts();
}
});
$('#rpg-custom-text-opacity').on('input', function() {
const opacity = Number($(this).val());
extensionSettings.customColors.textOpacity = opacity;
$('#rpg-custom-text-opacity-value').text(opacity + '%');
if (extensionSettings.theme === 'custom') {
applyCustomTheme();
updateSettingsPopupTheme(getSettingsModal());
updateChatThoughts();
}
}).on('change', function() {
saveSettings();
});
$('#rpg-custom-highlight').on('change', function() {
extensionSettings.customColors.highlight = String($(this).val());
saveSettings();
if (extensionSettings.theme === 'custom') {
applyCustomTheme();
updateSettingsPopupTheme(getSettingsModal());
updateChatThoughts();
}
});
$('#rpg-custom-highlight-opacity').on('input', function() {
const opacity = Number($(this).val());
extensionSettings.customColors.highlightOpacity = opacity;
$('#rpg-custom-highlight-opacity-value').text(opacity + '%');
if (extensionSettings.theme === 'custom') {
applyCustomTheme();
updateSettingsPopupTheme(getSettingsModal());
updateChatThoughts();
}
}).on('change', function() {
saveSettings();
});
// External API settings event handlers
$('#rpg-external-base-url').on('change', function() {
if (!extensionSettings.externalApiSettings) {
extensionSettings.externalApiSettings = {
baseUrl: '', apiKey: '', model: '', maxTokens: 8192, temperature: 0.7
};
}
extensionSettings.externalApiSettings.baseUrl = String($(this).val()).trim();
saveSettings();
});
$('#rpg-external-api-key').on('change', function() {
// Securely store API key in localStorage instead of shared extension settings
const apiKey = String($(this).val()).trim();
localStorage.setItem('rpg_companion_external_api_key', apiKey);
// Ensure the externalApiSettings object exists, but don't store the key in it
if (!extensionSettings.externalApiSettings) {
extensionSettings.externalApiSettings = {
baseUrl: '', model: '', maxTokens: 8192, temperature: 0.7
};
saveSettings();
}
});
$('#rpg-external-model').on('change', function() {
if (!extensionSettings.externalApiSettings) {
extensionSettings.externalApiSettings = {
baseUrl: '', apiKey: '', model: '', maxTokens: 8192, temperature: 0.7
};
}
extensionSettings.externalApiSettings.model = String($(this).val()).trim();
saveSettings();
});
$('#rpg-external-max-tokens').on('change', function() {
if (!extensionSettings.externalApiSettings) {
extensionSettings.externalApiSettings = {
baseUrl: '', apiKey: '', model: '', maxTokens: 8192, temperature: 0.7
};
}
extensionSettings.externalApiSettings.maxTokens = parseInt(String($(this).val()));
saveSettings();
});
$('#rpg-external-temperature').on('change', function() {
if (!extensionSettings.externalApiSettings) {
extensionSettings.externalApiSettings = {
baseUrl: '', apiKey: '', model: '', maxTokens: 8192, temperature: 0.7
};
}
extensionSettings.externalApiSettings.temperature = parseFloat(String($(this).val()));
saveSettings();
});
$('#rpg-toggle-api-key-visibility').on('click', function() {
const $input = $('#rpg-external-api-key');
const type = $input.attr('type') === 'password' ? 'text' : 'password';
$input.attr('type', type);
$(this).find('i').toggleClass('fa-eye fa-eye-slash');
});
$('#rpg-test-external-api').on('click', async function() {
const $result = $('#rpg-external-api-test-result');
const $btn = $(this);
const originalText = $btn.html();
$btn.html('<i class="fa-solid fa-spinner fa-spin"></i> Testing...').prop('disabled', true);
$result.hide().removeClass('rpg-success-message rpg-error-message');
try {
const result = await testExternalAPIConnection();
if (result.success) {
$result.addClass('rpg-success-message')
.html(`<i class="fa-solid fa-check-circle"></i> ${result.message}`)
.slideDown();
toastr.success(result.message);
} else {
$result.addClass('rpg-error-message')
.html(`<i class="fa-solid fa-exclamation-circle"></i> ${result.message}`)
.slideDown();
toastr.error(result.message);
}
} catch (error) {
$result.addClass('rpg-error-message')
.html(`<i class="fa-solid fa-exclamation-circle"></i> Error: ${error.message}`)
.slideDown();
} finally {
$btn.html(originalText).prop('disabled', false);
}
});
}
/**
* Synchronize all settings modal inputs with the currently saved settings.
* Called once during UI initialization after the template is in the DOM.
*/
export function initializeSettingsUIState() {
// Initialize UI state (enable/disable is in Extensions tab)
$('#rpg-toggle-auto-update').prop('checked', extensionSettings.autoUpdate);
$('#rpg-position-select').val(extensionSettings.panelPosition);
$('#rpg-update-depth').val(extensionSettings.updateDepth);
$('#rpg-toggle-user-stats').prop('checked', extensionSettings.showUserStats);
$('#rpg-toggle-info-box').prop('checked', extensionSettings.showInfoBox);
$('#rpg-toggle-thoughts').prop('checked', extensionSettings.showCharacterThoughts);
$('#rpg-toggle-alt-present-characters').prop('checked', extensionSettings.showAlternatePresentCharactersPanel ?? false);
$('#rpg-toggle-thought-based-expressions').prop('checked', extensionSettings.enableThoughtBasedExpressions === true);
$('#rpg-toggle-hide-default-expressions').prop('checked', extensionSettings.hideDefaultExpressionDisplay === true);
$('#rpg-toggle-inventory').prop('checked', extensionSettings.showInventory);
$('#rpg-toggle-equipment').prop('checked', extensionSettings.showEquipment);
$('#rpg-toggle-quests').prop('checked', extensionSettings.showQuests);
$('#rpg-toggle-lock-icons').prop('checked', extensionSettings.showLockIcons ?? true);
$('#rpg-toggle-thoughts-in-chat').prop('checked', extensionSettings.showThoughtsInChat);
$('#rpg-toggle-inline-thoughts').prop('checked', (extensionSettings.thoughtsInChatStyle || 'corner') === 'inline');
$('#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-omniscience').prop('checked', extensionSettings.enableOmniscienceFilter ?? 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);
$('#rpg-toggle-narrator').prop('checked', extensionSettings.narratorMode);
// 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-omniscience-toggle').prop('checked', extensionSettings.showOmniscienceToggle ?? 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);
$('#rpg-toggle-randomized-plot').prop('checked', extensionSettings.enableRandomizedPlot ?? true);
$('#rpg-toggle-natural-plot').prop('checked', extensionSettings.enableNaturalPlot ?? true);
$('#rpg-toggle-encounters').prop('checked', extensionSettings.encounterSettings?.enabled ?? true);
$('#rpg-encounter-history-depth').val(extensionSettings.encounterSettings?.historyDepth ?? 8);
$('#rpg-toggle-autosave-logs').prop('checked', extensionSettings.encounterSettings?.autoSaveLogs ?? true);
// Initialize avatar options (panel toggle)
$('#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-low-opacity').val(extensionSettings.statBarColorLowOpacity ?? 100);
$('#rpg-stat-bar-color-low-opacity-value').text((extensionSettings.statBarColorLowOpacity ?? 100) + '%');
$('#rpg-stat-bar-color-high').val(extensionSettings.statBarColorHigh);
$('#rpg-stat-bar-color-high-opacity').val(extensionSettings.statBarColorHighOpacity ?? 100);
$('#rpg-stat-bar-color-high-opacity-value').text((extensionSettings.statBarColorHighOpacity ?? 100) + '%');
$('#rpg-theme-select').val(extensionSettings.theme);
$('#rpg-custom-bg').val(extensionSettings.customColors.bg);
$('#rpg-custom-bg-opacity').val(extensionSettings.customColors.bgOpacity ?? 100);
$('#rpg-custom-bg-opacity-value').text((extensionSettings.customColors.bgOpacity ?? 100) + '%');
$('#rpg-custom-accent').val(extensionSettings.customColors.accent);
$('#rpg-custom-accent-opacity').val(extensionSettings.customColors.accentOpacity ?? 100);
$('#rpg-custom-accent-opacity-value').text((extensionSettings.customColors.accentOpacity ?? 100) + '%');
$('#rpg-custom-text').val(extensionSettings.customColors.text);
$('#rpg-custom-text-opacity').val(extensionSettings.customColors.textOpacity ?? 100);
$('#rpg-custom-text-opacity-value').text((extensionSettings.customColors.textOpacity ?? 100) + '%');
$('#rpg-custom-highlight').val(extensionSettings.customColors.highlight);
$('#rpg-custom-highlight-opacity').val(extensionSettings.customColors.highlightOpacity ?? 100);
$('#rpg-custom-highlight-opacity-value').text((extensionSettings.customColors.highlightOpacity ?? 100) + '%');
// Initialize External API settings values
if (extensionSettings.externalApiSettings) {
$('#rpg-external-base-url').val(extensionSettings.externalApiSettings.baseUrl || '');
// Load API Key from secure localStorage
const storedApiKey = localStorage.getItem('rpg_companion_external_api_key') || '';
$('#rpg-external-api-key').val(storedApiKey);
$('#rpg-external-model').val(extensionSettings.externalApiSettings.model || '');
$('#rpg-external-max-tokens').val(extensionSettings.externalApiSettings.maxTokens || 8192);
$('#rpg-external-temperature').val(extensionSettings.externalApiSettings.temperature ?? 0.7);
}
$('#rpg-generation-mode').val(extensionSettings.generationMode);
$('#rpg-skip-guided-mode').val(extensionSettings.skipInjectionsForGuided);
// Weather sub-options visibility follows the dynamic weather toggle visibility setting
updateWeatherSubOptionsVisibility();
} }
/** /**
* Helper: Bind a simple section visibility toggle * Helper: Bind a simple section visibility toggle
*/ */
function bindSectionToggle($, elementId, settingKey, callback) { function bindSectionToggle($, elementId, settingKey) {
$(`#${elementId}`).on('change', function() { $(`#${elementId}`).on('change', function() {
extensionSettings[settingKey] = $(this).prop('checked'); extensionSettings[settingKey] = $(this).prop('checked');
saveSettings(); saveSettings();
updateSectionVisibility(); updateSectionVisibility();
if (callback) callback();
});
}
/**
* Helper: Bind combat narrative setting
*/
function bindCombatNarrativeSetting($, elementId, key) {
$(`#${elementId}`).on('change', function() {
if (!extensionSettings.encounterSettings) {
extensionSettings.encounterSettings = {};
}
if (!extensionSettings.encounterSettings.combatNarrative) {
extensionSettings.encounterSettings.combatNarrative = {};
}
extensionSettings.encounterSettings.combatNarrative[key] = $(this).val();
saveSettings();
});
}
/**
* Helper: Bind summary narrative setting
*/
function bindSummaryNarrativeSetting($, elementId, key) {
$(`#${elementId}`).on('change', function() {
if (!extensionSettings.encounterSettings) {
extensionSettings.encounterSettings = {};
}
if (!extensionSettings.encounterSettings.summaryNarrative) {
extensionSettings.encounterSettings.summaryNarrative = {};
}
extensionSettings.encounterSettings.summaryNarrative[key] = $(this).val();
saveSettings();
});
}
/**
* Helper: Bind strip widget toggle
*/
function bindStripWidgetToggle($, elementId, widgetKey) {
$(`#${elementId}`).on('change', function() {
if (!extensionSettings.stripWidgets) {
extensionSettings.stripWidgets = { weatherIcon: true, clock: true, date: true, location: true, stats: true, attributes: true };
}
extensionSettings.stripWidgets[widgetKey] = $(this).prop('checked');
saveSettings();
updateStripWidgets();
}); });
} }
+1 -1
View File
@@ -1671,7 +1671,7 @@ function setupHistoryPersistenceListeners() {
messageCount: 5, messageCount: 5,
injectionPosition: 'assistant_message_end', injectionPosition: 'assistant_message_end',
contextPreamble: '', contextPreamble: '',
externalApiOnly: false sendAllEnabledOnRefresh: false
}; };
} }
+1 -1
View File
@@ -225,7 +225,7 @@ function getCurrentWeather() {
// Try to parse as JSON first (new format) // Try to parse as JSON first (new format)
try { try {
const parsed = typeof infoBoxData === 'string' ? JSON.parse(infoBoxData) : infoBoxData; const parsed = typeof infoBoxData === 'string' ? repairJSON(infoBoxData) : infoBoxData;
if (parsed && parsed.weather) { if (parsed && parsed.weather) {
// Return the forecast text from the weather object // Return the forecast text from the weather object
return parsed.weather.forecast || parsed.weather.emoji || null; return parsed.weather.forecast || parsed.weather.emoji || null;
+3 -2
View File
@@ -81,6 +81,7 @@ export function repairJSON(jsonString) {
try { try {
return JSON.parse(cleaned); return JSON.parse(cleaned);
} catch (e) { } catch (e) {
console.debug('[RPG JSON Repair] Attempt 1 (JSON.parse) failed:', e.message);
} }
// Attempt 2: Extract JSON object between first { and last } // Attempt 2: Extract JSON object between first { and last }
@@ -89,7 +90,7 @@ export function repairJSON(jsonString) {
try { try {
return JSON.parse(objectMatch[0]); return JSON.parse(objectMatch[0]);
} catch (e) { } catch (e) {
// Silent fail, try next method console.debug('[RPG JSON Repair] Attempt 2 (object extraction) failed:', e.message);
} }
} }
@@ -99,7 +100,7 @@ export function repairJSON(jsonString) {
try { try {
return JSON.parse(arrayMatch[0]); return JSON.parse(arrayMatch[0]);
} catch (e) { } catch (e) {
// Silent fail, try next method console.debug('[RPG JSON Repair] Attempt 3 (array extraction) failed:', e.message);
} }
} }
+1 -1
View File
@@ -10,7 +10,7 @@
* intercepts the raw fetch response as a fallback. * intercepts the raw fetch response as a fallback.
*/ */
import { generateRaw } from '../../../../../../../script.js'; import { generateRaw } from '../../../../../../script.js';
/** /**
* Extracts text from any API response shape (Anthropic content-block arrays, * Extracts text from any API response shape (Anthropic content-block arrays,
+64 -38
View File
@@ -24,18 +24,34 @@ const BLOCKED_PROPERTY_NAMES = [
]; ];
/** /**
* Validates and sanitizes storage location names. * Creates a generic string validator/sanitizer with configurable rules.
* Prevents prototype pollution and object property shadowing attacks. * Returns a function that validates and sanitizes input strings.
* *
* @param {string} name - Location name to validate * @param {Object} options - Validator configuration
* @returns {string|null} Sanitized location name or null if invalid/dangerous * @param {string} options.label - Human-readable label for logging (e.g., 'location name', 'item name')
* @param {number} options.maxLength - Maximum allowed string length
* @param {string[]} [options.blockedNames] - Array of blocked names (defaults to BLOCKED_PROPERTY_NAMES)
* @param {string[]} [options.additionalRejects] - Additional values to reject (lowercase comparison, e.g., ['none'])
* @param {boolean} [options.checkBlockedNames] - Whether to check against blocked property names (default: true)
* @returns {Function} Sanitizer function: (string) => string|null
* *
* @example * @example
* sanitizeLocationName("Home") // "Home" * const sanitizeLocation = createStringValidator({
* sanitizeLocationName("__proto__") // null (blocked, logs warning) * label: 'location name',
* sanitizeLocationName("A".repeat(300)) // "AAA..." (truncated to 200 chars) * maxLength: 200,
* checkBlockedNames: true
* });
* const sanitizeItem = createStringValidator({
* label: 'item name',
* maxLength: 500,
* additionalRejects: ['none'],
* checkBlockedNames: false
* });
*/ */
export function sanitizeLocationName(name) { export function createStringValidator(options) {
const { label, maxLength, blockedNames = BLOCKED_PROPERTY_NAMES, additionalRejects = [], checkBlockedNames = true } = options;
return function sanitize(name) {
if (!name || typeof name !== 'string') { if (!name || typeof name !== 'string') {
return null; return null;
} }
@@ -47,23 +63,48 @@ export function sanitizeLocationName(name) {
return null; return null;
} }
// Check for dangerous property names (case-insensitive) // Additional rejects (e.g., 'none' for items)
const lowerName = trimmed.toLowerCase(); if (additionalRejects.length > 0 && additionalRejects.includes(trimmed.toLowerCase())) {
if (BLOCKED_PROPERTY_NAMES.some(blocked => lowerName === blocked.toLowerCase())) {
console.warn(`[RPG Companion] Blocked dangerous location name: "${trimmed}"`);
return null; return null;
} }
// Max length check (reasonable location name) // Check for dangerous property names (case-insensitive)
const MAX_LOCATION_LENGTH = 200; if (checkBlockedNames) {
if (trimmed.length > MAX_LOCATION_LENGTH) { const lowerName = trimmed.toLowerCase();
console.warn(`[RPG Companion] Location name too long (${trimmed.length} chars), truncating to ${MAX_LOCATION_LENGTH}`); if (blockedNames.some(blocked => lowerName === blocked.toLowerCase())) {
return trimmed.slice(0, MAX_LOCATION_LENGTH); console.warn(`[RPG Companion] Blocked dangerous ${label}: "${trimmed}"`);
return null;
}
}
// Max length check
if (trimmed.length > maxLength) {
console.warn(`[RPG Companion] ${label.charAt(0).toUpperCase() + label.slice(1)} too long (${trimmed.length} chars), truncating to ${maxLength}`);
return trimmed.slice(0, maxLength);
} }
return trimmed; return trimmed;
};
} }
/**
* Validates and sanitizes storage location names.
* Prevents prototype pollution and object property shadowing attacks.
*
* @param {string} name - Location name to validate
* @returns {string|null} Sanitized location name or null if invalid/dangerous
*
* @example
* sanitizeLocationName("Home") // "Home"
* sanitizeLocationName("__proto__") // null (blocked, logs warning)
* sanitizeLocationName("A".repeat(300)) // "AAA..." (truncated to 200 chars)
*/
export const sanitizeLocationName = createStringValidator({
label: 'location name',
maxLength: 200,
checkBlockedNames: true
});
/** /**
* Validates and sanitizes item names. * Validates and sanitizes item names.
* Prevents excessively long item names that could cause DoS or UI issues. * Prevents excessively long item names that could cause DoS or UI issues.
@@ -76,27 +117,12 @@ export function sanitizeLocationName(name) {
* sanitizeItemName("") // null * sanitizeItemName("") // null
* sanitizeItemName("A".repeat(600)) // "AAA..." (truncated to 500 chars) * sanitizeItemName("A".repeat(600)) // "AAA..." (truncated to 500 chars)
*/ */
export function sanitizeItemName(name) { export const sanitizeItemName = createStringValidator({
if (!name || typeof name !== 'string') { label: 'item name',
return null; maxLength: 500,
} additionalRejects: ['none'],
checkBlockedNames: false
const trimmed = name.trim(); });
// Empty check
if (trimmed === '' || trimmed.toLowerCase() === 'none') {
return null;
}
// Max length check (reasonable item name with description)
const MAX_ITEM_LENGTH = 500;
if (trimmed.length > MAX_ITEM_LENGTH) {
console.warn(`[RPG Companion] Item name too long (${trimmed.length} chars), truncating to ${MAX_ITEM_LENGTH}`);
return trimmed.slice(0, MAX_ITEM_LENGTH);
}
return trimmed;
}
/** /**
* Validates and cleans a stored inventory object. * Validates and cleans a stored inventory object.
+1 -1
View File
@@ -1114,7 +1114,7 @@
Injected when "Enable Dialogue Coloring" is enabled. Affects all generation modes. Injected when "Enable Dialogue Coloring" is enabled. Affects all generation modes.
</small> </small>
<textarea id="rpg-prompt-dialogue-coloring" class="rpg-prompt-textarea" rows="4"></textarea> <textarea id="rpg-prompt-dialogue-coloring" class="rpg-prompt-textarea" rows="4"></textarea>
<button class="menu_button rpg-restore-prompt-btn" data-prompt="dialogue-coloring" style="margin-top: 8px;"> <button class="menu_button rpg-restore-prompt-btn" data-prompt="dialogueColoring" style="margin-top: 8px;">
<i class="fa-solid fa-rotate-left"></i>&nbsp;<span data-i18n-key="template.promptsEditor.restoreDefault">Restore Default</span> <i class="fa-solid fa-rotate-left"></i>&nbsp;<span data-i18n-key="template.promptsEditor.restoreDefault">Restore Default</span>
</button> </button>
</div> </div>