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
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
- **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)
- **Runtime**: Browser only. All code executes inside SillyTavern's page context.
- **jQuery**: Available globally as `$` / `jQuery`. Used extensively for DOM manipulation.
- **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/
├── core/ # Core infrastructure
│ ├── config.js # Extension name, folder path, default settings
│ ├── state.js # Centralized state variables + getters/setters
│ ├── config.js # Extension name, folder path, default settings (single source of truth)
│ ├── state.js # Centralized state variables + getters/setters (imports defaults from config.js)
│ ├── persistence.js # Save/load settings, chat data, migrations
│ ├── 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
│ ├── en.json # English (reference, 513 lines)
│ ├── fr.json # French
@@ -88,10 +90,39 @@ src/
│ ├── zh-cn.json # Simplified Chinese
│ ├── zh-tw.json # Traditional Chinese
│ └── 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)
│ ├── generation/ # AI prompt generation, parsing, API calls
│ │ ├── promptBuilder.js # Builds tracker prompts for AI
│ │ ├── parser.js # Parses AI responses to extract tracker data
│ │ ├── promptBuilder.js # Builds tracker prompts for AI (refactored with focused helpers)
│ │ ├── parser.js # Parses AI responses (~50 pre-compiled regex patterns)
│ │ ├── apiClient.js # Makes API calls for separate/external generation
│ │ ├── injector.js # Injects tracker prompts into generation context
│ │ ├── lockManager.js # Manages locked tracker fields
@@ -106,7 +137,8 @@ src/
│ │ ├── inventory.js # Renders inventory section
│ │ ├── equipment.js # Renders equipment 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
│ │ ├── theme.js # Theme system (default, sci-fi, fantasy, cyberpunk, custom)
│ │ ├── modals.js # Settings modal, dice modal, deprecation modal
@@ -119,7 +151,10 @@ src/
│ │ ├── encounterUI.js # Combat encounter modal
│ │ ├── snowflakes.js # Snowflake animation effect
│ │ ├── 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
│ │ ├── sillytavern.js # Event handlers: message sent/received, swipe, etc.
│ │ └── thoughtBasedExpressions.js # Integrates with ST Character Expressions
@@ -144,11 +179,11 @@ src/
├── imageUrls.js # Image URL utilities
├── itemParser.js # Item string parsing
├── 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
├── presentCharacters.js # Present character data helpers
├── 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
├── thoughtBasedExpressionPortraits.js # Expression-to-portrait mapping
└── transformations.js # Data transformation utilities
@@ -163,7 +198,7 @@ src/
5. **External API mode**: Uses user-configured external API endpoint
6. **AI responds**`MESSAGE_RECEIVED` event fires
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
### 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
- 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
`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)
- 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
```bash
# Validate translation files (check missing keys, type mismatches, empty values)
npm run validate_locale_once
# Build CSS from modular sources
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
npm run validate_locale
# Testing
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
@@ -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.
- 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
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.
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`.
@@ -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).
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 |
|------|---------|
| `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). |
| `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`. |
| `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/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/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/generation/promptBuilder.js` | Builds AI prompts for tracker generation. |
| `src/systems/generation/parser.js` | Parses AI responses to extract tracker JSON. |
| `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 (~50 pre-compiled regex patterns). |
| `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/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. |
+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`
+48 -15
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.
[![My 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)
## 🆕 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>
[![Marinara Discord](https://img.shields.io/badge/Discord-Join%20Server-7289da)](https://discord.com/invite/KdAkTg94ME)
[![Support Original Author](https://img.shields.io/badge/Ko--fi-Support%20Creator-ff5e5b)](https://ko-fi.com/marinara_spaghetti)
## 📥 Installation
@@ -21,7 +13,7 @@ Moving on to developing the Marinara Engine frontend, the extension will now be
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
@@ -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
- **🎲 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
- **⚔️ 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
- **📜 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
@@ -226,6 +219,49 @@ Choose from 6 beautiful themes:
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
### Extension doesn't appear
@@ -290,7 +326,4 @@ SpicyMarinara, Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDea
---
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)
Made with ❤️ by Marinara
+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"
}
}]
]
}
+86 -62
View File
@@ -55,7 +55,7 @@ import {
clearDebugLogs
} from './src/core/state.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';
// Generation & Parsing modules
@@ -108,7 +108,6 @@ import {
setupDiceRoller,
setupSettingsPopup,
updateDiceDisplay,
addDiceQuickReply,
getSettingsModal,
showWelcomeModalIfNeeded,
showDeprecationModalIfNeeded
@@ -187,7 +186,7 @@ import {
} from './src/systems/integration/sillytavern.js';
// 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
setThoughtBasedExpressionsRefreshHandler(() => {
@@ -207,6 +206,71 @@ function updateDynamicLabels() {
updateMobileTabLabels();
}
/**
* 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).
*/
function registerExtensionEvents() {
registerAllEvents({
[event_types.MESSAGE_SENT]: onMessageSent,
[event_types.GENERATION_STARTED]: onGenerationStarted,
[event_types.MESSAGE_RECEIVED]: onMessageReceived,
[event_types.GENERATION_STOPPED]: onGenerationEnded,
[event_types.GENERATION_ENDED]: onGenerationEnded,
[event_types.CHAT_CHANGED]: [onCharacterChanged, updatePersonaAvatar, restoreCheckpointOnLoad, clearSessionAvatarPrompts, clearDebugLogs],
[event_types.CHAT_LOADED]: onChatLoaded,
[event_types.MESSAGE_DELETED]: onMessageDeleted,
[event_types.MESSAGE_SWIPE_DELETED]: onMessageDeleted,
[event_types.MESSAGE_SWIPED]: onMessageSwiped,
[event_types.USER_MESSAGE_RENDERED]: updatePersonaAvatar,
[event_types.SETTINGS_UPDATED]: updatePersonaAvatar
});
// Use tracked event registration (onEvent) instead of direct eventSource.on()
// This ensures all handlers are tracked for cleanup by unregisterAllEvents()
onEvent(event_types.CHARACTER_MESSAGE_RENDERED, (messageId) => {
if (!extensionSettings.enabled) return;
const renderedMessage = chat[messageId];
if (renderedMessage && !renderedMessage.is_user && !renderedMessage.is_system) {
queueThoughtBasedExpressionsUpdate();
}
});
onEvent(event_types.MESSAGE_UPDATED, (messageId) => {
if (!extensionSettings.enabled) return;
const updatedMessage = chat[messageId];
if (updatedMessage && !updatedMessage.is_user && !updatedMessage.is_system) {
queueThoughtBasedExpressionsUpdate();
}
});
onEvent(event_types.MESSAGE_SWIPED, (messageIndex) => {
if (!extensionSettings.enabled) return;
const swipedMessage = chat[messageIndex];
if (swipedMessage && !swipedMessage.is_user && !swipedMessage.is_system) {
queueThoughtBasedExpressionsUpdate({ immediate: true });
}
});
onEvent(event_types.CHAT_CHANGED, () => {
clearThoughtBasedExpressionsCache();
setTimeout(() => onThoughtBasedExpressionsChatChanged(), 0);
});
onEvent(event_types.MESSAGE_DELETED, () => {
if (!extensionSettings.enabled) return;
clearThoughtBasedExpressionsCache();
setTimeout(() => onThoughtBasedExpressionsChatChanged(), 0);
});
onEvent(event_types.MESSAGE_SWIPE_DELETED, () => {
if (!extensionSettings.enabled) return;
clearThoughtBasedExpressionsCache();
setTimeout(() => onThoughtBasedExpressionsChatChanged(), 0);
});
}
/**
* Initializes the UI for the extension.
*/
@@ -252,6 +316,19 @@ async function initUI() {
// 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();
@@ -305,7 +382,7 @@ jQuery(async () => {
// Load settings with validation
try {
loadSettings();
await loadSettings();
} catch (error) {
console.error('[RPG Companion] Settings load failed, continuing with defaults:', error);
}
@@ -329,7 +406,7 @@ jQuery(async () => {
// Add extension settings to Extensions tab
try {
await addExtensionSettings($, renderExtensionTemplateAsync, clearExtensionPrompts, updateChatThoughts, cleanupCheckpointUI, clearThoughtBasedExpressionsCache, toggleDynamicWeather, initUI, loadChatData, scheduleChatStateRehydration, initThoughtBasedExpressions, injectCheckpointButton, updateAllCheckpointIndicators, removeAlternatePresentCharactersPanel);
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);
}
@@ -349,6 +426,9 @@ jQuery(async () => {
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);
}
@@ -370,63 +450,7 @@ jQuery(async () => {
// Register all event listeners
try {
registerAllEvents({
[event_types.MESSAGE_SENT]: onMessageSent,
[event_types.GENERATION_STARTED]: onGenerationStarted,
[event_types.MESSAGE_RECEIVED]: onMessageReceived,
[event_types.GENERATION_STOPPED]: onGenerationEnded,
[event_types.GENERATION_ENDED]: onGenerationEnded,
[event_types.CHAT_CHANGED]: [onCharacterChanged, updatePersonaAvatar, restoreCheckpointOnLoad, clearSessionAvatarPrompts, clearDebugLogs],
[event_types.CHAT_LOADED]: onChatLoaded,
[event_types.MESSAGE_DELETED]: onMessageDeleted,
[event_types.MESSAGE_SWIPE_DELETED]: onMessageDeleted,
[event_types.MESSAGE_SWIPED]: onMessageSwiped,
[event_types.USER_MESSAGE_RENDERED]: updatePersonaAvatar,
[event_types.SETTINGS_UPDATED]: updatePersonaAvatar
});
// Use tracked event registration (onEvent) instead of direct eventSource.on()
// This ensures all handlers are tracked for cleanup by unregisterAllEvents()
onEvent(event_types.CHARACTER_MESSAGE_RENDERED, (messageId) => {
if (!extensionSettings.enabled) return;
const renderedMessage = chat[messageId];
if (renderedMessage && !renderedMessage.is_user && !renderedMessage.is_system) {
queueThoughtBasedExpressionsUpdate();
}
});
onEvent(event_types.MESSAGE_UPDATED, (messageId) => {
if (!extensionSettings.enabled) return;
const updatedMessage = chat[messageId];
if (updatedMessage && !updatedMessage.is_user && !updatedMessage.is_system) {
queueThoughtBasedExpressionsUpdate();
}
});
onEvent(event_types.MESSAGE_SWIPED, (messageIndex) => {
if (!extensionSettings.enabled) return;
const swipedMessage = chat[messageIndex];
if (swipedMessage && !swipedMessage.is_user && !swipedMessage.is_system) {
queueThoughtBasedExpressionsUpdate({ immediate: true });
}
});
onEvent(event_types.CHAT_CHANGED, () => {
clearThoughtBasedExpressionsCache();
setTimeout(() => onThoughtBasedExpressionsChatChanged(), 0);
});
onEvent(event_types.MESSAGE_DELETED, () => {
if (!extensionSettings.enabled) return;
clearThoughtBasedExpressionsCache();
setTimeout(() => onThoughtBasedExpressionsChatChanged(), 0);
});
onEvent(event_types.MESSAGE_SWIPE_DELETED, () => {
if (!extensionSettings.enabled) return;
clearThoughtBasedExpressionsCache();
setTimeout(() => onThoughtBasedExpressionsChatChanged(), 0);
});
registerExtensionEvents();
} catch (error) {
console.error('[RPG Companion] Event registration failed:', error);
throw error;
+4
View File
@@ -1,4 +1,8 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.js'],
transform: {
'^.+\\.js$': 'babel-jest',
},
transformIgnorePatterns: ['/node_modules/'],
};
+1 -1
View File
@@ -58,7 +58,7 @@
</div>
<div style="margin-top: 10px; text-align: center; opacity: 0.6; font-size: 0.85em;">
v3.7.2
v3.7.4
</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
*/
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
? `data/default-user/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 = {
settingsVersion: 7, // Version number for settings migrations
enabled: true,
autoUpdate: true,
debugMode: false, // Enable verbose debug logging (developer flag)
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,
@@ -33,37 +36,94 @@ export const defaultSettings = {
enableThoughtBasedExpressions: false,
hideDefaultExpressionDisplay: false,
showInventory: true, // Show inventory section (v2 system)
showEquipment: true, // Show equipment section
showQuests: true, // Show quests section
showLockIcons: true, // Show lock/unlock icons on tracker items
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)
// Controls when the extension skips injecting tracker instructions/examples/HTML
// into generations that appear to be user-injected instructions. Valid values:
// - 'none' -> never skip (legacy behavior: always inject)
// - 'guided' -> skip for any guided / instruct or quiet_prompt generation
// - 'impersonation' -> skip only for impersonation-style guided generations
// This setting helps compatibility with other extensions like GuidedGenerations.
skipInjectionsForGuided: 'none',
enablePlotButtons: true, // Show plot progression buttons above chat input
saveTrackerHistory: false, // Save tracker data in chat history for each message
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',
highlight: '#e94560'
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,
@@ -72,14 +132,162 @@ export const defaultSettings = {
arousal: 0,
mood: '😐',
conditions: 'None',
/** @type {InventoryV2} */
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,
@@ -89,5 +297,65 @@ export const defaultSettings = {
cha: 10
},
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) {
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.
* Automatically migrates v1 inventory to v2 format if needed.
*/
export function loadSettings() {
export async function loadSettings() {
try {
const context = getContext();
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
if (currentVersion < 3) {
// console.log('[RPG Companion] Migrating settings to version 3 (JSON tracker format)');
migrateToV3JSON();
await migrateToV3JSON();
extensionSettings.settingsVersion = 3;
settingsChanged = true;
}
+10 -1
View File
@@ -25,8 +25,11 @@ import { i18n } from './i18n.js';
* @param {Function} injectCheckpointButton - Add checkpoint buttons
* @param {Function} updateAllCheckpointIndicators - Update checkpoint button states
* @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');
$('#extensions_settings2').append(settingsHtml);
@@ -43,12 +46,18 @@ export async function addExtensionSettings($, renderExtensionTemplateAsync, clea
clearThoughtBasedExpressionsCache();
toggleDynamicWeather(false);
// Unregister all tracked event handlers to avoid leaks while disabled
unregisterAllEvents();
$('#rpg-companion-panel').remove();
$('#rpg-mobile-toggle').remove();
$('#rpg-collapse-toggle').remove();
$('#rpg-plot-buttons').remove();
removeAlternatePresentCharactersPanel();
} else if (extensionSettings.enabled && !wasEnabled) {
// Re-register event handlers that were unregistered on disable
registerExtensionEvents();
initHistoryInjection();
await initUI();
loadChatData();
scheduleChatStateRehydration();
+4 -367
View File
@@ -3,347 +3,16 @@
* Centralizes all extension state variables
*/
import { defaultSettings } from './config.js';
// Type imports
/** @typedef {import('../types/inventory.js').InventoryV2} InventoryV2 */
/**
* Extension settings - persisted to SillyTavern settings
* Initialized from config.js defaultSettings (single source of truth)
*/
export let extensionSettings = {
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
}
};
export let extensionSettings = { ...defaultSettings };
/**
* Last generated data from AI response
@@ -618,36 +287,4 @@ export function clearDebugLogs() {
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');
const path = require('path');
const chokidar = require('chokidar');
const glob = require('glob');
import fs from 'fs-extra';
import path from 'path';
import { fileURLToPath } from 'url';
import chokidar from 'chokidar';
import { globSync } from 'glob';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const COMPILED_DIR = __dirname // path.join(__dirname, 'compiled');
@@ -11,7 +14,7 @@ function findUnlocalizedText() {
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/**']
});
@@ -101,7 +104,7 @@ function validateTranslations() {
// Get all keys from reference locale
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
const stats = {
@@ -127,7 +130,7 @@ function validateTranslations() {
// Check for missing keys
for (const key of referenceKeys) {
if (!key in translations[locale]) {
if (!(key in translations[locale])) {
stats.missingKeys[locale].push(key);
} else {
// Check for type mismatches
@@ -146,7 +149,7 @@ function validateTranslations() {
// Check for extra keys
for (const key of localeKeys) {
if (!key in translations[referenceLocale]) {
if (!(key in translations[referenceLocale])) {
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) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
+1 -10
View File
@@ -115,13 +115,4 @@ export function clearDiceRoll() {
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 { isPresentCharactersEnabled } from '../../utils/presentCharacters.js';
import { buildInventorySummary, generateTrackerInstructions, generateTrackerExample } from './promptBuilder.js';
import { applyLocks } from './lockManager.js';
/**
* Gets character information from the current chat
-4
View File
@@ -39,10 +39,6 @@ let currentSuppressionState = false;
// Type imports
/** @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)
let pendingContextMap = new Map();
+1 -4
View File
@@ -3,8 +3,7 @@
* Helper functions for building JSON format tracker prompts
*/
import { extensionSettings, committedTrackerData } from '../../core/state.js';
import { getContext } from '../../../../../../extensions.js';
import { extensionSettings } from '../../core/state.js';
import { getWeatherKeywordsAsPromptString } from '../ui/weatherEffects.js';
import { i18n } from '../../core/i18n.js';
@@ -39,7 +38,6 @@ function toFieldKey(name) {
* @returns {string} JSON format instruction for user stats
*/
export function buildUserStatsJSONInstruction() {
const userName = getContext().name1;
const trackerConfig = extensionSettings.trackerConfig;
const userStatsConfig = trackerConfig?.userStats;
const enabledStats = userStatsConfig?.customStats?.filter(s => s && s.enabled && s.name) || [];
@@ -184,7 +182,6 @@ export function buildInfoBoxJSONInstruction() {
* @returns {string} JSON format instruction for present characters
*/
export function buildCharactersJSONInstruction() {
const userName = getContext().name1;
const presentCharsConfig = extensionSettings.trackerConfig?.presentCharacters;
const enabledFields = presentCharsConfig?.customFields?.filter(f => f && f.enabled && f.name) || [];
const relationshipsEnabled = presentCharsConfig?.relationships?.enabled !== false;
+78 -65
View File
@@ -4,8 +4,8 @@
*/
import { getContext } from '../../../../../../extensions.js';
import { chat, getCurrentChatDetails, characters, this_chid } from '../../../../../../../script.js';
import { selected_group, getGroupMembers, getGroupChat, groups } from '../../../../../../group-chats.js';
import { chat, characters, this_chid } from '../../../../../../../script.js';
import { selected_group, getGroupMembers, groups } from '../../../../../../group-chats.js';
import { extensionSettings, committedTrackerData, FEATURE_FLAGS } from '../../core/state.js';
import {
buildUserStatsJSONInstruction,
@@ -78,85 +78,99 @@ async function getCharacterCardsInfo() {
// Narrator mode: use character card as narrator context, infer characters from story context
if (extensionSettings.narratorMode) {
if (this_chid !== undefined && characters && characters[this_chid]) {
const character = 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';
characterInfo = buildNarratorCardInfo(characters[this_chid]);
}
return characterInfo;
}
// Check if in group chat
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);
if (groupMembers && groupMembers.length > 0) {
characterInfo += 'Characters in this roleplay:\n\n';
// Filter out disabled (muted) members
const disabledMembers = group?.disabled_members || [];
// console.log('[RPG Companion] 🔍 Group ID:', selected_group, '| Disabled members:', disabledMembers);
let characterIndex = 0;
groupMembers.forEach((member) => {
if (!member || !member.name) return;
// Skip muted characters - check against avatar filename
if (member.avatar && disabledMembers.includes(member.avatar)) {
// console.log(`[RPG Companion] ❌ Skipping muted: ${member.name} (${member.avatar})`);
return;
}
characterIndex++;
characterInfo += `<character${characterIndex}="${member.name}">\n`;
if (member.description) {
characterInfo += `${member.description}\n`;
}
if (member.personality) {
characterInfo += `${member.personality}\n`;
}
characterInfo += `</character${characterIndex}>\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
const character = characters[this_chid];
characterInfo += 'Character in this roleplay:\n\n';
characterInfo += `<character="${character.name}">\n`;
if (character.description) {
characterInfo += `${character.description}\n`;
}
if (character.personality) {
characterInfo += `${character.personality}\n`;
}
characterInfo += `</character>\n\n`;
characterInfo = buildSingleCardInfo(characters[this_chid]);
}
return characterInfo;
}
/**
* 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;
groupMembers.forEach((member) => {
if (!member || !member.name) return;
// Skip muted characters
if (member.avatar && disabledMembers.includes(member.avatar)) {
return;
}
characterIndex++;
info += `<character${characterIndex}="${member.name}">\n`;
info += appendCharacterFields(member);
info += `</character${characterIndex}>\n\n`;
});
return info;
}
/**
* 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) {
fields += `${character.description}\n`;
}
if (character.personality) {
fields += `${character.personality}\n`;
}
return fields;
}
/**
* Builds a formatted inventory summary for AI context injection.
* Converts v2 inventory structure to multi-line plaintext format.
@@ -1232,7 +1246,6 @@ export async function generateSeparateUpdatePrompt() {
// /hide command automatically handles checkpoint filtering
// Add chat history as separate user/assistant messages with per-message historical context
const recentMessages = chat.slice(-depth);
const startIndex = chat.length - depth;
const position = historyPersistence?.injectionPosition || 'assistant_message_end';
// Build a map of which messages should get context based on position setting
+1 -1
View File
@@ -26,7 +26,7 @@ export function evaluateSuppression(extensionSettings, context, data) {
let instructContent = '';
if (instructObj) {
if (typeof instructObj === 'object') {
instructContent = String(instructObj.value || instructObj || '');
instructContent = String(instructObj.value || '');
} else {
instructContent = String(instructObj);
}
+1 -1
View File
@@ -4,7 +4,7 @@
*/
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
import {
@@ -5,7 +5,6 @@
import { extensionSettings, lastGeneratedData, committedTrackerData } from '../../core/state.js';
import { saveSettings, saveChatData, updateMessageSwipeData } from '../../core/persistence.js';
import { buildInventorySummary } from '../generation/promptBuilder.js';
import { buildUserStatsText } from '../rendering/userStats.js';
import { renderInventory, getLocationId } from '../rendering/inventory.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.
*/
export function renderEquipment() {
// Ensure showEquipment defaults to true if undefined
if (extensionSettings.showEquipment === undefined) {
extensionSettings.showEquipment = true;
}
if (!$equipmentContainer || !extensionSettings.showEquipment) {
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();
updateIfChanged($equipmentContainer, html, 'rpg-equipment');
+1 -1
View File
@@ -10,7 +10,7 @@ import {
committedTrackerData,
$infoBoxContainer
} 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 { isItemLocked } from '../generation/lockManager.js';
import { repairJSON } from '../../utils/jsonRepair.js';
+6 -3
View File
@@ -645,7 +645,10 @@ export function renderInventory() {
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
return String(text)
.replace(/&/g, '&amp;')
.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
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
if (!text) return '';
return String(text)
.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();
}
/**
* 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
* 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
}
/**
* 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.
* Renders either the original corner bubbles or inline dropdown cards.
+1 -1
View File
@@ -143,7 +143,7 @@ function updateUserStatsData() {
if (!itemString) return [];
const items = itemString.split(',').map(s => s.trim()).filter(s => s);
return items.map(item => {
const qtyMatch = item.match(/^(\\d+)x\\s+(.+)$/);
const qtyMatch = item.match(/^(\d+)x\s+(.+)$/);
if (qtyMatch) {
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 { extensionSettings, lastGeneratedData, committedTrackerData } from '../../core/state.js';
import { hexToRgba } from './theme.js';
import { repairJSON } from '../../utils/jsonRepair.js';
/**
* Helper to parse time string and calculate clock hand angles
@@ -50,7 +51,7 @@ export function updateStripWidgets() {
let infoData = null;
if (infoBox) {
try {
infoData = typeof infoBox === 'string' ? JSON.parse(infoBox) : infoBox;
infoData = typeof infoBox === 'string' ? repairJSON(infoBox) : infoBox;
} catch (e) {
console.warn('[RPG Strip Widgets] Failed to parse infoBox:', e);
}
@@ -124,7 +125,7 @@ export function updateStripWidgets() {
const userStatsData = lastGeneratedData?.userStats || committedTrackerData?.userStats;
if (userStatsData) {
try {
const parsedStats = typeof userStatsData === 'string' ? JSON.parse(userStatsData) : userStatsData;
const parsedStats = typeof userStatsData === 'string' ? repairJSON(userStatsData) : userStatsData;
if (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 { i18n } from '../../core/i18n.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.
@@ -503,7 +504,10 @@ export function setupMobileToggle() {
// top: $panel.css('top'),
// bottom: $panel.css('top'),
// transform: $panel.css('transform'),
// visibility: $panel.css('visibility')\n // }\n // });\n setupMobileTabs();
// visibility: $panel.css('visibility')
// }
// });
setupMobileTabs();
// Set initial icon for mobile
updateCollapseToggleIcon();
// 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
// ============================================
@@ -1322,7 +895,7 @@ export function updateFabWidgets() {
let infoData = null;
if (infoBox) {
try {
infoData = typeof infoBox === 'string' ? JSON.parse(infoBox) : infoBox;
infoData = typeof infoBox === 'string' ? repairJSON(infoBox) : infoBox;
} catch (e) {
console.warn('[RPG FAB Widgets] Failed to parse infoBox:', e);
}
@@ -1332,7 +905,7 @@ export function updateFabWidgets() {
let statsData = null;
if (userStats) {
try {
statsData = typeof userStats === 'string' ? JSON.parse(userStats) : userStats;
statsData = typeof userStats === 'string' ? repairJSON(userStats) : userStats;
} catch (e) {
console.warn('[RPG FAB Widgets] Failed to parse userStats:', e);
}
@@ -1444,7 +1017,7 @@ export function updateFabWidgets() {
let allStats = [];
try {
const userStatsJson = extensionSettings.userStats;
const parsedUserStats = typeof userStatsJson === 'string' ? JSON.parse(userStatsJson) : userStatsJson;
const parsedUserStats = typeof userStatsJson === 'string' ? repairJSON(userStatsJson) : userStatsJson;
if (parsedUserStats?.stats) {
allStats = parsedUserStats.stats;
}
+1 -10
View File
@@ -26,8 +26,7 @@ import { renderEquipment } from '../rendering/equipment.js';
import {
rollDice as rollDiceCore,
clearDiceRoll as clearDiceRollCore,
updateDiceDisplay as updateDiceDisplayCore,
addDiceQuickReply as addDiceQuickReplyCore
updateDiceDisplay as updateDiceDisplayCore
} from '../features/dice.js';
import { i18n } from '../../core/i18n.js';
@@ -578,14 +577,6 @@ export function updateDiceDisplay() {
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 {SettingsModal} The global SettingsModal instance
+659 -173
View File
@@ -4,12 +4,39 @@
* Extracted from index.js initUI() to reduce main entry point size.
*/
import { extensionSettings, updateExtensionSettings } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
/* eslint-disable no-unused-vars */
/* These functions receive jQuery-wrapped DOM elements from initUI() */
/* eslint-disable no-unused-vars */
import { getContext } from '../../../../../../extensions.js';
import { extensionSettings, $musicPlayerContainer } from '../../core/state.js';
import { saveSettings, commitTrackerDataFromPriorMessage } from '../../core/persistence.js';
import {
applyPanelPosition,
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.
@@ -29,6 +56,7 @@ export function bindSettingsListeners($) {
extensionSettings.panelPosition = String($(this).val());
saveSettings();
applyPanelPosition();
// Recreate thought bubbles to update their position
updateChatThoughts();
});
@@ -47,11 +75,11 @@ export function bindSettingsListeners($) {
});
// Section visibility toggles
bindSectionToggle($, 'rpg-toggle-user-stats', 'showUserStats', () => {});
bindSectionToggle($, 'rpg-toggle-info-box', 'showInfoBox', () => {});
bindSectionToggle($, 'rpg-toggle-inventory', 'showInventory', () => {});
bindSectionToggle($, 'rpg-toggle-equipment', 'showEquipment', () => {});
bindSectionToggle($, 'rpg-toggle-quests', 'showQuests', () => {});
bindSectionToggle($, 'rpg-toggle-user-stats', 'showUserStats');
bindSectionToggle($, 'rpg-toggle-info-box', 'showInfoBox');
bindSectionToggle($, 'rpg-toggle-inventory', 'showInventory');
bindSectionToggle($, 'rpg-toggle-equipment', 'showEquipment');
bindSectionToggle($, 'rpg-toggle-quests', 'showQuests');
// Thoughts section with render callback
$('#rpg-toggle-thoughts').on('change', function() {
@@ -160,25 +188,12 @@ export function bindSettingsListeners($) {
saveSettings();
});
// Dismiss holiday promo
$('#rpg-dismiss-promo').on('click', function() {
extensionSettings.dismissedHolidayPromo = true;
saveSettings();
$('#rpg-holiday-promo').fadeOut(300);
});
// Skip injections for guided mode
$('#rpg-skip-guided-mode').on('change', function() {
extensionSettings.skipInjectionsForGuided = String($(this).val());
saveSettings();
});
// Save tracker history
$('#rpg-save-tracker-history').on('change', function() {
extensionSettings.saveTrackerHistory = $(this).prop('checked');
saveSettings();
});
// Randomized plot
$('#rpg-toggle-randomized-plot').on('change', function() {
extensionSettings.enableRandomizedPlot = $(this).prop('checked');
@@ -200,7 +215,7 @@ export function bindSettingsListeners($) {
}
extensionSettings.encounterSettings.enabled = $(this).prop('checked');
saveSettings();
togglePlotButtons();
togglePlotButtons(); // This also controls encounter button visibility
});
// Encounter history depth
@@ -222,171 +237,642 @@ export function bindSettingsListeners($) {
saveSettings();
});
// Combat narrative style settings
bindCombatNarrativeSetting($, 'rpg-combat-tense', 'tense');
bindCombatNarrativeSetting($, 'rpg-combat-person', 'person');
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');
// Feature toggle visibility controls
$('#rpg-toggle-show-html-toggle').on('change', function() {
extensionSettings.showHtmlToggle = $(this).prop('checked');
saveSettings();
updateFeatureTogglesVisibility();
});
// Weather sub-options visibility toggle
$('#rpg-toggle-dynamic-weather-suboptions').on('change', function() {
extensionSettings.showDynamicWeatherToggle = $(this).prop('checked');
$('#rpg-toggle-show-dialogue-coloring-toggle').on('change', function() {
extensionSettings.showDialogueColoringToggle = $(this).prop('checked');
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();
});
// Strip widgets
bindStripWidgetToggle($, 'rpg-strip-widget-weather', 'weatherIcon');
bindStripWidgetToggle($, 'rpg-strip-widget-clock', 'clock');
bindStripWidgetToggle($, 'rpg-strip-widget-date', 'date');
bindStripWidgetToggle($, 'rpg-strip-widget-location', 'location');
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();
updateMobilePanelPosition();
// Weather sub-options (background and foreground) - radio buttons
$('#rpg-toggle-weather-background').on('change', function() {
if ($(this).prop('checked')) {
extensionSettings.weatherBackground = true;
extensionSettings.weatherForeground = false;
saveSettings();
// Re-apply weather effect
if (extensionSettings.enableDynamicWeather) {
toggleDynamicWeather(false);
toggleDynamicWeather(true);
}
}
});
$('#rpg-toggle-weather-foreground').on('change', function() {
if ($(this).prop('checked')) {
extensionSettings.weatherBackground = false;
extensionSettings.weatherForeground = true;
saveSettings();
// Re-apply weather effect
if (extensionSettings.enableDynamicWeather) {
toggleDynamicWeather(false);
toggleDynamicWeather(true);
}
}
});
$('#rpg-toggle-show-narrator-mode').on('change', function() {
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
*/
function bindSectionToggle($, elementId, settingKey, callback) {
function bindSectionToggle($, elementId, settingKey) {
$(`#${elementId}`).on('change', function() {
extensionSettings[settingKey] = $(this).prop('checked');
saveSettings();
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();
});
}
@@ -402,4 +888,4 @@ export function updateWeatherSubOptionsVisibility() {
} else {
$weatherSubOptions.hide();
}
}
}
+1 -1
View File
@@ -1671,7 +1671,7 @@ function setupHistoryPersistenceListeners() {
messageCount: 5,
injectionPosition: 'assistant_message_end',
contextPreamble: '',
externalApiOnly: false
sendAllEnabledOnRefresh: false
};
}
+1 -1
View File
@@ -225,7 +225,7 @@ function getCurrentWeather() {
// Try to parse as JSON first (new format)
try {
const parsed = typeof infoBoxData === 'string' ? JSON.parse(infoBoxData) : infoBoxData;
const parsed = typeof infoBoxData === 'string' ? repairJSON(infoBoxData) : infoBoxData;
if (parsed && parsed.weather) {
// Return the forecast text from the weather object
return parsed.weather.forecast || parsed.weather.emoji || null;
+3 -2
View File
@@ -81,6 +81,7 @@ export function repairJSON(jsonString) {
try {
return JSON.parse(cleaned);
} catch (e) {
console.debug('[RPG JSON Repair] Attempt 1 (JSON.parse) failed:', e.message);
}
// Attempt 2: Extract JSON object between first { and last }
@@ -89,7 +90,7 @@ export function repairJSON(jsonString) {
try {
return JSON.parse(objectMatch[0]);
} 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 {
return JSON.parse(arrayMatch[0]);
} 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.
*/
import { generateRaw } from '../../../../../../../script.js';
import { generateRaw } from '../../../../../../script.js';
/**
* Extracts text from any API response shape (Anthropic content-block arrays,
+75 -49
View File
@@ -23,6 +23,70 @@ const BLOCKED_PROPERTY_NAMES = [
'__lookupSetter__'
];
/**
* Creates a generic string validator/sanitizer with configurable rules.
* Returns a function that validates and sanitizes input strings.
*
* @param {Object} options - Validator configuration
* @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
* const sanitizeLocation = createStringValidator({
* label: 'location name',
* maxLength: 200,
* checkBlockedNames: true
* });
* const sanitizeItem = createStringValidator({
* label: 'item name',
* maxLength: 500,
* additionalRejects: ['none'],
* checkBlockedNames: false
* });
*/
export function createStringValidator(options) {
const { label, maxLength, blockedNames = BLOCKED_PROPERTY_NAMES, additionalRejects = [], checkBlockedNames = true } = options;
return function sanitize(name) {
if (!name || typeof name !== 'string') {
return null;
}
const trimmed = name.trim();
// Empty check
if (trimmed === '') {
return null;
}
// Additional rejects (e.g., 'none' for items)
if (additionalRejects.length > 0 && additionalRejects.includes(trimmed.toLowerCase())) {
return null;
}
// Check for dangerous property names (case-insensitive)
if (checkBlockedNames) {
const lowerName = trimmed.toLowerCase();
if (blockedNames.some(blocked => lowerName === blocked.toLowerCase())) {
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;
};
}
/**
* Validates and sanitizes storage location names.
* Prevents prototype pollution and object property shadowing attacks.
@@ -35,34 +99,11 @@ const BLOCKED_PROPERTY_NAMES = [
* sanitizeLocationName("__proto__") // null (blocked, logs warning)
* sanitizeLocationName("A".repeat(300)) // "AAA..." (truncated to 200 chars)
*/
export function sanitizeLocationName(name) {
if (!name || typeof name !== 'string') {
return null;
}
const trimmed = name.trim();
// Empty check
if (trimmed === '') {
return null;
}
// Check for dangerous property names (case-insensitive)
const lowerName = trimmed.toLowerCase();
if (BLOCKED_PROPERTY_NAMES.some(blocked => lowerName === blocked.toLowerCase())) {
console.warn(`[RPG Companion] Blocked dangerous location name: "${trimmed}"`);
return null;
}
// Max length check (reasonable location name)
const MAX_LOCATION_LENGTH = 200;
if (trimmed.length > MAX_LOCATION_LENGTH) {
console.warn(`[RPG Companion] Location name too long (${trimmed.length} chars), truncating to ${MAX_LOCATION_LENGTH}`);
return trimmed.slice(0, MAX_LOCATION_LENGTH);
}
return trimmed;
}
export const sanitizeLocationName = createStringValidator({
label: 'location name',
maxLength: 200,
checkBlockedNames: true
});
/**
* Validates and sanitizes item names.
@@ -76,27 +117,12 @@ export function sanitizeLocationName(name) {
* sanitizeItemName("") // null
* sanitizeItemName("A".repeat(600)) // "AAA..." (truncated to 500 chars)
*/
export function sanitizeItemName(name) {
if (!name || typeof name !== 'string') {
return null;
}
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;
}
export const sanitizeItemName = createStringValidator({
label: 'item name',
maxLength: 500,
additionalRejects: ['none'],
checkBlockedNames: false
});
/**
* 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.
</small>
<textarea id="rpg-prompt-dialogue-coloring" class="rpg-prompt-textarea" rows="4"></textarea>
<button class="menu_button rpg-restore-prompt-btn" data-prompt="dialogue-coloring" style="margin-top: 8px;">
<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>
</button>
</div>