Compare commits

..

305 Commits

Author SHA1 Message Date
ARIA 54e1b0c2b2 Fix equipment vanishing after save: preserve equipment in loadChatData 2026-07-03 12:07:46 +02:00
ARIA 9720a7befe Fix Equipment modal and json filter 2026-07-03 11:53:35 +02:00
ARIA 130998105a Added Agents.md 2026-07-03 11:24:11 +02:00
Pakobbix 411dc3eb9c Merge pull request 'feat: add Equipment system with slot validation and stat bonuses' (#1) from feature/equipment-system into main
Reviewed-on: #1
2026-07-03 09:16:57 +00:00
ARIA 10cfe581ac feat: add Equipment tab with slot-type validation
Add a new Equipment tab to manage player gear and stat bonuses.

Features:
- 19 equipment slots across 8 categories (helmet, necklace, body armor, gloves, pants, shoes, rings, accessories)
- Type-to-slot validation: each type has max equipped limits (1 helmet, 10 rings, 3 accessories, etc.)
- Auto-slot assignment: equipping a ring fills the first available ring slot
- Stat bonuses from equipped items display on RPG attributes (e.g. STR 10 +2)
- Create/edit modal with stat checkboxes per RPG attribute
- Inventory list for unequipped items

Architecture:
- Shared constants in src/systems/equipment/constants.js
- Category-based types (Ring, Accessory) with auto-slot assignment
- v7 migration converts legacy slot-specific types to generic categories
- Full i18n support for all UI strings

Files:
- New: src/systems/equipment/constants.js
- New: src/systems/interaction/equipmentActions.js
- New: src/systems/rendering/equipment.js
- Modified: state.js, persistence.js, template.html, index.js
- Modified: userStats.js, desktop.js, mobile.js, layout.js, modals.js
- Modified: apiClient.js, sillytavern.js, style.css, en.json
2026-07-03 11:11:23 +02:00
Spicy_Marinara 38fb3d8c51 Fix tracker issues and add deprecation notice 2026-05-04 13:08:52 +02:00
Spicy Marinara 70792f8a2a Merge pull request #143 from SpicyMarinara/SpicyMarinara-patch-1
Update README to mark project as deprecated
2026-05-04 13:02:16 +02:00
Spicy Marinara 4cd0b72472 Update README to mark project as deprecated
Removed version details and updated project status to deprecated.
2026-05-04 12:57:37 +02:00
Spicy Marinara 2d63e51962 Merge pull request #142 from CristianAUnisa/fix-parser
Harden parser handling for noisy and non-critical tracker responses
2026-05-04 12:21:14 +02:00
Spicy Marinara f142d04b48 Merge pull request #141 from CristianAUnisa/fix-injection
Fix tracker injection commit ordering and swipe source selection
2026-05-04 12:20:53 +02:00
CristianAUnisa 45c074499c Fix tracker injection context ordering and placeholder timing 2026-04-25 19:39:07 +02:00
CristianAUnisa ad66410f81 Harden parser handling for noisy and non-critical tracker responses 2026-04-25 17:35:23 +02:00
Spicy Marinara ed9c12e969 Merge pull request #135 from Tremendoussly/doom-lite-expression-sync-v2
Add optional alternate tracker displays and expression sync
2026-04-14 18:26:56 +02:00
Tremendoussly 60c430919b Handle swipe-deleted expression refresh and add zh-cn display strings 2026-04-14 17:43:57 +02:00
Tremendoussly 2ee081a619 Clean up post-merge delete handler artifacts 2026-04-14 17:08:42 +02:00
Tremendoussly 19ebf1a834 Merge upstream/main into doom-lite-expression-sync-v2 2026-04-14 16:21:07 +02:00
Spicy Marinara bda43208a2 Merge pull request #140 from jakstein/ofilter-fix
replace filter tags with ofilter to avoid HTML/SVG collision
2026-04-08 20:41:00 +02:00
jakstein 781b28e02b replace filter tags with ofilter to avoid HTML/SVG collision 2026-04-08 12:30:43 +02:00
Spicy Marinara e018cddb15 Merge pull request #136 from dd178/main
Add Simplified Chinese Support and Improve Parser Robustness
2026-04-07 13:34:45 +02:00
Spicy Marinara 05655fecf7 Merge branch 'main' into main 2026-04-02 20:41:21 +02:00
Spicy Marinara 4e36667890 Merge pull request #130 from Alamion/main
A few small FRs
2026-04-02 20:39:09 +02:00
Spicy Marinara c82eb03288 Merge pull request #129 from DAurielS/fix/swipe-tracker-state
Fix: Save tracker state per-swipe, per-message
2026-04-02 20:38:55 +02:00
dd178 96d589adc0 ```
feat(encounter): 添加战斗遭遇界面国际化支持和优化错误处理

- 添加新的中文翻译项包括战斗结果状态、错误消息、界面标签等
- 将硬编码的文本替换为国际化翻译调用
- 添加战斗遭遇初始化和处理过程中的错误处理消息
- 增加确认对话框的本地化文本

fix(regex): 更新正则表达式以支持Unicode字符

- 将多个文件中的ASCII限定正则表达式 /[^a-z0-9]+/g 替换为Unicode感知的
  /[^\p{L}\p{N}]+/gu 以正确处理非ASCII字符
- 修复jsonMigration.js中的字符过滤逻辑

feat(weather): 为中文添加天气模式识别规则

- 在WEATHER_PATTERNS_BY_LANGUAGE中为zh-cn语言添加完整的天气关键词模式
- 支持中文天气条件的自动识别和效果应用

style(fab): 添加nowrap样式防止文本换行

- 在FAB组件中添加white-space: nowrap样式属性
```
2026-03-23 03:27:12 +08:00
dd178 55aa2a1e6a ```
feat(i18n): 添加简体中文语言选项并扩展国际化支持

添加了简体中文(zh-cn)语言选项到设置页面的语言选择下拉菜单中。

同时新增了大量国际化字符串。

fix(parser): 提高解析器的鲁棒性

现在会遍历所有json对象检测统一格式,即使AI响应中包含多个JSON对象也能正确识别统一格式。
```
2026-03-22 14:07:11 +08:00
Tremendoussly 215a122797 Fix clear cache to remove per-swipe tracker data 2026-03-15 23:11:50 +01:00
Tremendoussly 1a11699522 Rename expression sync to thought-based expression portraits and clean up compatibility solutions 2026-03-15 22:18:25 +01:00
Tremendoussly c79c941871 Replace speaker-based expression sync with thoughts-driven sync 2026-03-15 21:38:45 +01:00
Tremendoussly 9ef5b16663 Break expression sync cycle and guard portrait lookup 2026-03-15 17:38:39 +01:00
Tremendoussly 1d297aeecf Gate below-chat expression sync and localize inline thoughts 2026-03-15 16:51:20 +01:00
Tremendoussly 4d2afafbaf Harden synced expression portrait URLs 2026-03-15 15:54:46 +01:00
Tremendoussly 08097e8b41 Translate new display settings for fr, ru, and zh-tw
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-15 15:02:42 +01:00
Tremendoussly e81afa4d74 Align inline thoughts (alternate display) with SillyTavern message text spacing 2026-03-09 14:26:59 +01:00
Tremendoussly 0499cc6961 Keep Present Characters visible during pending swipes 2026-03-09 13:49:32 +01:00
Tremendoussly 51b5360a7c Changed the scrollbar for multiple characters present 2026-03-08 23:25:22 +01:00
Tremendoussly c73c0c2bb6 First attempt at adding expression sync 2026-03-08 23:25:22 +01:00
Tremendoussly 2f98686e60 Add optional below-chat Present Characters panel (#3) 2026-03-08 22:58:42 +01:00
Tremendoussly ae9e44eafb Alternate Thoughts Display (#2)
This PR adds an optional alternate display mode for RPG Companion thoughts.

When enabled, thoughts are shown as compact expandable cards directly below the relevant latest character message. When disabled, RPG Companion keeps its original corner/overlay thought bubbles, so the existing behavior is preserved unless the user explicitly switches modes.

The new display mode is built on top of RPG Companion’s existing thoughts system rather than replacing it. Thought UI now updates more reliably across new generations, swipe changes, message deletion, chat reload/re-entry, and live mode toggling, so thoughts stay attached to the correct visible message instead of lingering on stale UI. It also improves restoration of RPG Companion state after reopening a chat, making thoughts and related tracker data more consistent with the current chat view.
2026-03-08 19:54:38 +01:00
Alamion 933d78e192 feat: localization validator for missing internationalization
fix: trackerEditor.js doesn't have syntax issues anymore.
2026-03-02 20:54:15 +03:00
Daryl 03345b81f4 Update Refresh RPG Info buttons in index.js to call commitTrackerDataFromPriorMessage() before updateRPGData() 2026-02-26 17:59:45 -04:00
Daryl c307f1a1bc Revert Copilot mistake in inheriting prior swipe data; testing in practice reveals inheritance does not work after applying its suggestion. 2026-02-26 17:32:16 -04:00
Daryl c442314c10 Remove redundant data commit call in sillytavern.js to prevent n-2 tracker data commits in fresh message generations 2026-02-26 17:09:33 -04:00
Spicy Marinara ce48ac0c34 Update src/systems/integration/sillytavern.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-26 01:48:15 +01:00
Spicy Marinara 8ea9044492 Update src/core/persistence.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-26 01:47:42 +01:00
Spicy Marinara 9213d264a0 Update src/systems/integration/sillytavern.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-26 00:40:22 +01:00
Daryl Streete 32280d60ef Update src/systems/integration/sillytavern.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 19:32:54 -04:00
Spicy Marinara 75c8f9b63a Update src/systems/ui/trackerEditor.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 23:58:51 +01:00
Spicy Marinara b1098a2721 Update src/systems/ui/trackerEditor.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 23:58:44 +01:00
Spicy Marinara 131b28fc1f Update src/systems/ui/trackerEditor.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 23:58:37 +01:00
Spicy Marinara 7305af8f88 Update src/systems/ui/trackerEditor.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 23:58:26 +01:00
Spicy Marinara 66a22c74d0 Update src/utils/transformations.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 23:58:02 +01:00
Spicy Marinara 5b2cb331c8 Update src/core/persistence.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 23:57:30 +01:00
Spicy Marinara bb202aca9c Update src/systems/integration/sillytavern.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 23:57:24 +01:00
Spicy Marinara 733647084a Update src/systems/integration/sillytavern.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 23:57:16 +01:00
Spicy Marinara ab848828e7 Update src/systems/integration/sillytavern.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 23:57:09 +01:00
Alamion 4d0de8419c fixes:
- now stats, attributes, characters stats have a changeable id
- now all additional promts are stacked in 2 lines
2026-02-23 21:51:02 +03:00
Daryl 76beb5dff4 Add inheritSwipeDataFromPriorMessage function to populate swipe slots with tracker data from prior messages in cases where tracker auto-update is disabled. This maintains the paradigm of every swipe having saved tracker data, which can then be regenerated by the user manually if they so choose. 2026-02-22 03:53:59 -04:00
Daryl 178ced00be Update widget displayed data when swiping 2026-02-22 00:35:26 -04:00
Daryl 979525e372 Add syncLastGeneratedDataFromSwipeStore function to manage swipe data retrieval and update lastGeneratedData on message changes 2026-02-21 23:47:47 -04:00
Daryl d96a199890 Implement separate generation ID to ensure that messages deleted during separate tracker generation do not attempt to apply the received data to a now non-existent message 2026-02-21 22:21:18 -04:00
Daryl 4b816dd1fd Add event handler for message deletion to sync tracker state and update UI to reflect the new most-recent message in chat 2026-02-21 21:45:47 -04:00
Daryl 8f2dbd2f88 Implement swipe data persistence between reloads and ensure all tracker data commits are based on prior assistant message when generating/swiping 2026-02-21 21:40:52 -04:00
Daryl f3e7518622 Enhance swipe data handling to correctly display swipe-specific tracker stats: add getSwipeData function and refactor commitTrackerData to utilize it 2026-02-21 20:40:33 -04:00
Spicy Marinara 502646bb92 Merge pull request #128 from Korddie/main
Add French translate
2026-02-19 18:52:14 +01:00
Spicy Marinara 98fd702175 Update src/i18n/fr.json
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-19 18:52:04 +01:00
rei 25aedce786 Add French translate
Add French translation and localize hardcoded strings :
Changes
1. Translation Files
Created
src/i18n/fr.json
: Contains all French translations for the extension.
Updated
src/i18n/en.json
: Added new keys for terms that were previously hardcoded (e.g., "Force", "Volonté", "Météo", "Locked", "Unlocked").
2. UI Updates
settings.html
: Added "Français" to the language selection dropdown.
3. Code Refactoring
src/systems/rendering/thoughts.js
: Replaced hardcoded strings ("Add Character", "Locked", "Click to edit") with i18n calls.
src/systems/rendering/userStats.js
: Replaced hardcoded tooltips and titles with i18n calls.
src/systems/rendering/infoBox.js
: Localized weather, location, and date widget texts.
src/systems/ui/trackerEditor.js
: Updated the "Reset to Defaults" logic to use localized names for stats (e.g., "Santé", "Force") based on the active language.
2026-02-18 03:56:18 +01:00
Spicy_Marinara 105e20e97a v3.7.2: Fix status field key generation for parenthetical names & scroll preservation
- Fix: Status fields with parenthetical descriptions (e.g., 'Conditions (up to 5 traits)') now use the base name for the JSON key ('conditions' instead of 'conditions_up_to_5_traits')
- Fix: Status field value templates no longer repeat the field name with numbered suffixes
- Fix: Editing fields in Present Characters no longer scrolls the panel to the top
- Updated jsonPromptHelpers.js, parser.js, and userStats.js to use new toFieldKey() helper
- Added scroll position preservation to renderThoughts() when re-rendering after field edits
2026-02-13 18:34:44 +01:00
Spicy_Marinara 5498c64f5d Opussy bug fix 2026-02-06 16:53:24 +01:00
Spicy_Marinara 5fa369e3d7 Update userStats.js 2026-02-04 10:28:49 +01:00
Spicy_Marinara 52be8dca1f Update README.md 2026-02-03 17:32:32 +01:00
Spicy_Marinara 32c4f67822 v3.7.1: Weather keywords, character stat editing fix, scroll bug fix, avatar layout
- Improved weather generation: Added hard templates for weather keywords to ensure LLM generates valid weather patterns that match dynamic effects
- Fixed character stat editing bug: Now properly handles array format stats from LLM (values no longer revert on blur)
- Fixed scroll/viewport bug: Mobile-only scrollIntoView prevents page jumping on desktop when editing fields
- Changed Present Characters avatar display: Avatar now aligned with name in header row, fields take full width below
- Updated descriptions and labels
2026-02-01 14:42:00 +01:00
Spicy_Marinara b61a426efe Update injector.js 2026-02-01 13:57:37 +01:00
Spicy_Marinara 2a77c091dd Add Jakstein to contributors list
Updated README.md and settings.html to include Jakstein as a contributor in the project acknowledgments.
2026-01-27 19:46:50 +01:00
Spicy_Marinara c0431a6117 Update contributors list 2026-01-27 14:38:48 +01:00
Spicy_Marinara 43610bf8b6 Merge branch 'main' of https://github.com/SpicyMarinara/rpg-companion-sillytavern 2026-01-27 14:36:21 +01:00
Spicy_Marinara 2a5b57087b Merge pr-109 into main: v3.7.0 2026-01-27 14:34:44 +01:00
Spicy_Marinara 653d23ef9a Merge main into pr-109 2026-01-27 14:34:39 +01:00
Spicy_Marinara ea81dd0634 v3.7.0: Merge PR #109 + opacity sliders + custom attributes fix + context instructions prompt + newline fixes 2026-01-27 14:33:36 +01:00
Spicy Marinara 7a3487c741 Merge pull request #109 from jakstein/omniscience-filter
Implementation of omniscience filter, ability to only reveal what player character can see without confusing the LLM.
2026-01-27 13:01:37 +01:00
Spicy_Marinara 6fc35e50a1 Refactor inventory lock logic to use item names
Updated inventory lock management and rendering to match items by name instead of index, improving reliability and consistency. Also adjusted quest rendering and parsing to handle locked quest objects with a value property.
2026-01-23 09:17:40 +01:00
Spicy_Marinara e82918004e v3.6.3: Fix relationship field to use correct nested format (relationship.status) 2026-01-20 21:51:41 +01:00
Spicy_Marinara f78c8a1b78 v3.6.2: Fix relationship field in context for manually added characters, add empty field placeholders and mobile support 2026-01-18 19:15:30 +01:00
Spicy_Marinara 2a48c30808 Update sillytavern.js 2026-01-17 21:34:53 +01:00
Spicy Marinara c5a9c8631f Merge pull request #115 from Olaroll/weather-pattern-fix
Fix weather pattern matching regression
2026-01-17 21:15:06 +01:00
Spicy Marinara 2623df4050 Merge pull request #117 from SpicyMarinara/revert-116-revert-111-main
Revert "Revert "internalization weatherEffects.js""
2026-01-17 21:14:55 +01:00
Spicy Marinara 03f21ef1ef Revert "Revert "internalization weatherEffects.js"" 2026-01-17 21:14:44 +01:00
Spicy Marinara 2e747bc8aa Merge pull request #116 from SpicyMarinara/revert-111-main
Revert "internalization weatherEffects.js"
2026-01-17 21:13:50 +01:00
Spicy Marinara d0dd8950a6 Revert "internalization weatherEffects.js" 2026-01-17 21:13:28 +01:00
Olari Tšernobrovkin 5ddc380dac Make constant's variable name consistent with the codebase 2026-01-17 20:03:34 +02:00
Olari Tšernobrovkin f4324a5d19 Fix weather pattern matching regression 2026-01-15 20:30:48 +02:00
Spicy Marinara 4612ed2108 Merge pull request #111 from IDeathByte/main
internalization weatherEffects.js
2026-01-15 11:04:53 +01:00
IDeathByte 0e988b201c Update weatherEffects.js
syntax fix
2026-01-15 11:38:26 +05:00
IDeathByte 7b4ebb8d76 internalization weatherEffects.js
update for russian support
2026-01-15 11:23:52 +05:00
jakstein 08474bd910 make the tags close with a slash for proper self termination 2026-01-14 22:40:58 +01:00
jakstein 0bb2085305 remove regex workaround and fix tag format 2026-01-14 22:08:51 +01:00
jakstein c6f13d18ff attempted slight improvement of the default prompt 2026-01-14 19:27:07 +01:00
jakstein 334f5fa5a3 roll back the default prompt, the new one was too cringy and aggresive 2026-01-14 19:16:40 +01:00
jakstein 5f9d67ebe8 attempt to fix trimming logic and improve prompt 2026-01-14 19:06:43 +01:00
jakstein 93c37c25d7 initial omniscience filter 2026-01-14 18:44:54 +01:00
Spicy Marinara 0499f2c43e Merge pull request #107 from tomt610/feature/improved-clear-weather-effects
Add sunrise/sunset effects and improve sun positioning
2026-01-14 00:51:16 +01:00
Spicy_Marinara 35bd55615b fixes 2026-01-13 23:24:40 +01:00
tomt610 f38f6850c3 Add sunrise/sunset effects and improve sun positioning
- Add sunrise (dawn 5-7 AM) with warm gradient, horizon glow, fading stars
- Add sunset (dusk 18-20) with orange gradient, horizon glow, emerging stars
- Widen sun arc from 5-85% to 3-92% for more dramatic sunset positioning
- Lower horizon position for setting/rising sun (35% to 40%)
- Fix mobile viewport with dvh/vw units for all overlay elements
- Reduce overlay opacity for subtler atmospheric effect
2026-01-13 20:26:55 +00:00
Spicy Marinara 989f511d01 Merge pull request #106 from tomt610/fix/stats-show-max-value-in-number-mode
Fix: Include max value in stats context when number mode is selected
2026-01-13 20:55:58 +01:00
tomt610 b827b77184 Fix: Include max value in stats context when number mode is selected 2026-01-13 19:47:14 +00:00
Spicy_Marinara 4f3d59bfb7 v3.6.1: Dynamic combat actions and bug fixes
- Added dynamic action updates: AI can now modify available attacks/items based on combat state
- Items decrease when used, abilities change based on status effects
- Fixed event delegation for encounter buttons to work reliably on mobile
- Fixed multiple JSON parsing validation errors
- Added proper dialogue handling in combat summaries
- UI now re-renders action buttons when actions change
- Improved prompt instructions for item quantities and dynamic actions
2026-01-13 19:22:01 +01:00
Spicy Marinara c18fd39283 Merge pull request #105 from IDeathByte/main
Add russian to settings.html
2026-01-13 15:45:34 +01:00
IDeathByte f5825a7a24 Add russian to settings.html 2026-01-13 19:00:26 +05:00
Spicy Marinara c14250e467 Merge pull request #104 from IDeathByte/main
Ru language
2026-01-13 13:53:27 +01:00
Spicy_Marinara e8edc42164 v3.6.0 - Bug fixes and number display mode for stats
- Fixed custom status fields not being sent to prompts or parsed
- Fixed date format selection not working beyond default format
- Fixed widget text overflow issues with minimal scrollbars
- Added ability to display stats as numbers with custom max values instead of percentages
- Enabled desktop strip widgets by default
- Removed icon from Desktop Collapsed Strip Widgets heading
2026-01-13 13:52:18 +01:00
IDeathByte acf119d4b4 Add russian language 2026-01-13 14:35:06 +05:00
IDeathByte 6582095cc1 add russian 2026-01-13 13:51:16 +05:00
IDeathByte 8aaf258ba3 add russian 2026-01-13 13:50:33 +05:00
IDeathByte 7c1c140a2a add russian 2026-01-13 13:49:48 +05:00
Spicy Marinara ce668c4793 Merge pull request #101 from tomt610/feature/desktop-strip-widgets
feat: Add desktop collapsed strip widgets
2026-01-13 09:35:51 +01:00
tomt610 3d6db2b0e9 Fix strip refresh button visibility based on generation mode 2026-01-13 00:55:45 +00:00
tomt610 2151b2dae3 fix: Use absolute positioning for strip widget container to fill full panel height 2026-01-13 00:40:26 +00:00
tomt610 4644e0fd93 feat: Add desktop collapsed strip widgets
- Add new desktopStripWidgets settings in state.js with toggles for weather, clock, date, location, stats, and attributes
- Add strip widget container in template.html with animated clock face
- Add CSS styles for strip widgets with wider collapsed panel (5rem), vertical layout, and theme support
- Implement updateStripWidgets() in desktop.js to populate widgets from tracker data
- Wire up settings handlers in index.js for all strip widget toggles
- Call updateStripWidgets() on data updates in sillytavern.js integration
- Trigger widget update when panel is collapsed in layout.js

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

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

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

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

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

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

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

- Add syncQuestsToCommittedData() function to update JSON quest data
- Call sync and saveChatData() on all quest modification actions
- Imports committedTrackerData, lastGeneratedData, saveChatData
2026-01-09 00:23:23 +00:00
Spicy_Marinara 0d71dcca04 v3.3.1: Fix Recent Events reading from lastGeneratedData and add desktop thought panel collapse 2026-01-08 23:29:18 +01:00
Spicy Marinara 39e2a07829 Merge pull request #85 from tomt610/feature/update-complete-event
Add event emission when tracker update completes
2026-01-08 23:18:10 +01:00
tomt610 dedfead59e Add event emission when tracker update completes
Emits 'rpg_companion_update_complete' event after updateRPGData() finishes.
This allows other extensions (like Context Prewarm) to hook into the
completion of tracker updates and perform actions afterward.

The event is emitted in the finally block, so it fires regardless of
success or failure, after isGenerating is reset.
2026-01-08 22:12:06 +00:00
Spicy_Marinara f1179d3b83 v3.3.0: Fix encounter UI theming and JSON cleaning regex properties 2026-01-08 21:52:31 +01:00
Spicy_Marinara 045d1da88b Update README.md 2026-01-08 19:50:20 +01:00
Spicy_Marinara bd056934e1 v3.2.5: Always enable JSON cleaning regex with proper settings 2026-01-08 18:27:16 +01:00
Spicy_Marinara e5bd1e0411 v3.2.4: Fix Present Characters field editing - relationship badges, custom fields, and avatar upload 2026-01-08 18:13:12 +01:00
Spicy_Marinara 055c19951c v3.2.3: Restore avatar upload feature for Present Characters 2026-01-08 13:05:02 +01:00
Spicy_Marinara d41996fb04 v3.2.2: Remove fixed max-width on mobile to support high zoom levels 2026-01-08 12:45:20 +01:00
Spicy_Marinara 2624309523 v3.2.1: Fix mobile viewport height issues with dvh units 2026-01-08 11:18:09 +01:00
Spicy_Marinara 7e9d98738f v3.2.0: Major update with JSON trackers, locking system, and UI improvements 2026-01-08 10:35:54 +01:00
Spicy_Marinara be05051a39 v3.1.1: Fix mobile tabs not initializing on mobile devices 2026-01-08 00:12:19 +01:00
Spicy_Marinara a3063aff4f v3.1.0: Add parser error detection and recommended models section 2026-01-07 22:56:26 +01:00
Spicy_Marinara dbf5c2d17a Merge branch 'main' of https://github.com/SpicyMarinara/rpg-companion-sillytavern 2026-01-07 21:29:20 +01:00
Spicy_Marinara 718d45095d v3.0.1 - Fix thought bubble editing persistence 2026-01-07 20:26:11 +01:00
Spicy_Marinara 897380d532 v3.0.1 - Fix thought bubble editing persistence 2026-01-07 20:25:49 +01:00
Spicy_Marinara 93327e4416 Merge remote changes for v3.0.0 release 2026-01-07 19:52:51 +01:00
Spicy_Marinara c3cdac24c6 Release v3.0.0 - Major update with JSON format, lock/unlock trackers, reorganized UI, colored dialogues, editable prompts, and numerous bug fixes 2026-01-07 17:22:22 +01:00
Spicy Marinara f536472dbe Merge pull request #82 from munimunigamer/auto-update-ext
Fixed auto updating with external api mode
2026-01-06 01:01:16 +01:00
munimunigamer 5bba422904 fixed auto updating with external api mode 2026-01-05 23:58:48 -06:00
Spicy_Marinara 8df6548e0b v2.1.3 - Improved thought bubble positioning and responsiveness
- Align thought bubbles with avatar top instead of center for better visibility
- Fix issue where bubbles extend above avatar when scrolling is limited
- Change thought circles to horizontal layout for cleaner visual flow
- Add responsive positioning that adapts to screen width changes
- Implement smart viewport detection to prevent cutoff at narrow widths
2026-01-03 11:40:07 +01:00
Spicy_Marinara 58020e93d0 Fix: Ensure clothing instruction is included in separate generation prompts 2026-01-03 01:17:41 +01:00
Spicy_Marinara ef03bb11ee Update version to 2.1.2 in settings.html 2026-01-03 00:59:07 +01:00
Spicy_Marinara d75f76b807 Update README with v2.1.2 changelog 2026-01-03 00:58:22 +01:00
Spicy_Marinara c6b71ec1aa Add optional toggle for Relationship Status Fields (v2.1.2)
- Added relationships.enabled toggle in tracker configuration
- Relationship fields and emoji badges can now be disabled/enabled
- UI toggle added in Edit Trackers > Present Characters tab
- Updated prompt generation to respect the toggle
- Maintains backward compatibility with existing configs
- Added i18n translations (en, zh-tw)
2026-01-03 00:55:29 +01:00
Spicy_Marinara d44bb1cff9 v2.1.1: Fix swipe detection in together mode and combat encounter prompt 2026-01-02 20:58:49 +01:00
Spicy_Marinara 87f0931942 Update README with v2.1 release notes 2026-01-02 14:09:13 +01:00
Spicy_Marinara 62ed7ffb18 v2.1: Add dynamic weather effects, clothing inventory, and bug fixes
Features:
- Add dynamic weather effects system (snow, rain, mist, sunshine, storm, wind, blizzard)
- Add separate Clothing tab in inventory system
- Weather effects auto-update based on Info Box weather field
- Combined effects for storm (rain+lightning) and blizzard (snow+wind)

Improvements:
- Settings migration system for automatic feature enablement
- Weather effects positioned behind chat interface (z-index: 1)
- Dynamic weather enabled by default for new users

Bug Fixes:
- Fix tab visibility issues (disabled tabs now properly hide)
- Fix theme-aware borders (remove hardcoded blue colors)
- Fix double scrollbar in Edit Trackers window
- Fix scroll position jumping when editing Present Characters
- Fix dynamic weather toggle hiding issue

Technical:
- Update inventory schema to v2.1 with clothing field
- Add automatic migration for existing v2 inventories
- Update parsers and prompts to handle clothing separately
- Add translations (EN/ZH-TW) for new features
2026-01-02 13:58:43 +01:00
Spicy_Marinara ddd59d124e bug 2025-12-31 18:14:44 +01:00
Spicy_Marinara 3bfc6ea934 Update to v2.0 and add version indicator 2025-12-31 10:53:09 +01:00
Spicy_Marinara 3f58c7ceca Add holiday promotion, snowflakes effect, and Spotify music widget
- Added holiday promotion banner with 2026WITHMARINARA discount code
- Added dismiss functionality for promotion with persistent state
- Implemented snowflakes animation effect with toggle
- Added Spotify music widget above chat input
- Widget matches extension theme colors and positioning
- Added Display Options toggles to show/hide feature toggles
- Improved responsive design and mobile support
2025-12-30 20:56:43 +01:00
Spicy Marinara 51535c5fdc Merge pull request #80 from munimunigamer/image-gen-updates
General Automatic Image Generation Updates
2025-12-30 11:14:55 +01:00
munimunigamer bc4d4a0dd1 prompt now focuses on bust up/face 2025-12-30 04:11:08 -12:00
munimunigamer 5eb602e91d added better prompt generation 2025-12-30 04:02:45 -12:00
munimunigamer ca4a318135 removed unused localization 2025-12-30 03:24:06 -12:00
munimunigamer b4ad757e42 removed unnecessary "LLM Instruction" prompt for image generation 2025-12-30 03:23:18 -12:00
Spicy_Marinara 530d871fd3 Update layout.js 2025-12-29 18:07:33 +01:00
Spicy_Marinara 474e3ce963 Add customizable prompts editor and reorganize settings panel
- Reorganized settings: moved Auto-update, Narrator Mode, and Debug Mode to Advanced section
- Added Customize Prompts button with comprehensive prompts editor modal
- Implemented 7 customizable AI prompts: HTML, plot progression (random/natural), avatar generation, tracker instructions, tracker continuation, and combat narrative
- Added individual and bulk restore to defaults functionality
- Integrated custom prompts across generation modules (plotProgression, promptBuilder, encounterPrompts)
- Auto-update toggle now disabled when not in Separate generation mode
- Merged XML/Markdown tracker instructions into unified prompt
2025-12-29 14:41:12 +01:00
Spicy_Marinara 0b5bca56eb Fix display settings persistence and add responsive layout for plot buttons
- Fixed updateSectionVisibility() to use explicit .show()/.hide() instead of .toggle() to ensure proper element visibility on page reload
- Added responsive CSS for plot buttons to adjust to small screens and mobile devices
- Wrapped button text in spans to enable icon-only mode on very small screens (≤400px)
- Reduced button margins and added flexbox layout with wrapping for better mobile UX
2025-12-29 13:32:30 +01:00
Spicy Marinara 39f4fed40d Merge pull request #79 from munimunigamer/external-mode
feature: Add External API Generation Mode with Secure Key Storage
2025-12-29 10:29:35 +01:00
munimunigamer 6ffcf9c929 detailing that it connects to openai rather than just third party 2025-12-29 02:58:05 -12:00
munimunigamer 10a4f9e89e fixed auto image gen to use external mode as well 2025-12-29 02:56:04 -12:00
munimunigamer fb8a6fcc30 using localstorage instead of extension settings for api key now 2025-12-29 02:50:30 -12:00
munimunigamer b037d95da8 hide the "use model connected to RPG Companion Trackers preset" 2025-12-29 02:43:53 -12:00
munimunigamer 018ab3613f changed default max tokens from 2048 to 8192 2025-12-29 02:42:51 -12:00
munimunigamer 5369cb14a5 add cors error logging, letting the user know if an endpoint can't be used due to cors blocking 2025-12-29 02:42:37 -12:00
munimunigamer 1d4a64bac7 added external api 2025-12-29 02:38:08 -12:00
munimunigamer 9936fb483d added external api settings to extension settings 2025-12-29 02:21:30 -12:00
Spicy_Marinara 3146f033df Fix clamp() to use rem/px for min/max values
- Replace clamp(Xvw, Yvw, Zvw) with clamp(Xrem, Yvw, Zrem)
- Prevents font sizes from scaling excessively on ultrawide monitors
- Now minimum and maximum values are fixed units while middle value remains responsive
- Fixes info box, stats, calendar, weather, and all other text elements
2025-12-28 23:38:41 +01:00
Spicy_Marinara ed421bee63 Fix font sizes for ultrawide monitors using clamp()
- Replace all vw-based font-size properties with clamp() to prevent excessively large text
- Set maximum font sizes to prevent issues on 3440x1440 and other ultrawide displays
- Maintain responsive behavior for normal and mobile screen sizes
- Fix gap properties using vw for better spacing consistency
2025-12-28 22:07:27 +01:00
Spicy Marinara 09463fc95a Merge pull request #75 from munimunigamer/avatar-gen-fixes
fixed right click regen and clearing chat
2025-12-27 21:32:53 +01:00
munimunigamer 10f6326f82 fixed right click regen and clearing chat 2025-12-27 12:36:48 -12:00
Spicy_Marinara 3caa74fbf8 Combat encounters: Add pre-encounter config modal, targeting fixes, and tracker integration
- Add pre-encounter narrative configuration modal with combat/summary style settings
- Change POV fields to text inputs (default: narrator) for custom character names
- Fix targeting system for enemies with spaces in names (e.g., 'Gilded Thrall 1')
- Display character-specific sprites/avatars in targeting modal instead of generic emojis
- Add combat difficulty scaling guidance to prevent trivial god defeats or endless wolf battles
- Integrate tracker updates in combat summary generation (together mode)
- Update auto-save logs description to clarify file storage vs chat history
- Apply extension theming to Close Combat Window button
2025-12-27 16:06:06 +01:00
Spicy Marinara 436f3495f8 Merge pull request #74 from joenunezb/fix/hanging-swipes-with-chapterCheckpoints
fix: Swipes hanging due race condition when handling promises in chapterCheckpoints
2025-12-27 08:42:04 +01:00
joenunezb afa39b1387 Useless comments 2025-12-26 19:24:08 -08:00
joenunezb b38dbe06a6 fix: Properly handle promises that lead to hangs on swipes 2025-12-26 19:01:05 -08:00
Spicy Marinara 6b73e422de Merge pull request #73 from munimunigamer/merge-fix-69-70
Quick fix on the merge problem
2025-12-26 18:13:41 +01:00
munimunigamer ed5bcb2670 fixed index.js file 2025-12-26 10:57:46 -06:00
Spicy Marinara c29f2b1bb5 Merge pull request #70 from munimunigamer/auto-image-generation
Add automatic avatar generation for NPCs with LLM-powered prompts
2025-12-26 10:00:38 +01:00
Spicy Marinara 91732b4d1c Merge branch 'main' into auto-image-generation 2025-12-26 10:00:29 +01:00
Spicy Marinara b163141652 Merge pull request #69 from munimunigamer/main
feat: Add configurable toggles for Narrator Mode and dice display
2025-12-26 09:58:32 +01:00
munimunigamer 87e86bfbb4 Removing my .claude settings file oopsies 2025-12-25 23:43:39 -08:00
munimunigamer d10d4e876f replaced disable placeholder card with Narrator mode (changes prompt a bit to support open ended rpgs/narrator cards that don't have defined characters better) 2025-12-26 01:31:16 -06:00
munimunigamer fdaca39d39 renamed stable diffusion to image generation 2025-12-26 01:01:04 -06:00
munimunigamer 2df173e6af fixed up re-rendering images when right clicking 2025-12-26 00:54:44 -06:00
munimunigamer de11f6f7e2 llm generated image gen prompts 2025-12-25 21:13:19 -08:00
munimunigamer b7e52046bc fixed avatar images appearing in rpg 2025-12-25 20:39:01 -08:00
munimunigamer 7802479670 auto-image-generation 2025-12-25 19:59:25 -08:00
munimunigamer c73260b2c6 Added placeholder and dice config options 2025-12-25 18:27:28 -08:00
Spicy_Marinara 04bd314da2 Add chapter checkpoint UI improvements and separate Quests toggle
- Fix checkpoint button display with expandMessageActions setting
- Add body class observer to update buttons when setting toggles
- Add cleanupCheckpointUI function for extension disable
- Separate Quests from Inventory with independent toggle
- Add horizontal scrolling to Info Box dashboard
- Add divider between Inventory and Quests sections
2025-12-22 01:05:01 +01:00
Spicy_Marinara d386752f9c Fix chapter checkpoint button duplication issue 2025-12-22 00:27:25 +01:00
Spicy_Marinara 9d8b758317 Remove accidentally committed log file 2025-12-21 23:55:02 +01:00
Spicy_Marinara fe03cba802 feat: Add remove button for characters in Present Characters panel
- Add removeCharacter() function to delete characters from panel and saved data
- Remove character from both lastGeneratedData and committedTrackerData
- Add X button to character card header with hover effects
- Button removes character from display and prevents re-inclusion in next generation
- Updates are persisted to chat metadata
2025-12-19 18:01:05 +01:00
Spicy_Marinara ab7dfeaf8b feat: Add custom avatar upload for NPCs in Present Characters panel
- Add npcAvatars storage to extension settings for custom NPC images
- Implement getCharacterAvatar() to check custom avatars first
- Add uploadNpcAvatar() function with file validation (2MB max, images only)
- Make character avatars clickable with visual feedback
- Support left-click to upload and right-click to remove custom avatars
- Add camera icon overlay on hover with smooth animations
- Store avatars as base64 data URIs for persistence across sessions
2025-12-18 14:14:49 +01:00
Spicy_Marinara 5bc7bfe22f Fix: Preserve decimal commas in numbers (e.g., 4443,445)
- Modified itemParser to detect commas between digits
- Prevents splitting money/numbers with comma decimal separators
- Example: '4443,445 gold coins' now stays as one item
- Added documentation and example for decimal comma handling
2025-12-18 02:03:11 +01:00
Spicy_Marinara 3ded104218 Add chapter checkpoint feature
- New feature: bookmark messages to exclude earlier history from context
- Saves tokens by marking chapter start points in long chats
- Uses SillyTavern's /hide and /unhide slash commands
- Persists checkpoint across page reloads and generation events
- UI: bookmark icon in message menus with visual indicators
- Debounced restore function prevents concurrent executions
- Pre-generation checkpoint application ensures messages stay hidden
- Clean production-ready code with proper error handling
2025-12-18 01:59:14 +01:00
Spicy_Marinara 8645bbde98 Revert "Merge pull request #57 from devsorcer/claude/add-character-state-tracking-01AC3zt7Z6eEYLfZXoZCgut4"
This reverts commit 8905db3e44, reversing
changes made to 628d8ee7a4.
2025-12-06 00:04:32 +01:00
Spicy Marinara cc1dd8dc11 Merge pull request #60 from SpicyMarinara/revert-59-main
Revert "All the features"
2025-12-05 22:44:11 +01:00
Spicy Marinara bfb63a34cd Revert "All the features" 2025-12-05 22:43:56 +01:00
Spicy_Marinara 275179fa7f Revert "Update promptBuilder.js"
This reverts commit 3c6daa6a72.
2025-12-05 22:43:04 +01:00
Spicy_Marinara 3c6daa6a72 Update promptBuilder.js 2025-12-05 21:54:15 +01:00
Spicy Marinara 02f74c8e75 Merge pull request #59 from Subarashimo/main
All the features
2025-12-05 20:51:38 +01:00
Spicy Marinara cdbf3a0354 Merge branch 'main' into main 2025-12-05 20:51:05 +01:00
Spicy Marinara 31f12941a8 Merge pull request #58 from devsorcer/main
Done
2025-12-05 20:47:50 +01:00
Spicy Marinara 8905db3e44 Merge pull request #57 from devsorcer/claude/add-character-state-tracking-01AC3zt7Z6eEYLfZXoZCgut4
Claude/add character state tracking 01 ac3zt7 z6e ey lf z xo z cgut4
2025-12-05 20:46:32 +01:00
Subarashimo 3500c200c6 Merge branch 'main' of https://github.com/Subarashimo/rpg-companion-sillytavern 2025-12-05 19:53:04 +01:00
Subarashimo e9317595b6 fix: string format skills 2025-12-05 19:52:25 +01:00
Subarashimo f1219d6a40 Merge branch 'main' into main 2025-12-05 18:16:05 +01:00
Subarashimo 7e47dbfd7c chore: final cleanup 2025-12-05 18:10:21 +01:00
Subarashimo 38328de1bf fix: clear skills 2025-12-05 16:29:54 +01:00
Subarashimo 806a7078a7 feat: message interception 2025-12-05 11:40:50 +01:00
Claude 6a513bc0b5 Add dynamic container creation as fallback if template fails to load 2025-12-05 05:19:41 +00:00
Claude ffed3aa1b5 Add diagnostic logging to character state tracking 2025-12-05 05:12:58 +00:00
Claude 14465e5ae9 Bump version to 2.0.0 with visible loading indicator 2025-12-05 05:06:39 +00:00
devsorcer 817dad352f Add files via upload 2025-12-05 10:32:28 +05:30
Claude 19c47de934 Add READY_TO_USE guide explaining 100% copy-paste integration 2025-12-05 04:53:10 +00:00
Claude c35e39c445 Integrate character state tracking system into main extension
This commit fully integrates the character tracking system into the
RPG Companion extension. Now 100% ready to use with zero manual work.

Changes to index.js:
- Added imports for character state modules
- Created event wrapper functions for:
  - onGenerationStarted (injects character tracking prompt)
  - onMessageReceived (parses and applies state updates)
  - onCharacterChanged (loads character state from chat)
- Added persistence functions (save/load to chat metadata)
- Modified event registration to use wrapper functions
- Added character state display initialization

Changes to template.html:
- Added #rpg-character-state-container for UI display

SYSTEM NOW FULLY FUNCTIONAL:
 LLM receives character state before generation
 LLM updates character state in responses
 States automatically parse and apply
 UI displays character emotions, physical stats, relationships
 State persists between sessions in chat metadata
 100% copy-paste ready - no manual integration needed

To use:
1. Files are already in place
2. System works automatically
3. Check console for [Character Tracking] logs
4. See character state in RPG panel
2025-12-05 04:52:01 +00:00
Claude 0440159089 Add comprehensive character state tracking system for {{char}}
This implements a complete Katherine RPG-based character state tracking
system that tracks the AI character ({{char}}) instead of the user.

Features:
- 40+ primary personality traits (dominance, honesty, empathy, etc.)
- 70+ secondary emotional states (happy, horny, anxious, playful, etc.)
- Physical stats tracking (energy, hunger, arousal, health, pain, etc.)
- Relationship tracking per-NPC (trust, love, attraction, thoughts, etc.)
- Clothing/outfit dynamic tracking
- Internal thoughts and contextual awareness
- LLM-driven automatic state updates based on responses
- Full UI rendering with tabbed interface

New Files:
- src/core/characterState.js (528 lines) - Core state data structure
- src/systems/generation/characterPromptBuilder.js (407 lines) - LLM prompts
- src/systems/generation/characterParser.js (456 lines) - Response parsing
- src/systems/rendering/characterStateRenderer.js (401 lines) - UI rendering
- CHARACTER_TRACKING_README.md - Complete documentation
- INTEGRATION_EXAMPLE.js - Step-by-step integration guide
- IMPLEMENTATION_SUMMARY.md - System overview and deliverables

System tracks 150+ individual stats per character with full LLM integration
for contextual, realistic character simulation.

All code is production-ready and copy-paste complete.
2025-12-05 04:39:53 +00:00
devsorcer 2d5b3c4c5b Add files via upload 2025-12-05 09:57:31 +05:30
Subarashimo 271c69ec49 feat: remove character button 2025-12-04 21:04:56 +01:00
Subarashimo 9f6c44745b feat: rpg stats improvements 2025-12-04 20:40:02 +01:00
Spicy Marinara 628d8ee7a4 Merge pull request #56 from IDeathByte/main
Fix for character card
2025-12-04 09:29:11 +01:00
IDeathByte 23a4e77b0a Fix for character card
Fixing the issue, when card data makes unreadable, when LLM generates more than 2 strings for appearance

Make it scrollable
2025-12-04 13:05:04 +05:00
Subarashimo b5f5f6d9c5 fix: classica js falsy bug 2025-12-03 22:45:58 +01:00
Subarashimo c24515db7e fix: several issues 2025-12-03 22:34:50 +01:00
Subarashimo 0f7fdfcef1 feat: json format, et al. 2025-12-03 14:55:30 +01:00
Subarashimo 56349f30e6 fix: prompt consistency 2025-12-03 10:02:39 +01:00
Subarashimo d775b45951 fix: missing divider handling 2025-12-03 09:24:00 +01:00
Subarashimo c1a343eb46 fix: flexible prompts 2025-12-03 09:22:42 +01:00
Subarashimo f3c224a99a feat: more settings 2025-12-03 09:19:03 +01:00
Spicy_Marinara 32c2543605 Respect showInventory toggle in prompt generation 2025-12-01 11:46:57 +01:00
Spicy Marinara 968aedc537 Merge pull request #52 from lilminzyu/i18n/zh-tw
Add Chinese (Traditional) i18n support
2025-12-01 11:31:44 +01:00
Mingyu f38bddec62 The processing of the Separate button was missing and has been added. Dynamic update logic centralized 2025-11-26 21:46:59 +08:00
Mingyu 691586ce2f Merge branch 'SpicyMarinara:main' into i18n/zh-tw 2025-11-26 15:51:12 +08:00
Mingyu d486c9e924 mobile done 2025-11-26 07:49:59 +00:00
Spicy_Marinara 0c5b55b190 Add character card info in separate mode with muted filtering and scrollable Past Events 2025-11-25 12:40:28 +01:00
Mingyu 6759f514f3 pc all done 2025-11-24 22:38:56 +08:00
Mingyu 79f99a40c6 RPG Companion index [done] 2025-11-24 19:48:34 +08:00
Mingyu ab33604ea0 Edit Trackers Pop-up window [done] 2025-11-24 17:40:07 +08:00
Mingyu 0f0a4dceeb RPG Companion Settings Pop-up window [done] 2025-11-24 17:37:38 +08:00
Mingyu 8ef4e4ba6d first try i18n base ok 2025-11-24 17:35:41 +08:00
Spicy_Marinara 950d83fc18 Update promptBuilder.js 2025-11-23 19:59:27 +01:00
Spicy Marinara 5e05dee0e8 Merge pull request #50 from chungchan-dev/fix/mobile-layout
fix: some mobile layout
2025-11-23 13:40:09 +01:00
Spicy_Marinara 67df7034eb Add custom HTML prompt editor, skills blur handler, and include skills in separate mode 2025-11-22 23:36:39 +01:00
Chanho Chung eef547b0fa fix: mobile layout of rpg-skills-section font size 2025-11-22 23:35:30 +09:00
Chanho Chung fed4e2d095 fix: mobile layout of rpg-skills-section and rpg-classic-stat 2025-11-22 23:27:45 +09:00
Chanho Chung 02f080cc98 fix: mobile layout of rpg-mobile-tabs, rpg-info-content, rpg-character-card 2025-11-22 23:09:54 +09:00
Chanho Chung 00265ba905 fix: mobile layout of rpg-info-box 2025-11-22 11:14:14 +09:00
Spicy_Marinara c3624c240f Exclude attributes from separate tracker generation requests 2025-11-21 00:01:44 +01:00
Spicy_Marinara 76c7e3cd9c Update HTML prompt wording and improve together mode swipe handling 2025-11-20 22:59:31 +01:00
Spicy_Marinara 2b45dc8fae Add stat change guidelines, attributes toggle, skills editing, and improved character parsing
- Add temporal awareness and stat decay rules to prompt (0-5% per message)
- Add 'Always Include Attributes' toggle in tracker editor
- Fix skills section editing (was not saving customFields)
- Improve Present Characters parser to handle malformed formats (mid-line chars, extra blank lines)
- All changes work in both together/separate generation modes
2025-11-18 15:10:24 +01:00
Spicy_Marinara ed3eac54fc Update promptBuilder.js 2025-11-16 00:06:32 +01:00
Spicy_Marinara c48b1dab46 Fix: Hide UI elements when extension disabled
- Skip UI initialization entirely when extension is disabled on page load
- Remove all UI elements (panel, buttons) from DOM when disabling extension
- Recreate full UI when re-enabling extension
- Hide mobile toggle button on desktop viewports (>1000px)
- Show/hide mobile toggle based on viewport size transitions
- Ensures clean state management for extension enable/disable
2025-11-13 23:30:44 +01:00
Spicy_Marinara bd891e39b0 Fix: Quests now properly scoped per-chat
Quests were bleeding through from other chats because loadChatData()
wasn't resetting them when switching to a chat without RPG data.

When loading a chat with no rpg_companion metadata, the function now
resets quests to empty state (main: 'None', optional: []) along with
other tracker data. This ensures each chat maintains its own quest
state independently.
2025-11-13 21:01:37 +01:00
Spicy Marinara dfbae54b48 Merge pull request #47 from amauragis/guided-generation-compat
Add a prompt injection suppresson feature for Guided Generations
2025-11-13 20:59:13 +01:00
Andy Mauragis dc37fd4a63 docs: Update README with guided generation compatibility info 2025-11-13 13:46:36 -05:00
Andy Mauragis 0ac85ad9fd feat: Add 'Skip Injections during Guided Generations' setting and UI 2025-11-13 13:46:36 -05:00
Andy Mauragis 407a45a25c feat: Add core suppression logic and integrate into prompt injector 2025-11-13 13:46:36 -05:00
Spicy_Marinara d658e337f6 Fix: Support multiple character variants in Present Characters panel
Fixed issues when AI generates multiple character variants (e.g.,
storyteller mode with 'Dottore (Prime)', 'Dottore (Beta)', etc.):

1. Escape quotes in character names to prevent HTML attribute breakage
   - Added escapeHtmlAttr() helper function
   - Prevents names like 'Marianna "Mari"' from breaking HTML

2. Restore avatar lookup for character variants
   - namesMatch() now strips parentheses and quotes from both sides
   - Allows 'Dottore (Prime)' to find 'Dottore' character card avatar
   - Each variant still gets its own card with separate attributes

3. Multiple characters now display correctly in panel
   - Each variant creates its own character object
   - Attributes (Details, Relationship, Stats, Thoughts) don't mix
   - All characters appear in the panel, not just the last one
2025-11-13 16:18:35 +01:00
Spicy Marinara 172c8d6ab8 Merge pull request #45 from joenunezb/fix/render-chat-message-properly
fix: Render chat messages using updateMessageBlock
2025-11-13 13:26:13 +01:00
joenunezb c23c68fbc3 fix: Render chat messages using updateMessageBlock 2025-11-13 03:43:41 -08:00
Spicy_Marinara d4fc3ce1d8 Add 'Always Show Thought Bubble' setting - keeps thought bubble permanently expanded 2025-11-06 22:20:35 +01:00
Spicy Marinara 227eb4c31e Merge pull request #43 from SpicyMarinara/revert-36-feat/v2-widget-dashboard-system
Revert "feat: v2 widget dashboard system"
2025-11-06 20:06:40 +01:00
79 changed files with 31157 additions and 4149 deletions
+1
View File
@@ -24,3 +24,4 @@ node_modules/
# Claude
CLAUDE.md
yarn.lock
+3
View File
@@ -0,0 +1,3 @@
{
"MD013": false
}
+330
View File
@@ -0,0 +1,330 @@
# RPG Companion for SillyTavern — Agent Instructions
## 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.
---
## Quick start
- **Entry point**: `index.js` (loaded by SillyTavern via `manifest.json`)
- **CSS**: `style.css` (single file, ~12,300 lines)
- **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).
---
## Manifest and loading
`manifest.json` tells SillyTavern how to load the extension:
```json
{
"display_name": "RPG Companion",
"loading_order": 100,
"js": "index.js",
"css": "style.css"
}
```
SillyTavern loads `index.js` and `style.css` relative to the extension directory. The extension name used internally is `third-party/rpg-companion-sillytavern`.
### Extension path detection
`src/core/config.js` auto-detects whether the extension is installed globally (`public/extensions/`) or per-user (`data/default-user/extensions/`) by inspecting `import.meta.url`. This determines template loading paths.
---
## SillyTavern integration
The extension imports from SillyTavern's global scripts using **relative paths** that assume the extension lives at:
- `scripts/extensions/third-party/rpg-companion-sillytavern/` (global install), or
- `data/default-user/extensions/third-party/rpg-companion-sillytavern/` (user install)
Key imports in `index.js`:
```js
import { getContext, renderExtensionTemplateAsync, extension_settings as st_extension_settings }
from '../../../extensions.js';
import { eventSource, event_types, substituteParams, chat, saveSettingsDebounced, ... }
from '../../../../script.js';
import { selected_group, getGroupMembers } from '../../../group-chats.js';
import { power_user } from '../../../power-user.js';
```
The deeper relative imports in `src/` files (e.g., `../../../../../../script.js`) follow the same pattern adjusted for directory depth.
### SillyTavern globals available at runtime
- `self.SillyTavern` — global namespace object
- `$` / `jQuery` — jQuery
- `toastr` — toast notification library
- `eventSource` — SillyTavern's event emitter
- `chat` — current chat message array
- `chat_metadata` — per-chat metadata store
- `characters` — loaded character array
- `getContext()` — returns current chat context
---
## Architecture
### Source tree (`src/`)
```
src/
├── core/ # Core infrastructure
│ ├── config.js # Extension name, folder path, default settings
│ ├── state.js # Centralized state variables + getters/setters
│ ├── 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/ # Translation JSON files
│ ├── en.json # English (reference, 513 lines)
│ ├── fr.json # French
│ ├── ru.json # Russian
│ ├── zh-cn.json # Simplified Chinese
│ ├── zh-tw.json # Traditional Chinese
│ └── validator.js # Node.js script to validate translation consistency
├── 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
│ │ ├── apiClient.js # Makes API calls for separate/external generation
│ │ ├── injector.js # Injects tracker prompts into generation context
│ │ ├── lockManager.js # Manages locked tracker fields
│ │ ├── suppression.js # Controls when to skip tracker injection
│ │ ├── encounterPrompts.js # Combat encounter prompt building
│ │ ├── inventoryParser.js # Parses inventory data from AI responses
│ │ └── jsonPromptHelpers.js # JSON formatting helpers for prompts
│ ├── rendering/ # UI rendering functions
│ │ ├── userStats.js # Renders user stats panel
│ │ ├── infoBox.js # Renders info box (date, weather, location, events)
│ │ ├── thoughts.js # Renders character thoughts + chat bubbles
│ │ ├── inventory.js # Renders inventory section
│ │ ├── equipment.js # Renders equipment section
│ │ ├── quests.js # Renders quests section
│ │ └── musicPlayer.js # Renders Spotify music player
│ ├── ui/ # UI components and helpers
│ │ ├── theme.js # Theme system (default, sci-fi, fantasy, cyberpunk, custom)
│ │ ├── modals.js # Settings modal, dice modal, deprecation modal
│ │ ├── layout.js # Panel positioning, collapse/expand, section visibility
│ │ ├── mobile.js # Mobile FAB button, mobile tabs, keyboard handling
│ │ ├── desktop.js # Desktop strip widgets
│ │ ├── trackerEditor.js # Tracker configuration editor
│ │ ├── promptsEditor.js # Custom prompt editor
│ │ ├── checkpointUI.js # Chapter checkpoint buttons
│ │ ├── encounterUI.js # Combat encounter modal
│ │ ├── snowflakes.js # Snowflake animation effect
│ │ ├── weatherEffects.js # Dynamic weather visual effects
│ │ └── alternatePresentCharacters.js # Below-chat character panel
│ ├── integration/ # SillyTavern event integration
│ │ ├── sillytavern.js # Event handlers: message sent/received, swipe, etc.
│ │ └── thoughtBasedExpressions.js # Integrates with ST Character Expressions
│ ├── interaction/ # User interaction handlers
│ │ ├── inventoryActions.js # Inventory click/edit handlers
│ │ ├── equipmentActions.js # Equipment click/edit handlers
│ │ └── inventoryEdit.js # Inventory inline editing
│ └── features/ # Feature modules
│ ├── plotProgression.js # Plot progression buttons
│ ├── classicStats.js # D&D-style attribute buttons
│ ├── dice.js # Dice roller
│ ├── htmlCleaning.js # Regex-based HTML tag cleaning
│ ├── jsonCleaning.js # Regex-based JSON cleanup in messages
│ ├── musicPlayer.js # Spotify URL parsing and embedding
│ ├── chapterCheckpoint.js # Save/restore tracker state checkpoints
│ ├── avatarGenerator.js # Auto-generate character avatars
│ └── encounterState.js # Combat encounter state management
├── types/ # JSDoc type definitions
│ └── inventory.js # InventoryV2 type definition
└── utils/ # Utility functions
├── avatars.js # Avatar URL handling
├── 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
├── 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
├── sillyTavernExpressions.js # ST expression mapping
├── thoughtBasedExpressionPortraits.js # Expression-to-portrait mapping
└── transformations.js # Data transformation utilities
```
### Data flow
1. **User sends message**`MESSAGE_SENT` event fires
2. **Extension hooks** `onMessageSent` in `sillytavern.js`
3. **Together mode**: Tracker prompt is injected into the generation context via `injector.js`
4. **Separate mode**: After main response, `apiClient.js` makes a separate API call
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
9. **Persistence**: Tracker data is saved to `chat_metadata` per chat, and per-swipe
### State management
All extension state lives in `src/core/state.js` as module-level `let` variables with getter/setter functions. Key state:
- `extensionSettings` — persisted settings object (merged with defaults on load)
- `lastGeneratedData` — tracker data from the last AI generation
- `committedTrackerData` — tracker data committed after a message
- `lastActionWasSwipe` — flag to handle swipe-specific logic
- `isGenerating` — flag to prevent re-entrant generation
- `pendingDiceRoll` — dice roll result to inject into next generation
- DOM element caches: `$panelContainer`, `$userStatsContainer`, etc.
### Persistence
`src/core/persistence.js` handles:
- Extension settings: saved via SillyTavern's `saveSettingsDebounced()`
- Chat-specific data: saved in `chat_metadata.rpg_companion` per chat
- Per-swipe data: stored on each chat message object
- Settings versioning: automatic migration via `CURRENT_SETTINGS_VERSION` (currently 7)
- Deferred saves: batches chat data saves to avoid excessive writes
---
## Development commands
```bash
# Validate translation files (check missing keys, type mismatches, empty values)
npm run validate_locale_once
# Watch mode: re-validate on file changes
npm run validate_locale
```
No build, lint, test, or typecheck commands exist. The code runs directly in the browser.
---
## i18n system
### How it works
1. Translation files are flat JSON in `src/i18n/{locale}.json`
2. Keys use dot notation: `"template.settingsModal.display.showUserStats"`
3. HTML elements use `data-i18n-key`, `data-i18n-title`, or `data-i18n-aria-label` attributes
4. `src/core/i18n.js` fetches translations via `fetch()` from the extension path
5. `applyTranslations()` walks the DOM and updates `textContent`, `title`, and `aria-label`
### Adding translations
1. Add keys to `src/i18n/en.json` (reference locale)
2. Add corresponding keys to all other locale files
3. Add `data-i18n-key="your.key.here"` to HTML elements in `template.html` or `settings.html`
4. For dynamically generated text, use `i18n.getTranslation('your.key.here')` in JS
### Validator
`src/i18n/validator.js` is a **Node.js script** (uses `require()`, not ES modules). It:
- Compares all locales against the reference locale for missing/extra keys
- Checks for type mismatches
- Checks for empty strings and null values
- Scans `.html`/`.js`/`.jsx` files for unlocalized text (text in tags without `data-i18n-key`)
---
## Key conventions
### jQuery usage
DOM manipulation uses jQuery throughout. Element caches are stored as jQuery objects with `$` prefix:
```js
$panelContainer = $('#rpg-companion-panel')
$userStatsContainer = $('#rpg-user-stats')
```
### Event system
All SillyTavern event registration goes through `src/core/events.js`:
```js
registerAllEvents({
[event_types.MESSAGE_SENT]: onMessageSent,
[event_types.CHAT_CHANGED]: [onCharacterChanged, updatePersonaAvatar],
});
```
### CSS naming
All CSS classes use `rpg-` prefix to avoid conflicts with SillyTavern and other extensions:
- `.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`
### Panel structure
The main panel (`template.html`) has these sections in order:
1. Collapse toggle button
2. Strip widget container (shown when panel is collapsed)
3. Game container with header
4. Dice roll display
5. Content box with sections: User Stats → Info Box → Thoughts → Inventory → Equipment → Quests → Music Player
6. Feature toggles row (HTML, Dialogue Coloring, Deception, Omniscience, CYOA, Spotify, Weather, Narrator, Auto Avatars)
7. Manual update button
8. Settings and Edit Trackers buttons
### Generation modes
Three modes control how tracker data is generated:
- **`together`**: Tracker prompt injected into main generation. Single API call, faster, but tracker JSON mixed in response.
- **`separate`**: Two API calls — one for roleplay, one for tracker data. Cleaner roleplay, slower.
- **`external`**: Uses user-configured external API (OpenAI-compatible endpoint) for tracker generation.
### Guided generation compatibility
The `skipInjectionsForGuided` setting controls tracker injection behavior during guided generations:
- `'none'` — always inject (default)
- `'impersonation'` — skip only for impersonation-style guided generations
- `'guided'` — skip for any guided/instruct or quiet_prompt generation
---
## Important gotchas
1. **No bundler**: All imports use relative paths that depend on the extension's directory structure within SillyTavern. Changing file locations requires updating import paths.
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).
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`.
5. **i18n fetch path**: Translations are fetched at `/scripts/extensions/third-party/rpg-companion-sillytavern/src/i18n/{lang}.json`. The hardcoded path in `i18n.js` must match the extension's actual URL path.
6. **Extension enable/disable**: The extension can be toggled from SillyTavern's Extensions tab. When disabled, all UI elements are removed from the DOM. When re-enabled, `initUI()` rebuilds everything.
7. **Mobile vs desktop**: Viewport breakpoint is 1000px. Below: FAB button + mobile tabs. Above: fixed panel + desktop tabs.
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.
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.
---
## File reference
| File | Purpose |
|------|---------|
| `index.js` | Main entry point. All imports, UI init, event registration, startup logic. |
| `manifest.json` | SillyTavern extension manifest (name, loading order, JS/CSS files). |
| `style.css` | All CSS (~12,300 lines). Themes, animations, responsive breakpoints. |
| `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. |
| `src/core/persistence.js` | Settings/chat data save/load, version migrations. |
| `src/core/config.js` | Extension name, path detection, default settings documentation. |
| `src/core/events.js` | SillyTavern event system wrapper with bulk register/unregister. |
| `src/core/i18n.js` | Translation loader and DOM applier. |
| `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/apiClient.js` | API client for separate/external generation modes. |
| `src/systems/generation/injector.js` | Injects tracker prompts into SillyTavern's generation context. |
| `src/i18n/validator.js` | Node.js script for validating translation consistency. |
+31 -11
View File
@@ -5,6 +5,14 @@ An immersive RPG extension for browsers that tracks character stats, scene infor
[![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>
## 📥 Installation
1. Open SillyTavern
@@ -13,7 +21,7 @@ An immersive RPG extension for browsers that tracks character stats, scene infor
3. Go to Install extension
4. Copy-paste this link: https://github.com/SpicyMarinara/rpg-companion-sillytavern
4. Copy-paste this link: <https://github.com/SpicyMarinara/rpg-companion-sillytavern>
5. Press Install for all users/Install just for me
@@ -57,8 +65,6 @@ An immersive RPG extension for browsers that tracks character stats, scene infor
### To-Do
1. Allow users to use a different model for the separate trackers generation
2. ~~Make all trackers and fields customizable~~ ✅ Done!
3. ~~Kill myself~~
## ⚙️ Settings
@@ -93,11 +99,13 @@ AI: Trackers + Full roleplay response
↓ Main chat shows clean roleplay text
Pros:
- Single API call
- Faster response
- Simpler setup
Cons:
- Tracker formatting mixed in AI response
- May affect roleplay quality slightly
@@ -121,11 +129,13 @@ AI: Separate call with just the tracker data
↓ Context summary injected into the next generation
Pros:
- Clean roleplay responses
- Better roleplay quality
- Contextual summary enhances immersion
Cons:
- Extra API call
- Slightly slower
@@ -157,16 +167,19 @@ You can edit most fields by clicking on them:
Access comprehensive customization through the Tracker Settings button:
**User Stats Configuration:**
- Add/remove custom stats with unique names
- Configure Status section (mood emoji + custom fields)
- Configure Skills section with custom skill fields
- Toggle RPG attributes display
**Info Box Configuration:**
- Enable/disable individual widgets (Date, Weather, Temperature, Time, Location, Recent Events)
- Choose temperature unit (Celsius/Fahrenheit)
**Present Characters Configuration:**
- Add custom character fields (appearance, action, demeanor, etc.)
- Configure relationship status options
- Enable character-specific stats tracking
@@ -186,6 +199,18 @@ The extension fully supports swipes:
You can click the "Refresh RPG Info" button in the settings to refresh the RPG data at any time in separate generation mode.
### Compatibility with Guided Generations
This extension detects when a "guided generation" prompt is submitted (for example, via the GuidedGenerations extension which injects an ephemeral `instruct` prompt), and will avoid adding its tracker injection instructions (requests for stats, info box, and context prompts) to the generation context. This prevents conflicting instructions and ensures guided generations behave as the user expects.
If you want tracker prompts to apply during a guided generation, run the update via separate generation or temporarily disable guided generation in the other extension.
There is a new setting "Skip Tracker & HTML Injections during Guided Generations" in the RPG Companion settings (Advanced section). It now supports three modes:
- none: never skip (always inject the tracker prompts as usual, default)
- impersonation: only skip when an impersonation-style guided generation is detected
- guided: skip whenever a guided `instruct` or `quiet_prompt` generation is detected
## 🎨 Themes
Choose from 6 beautiful themes:
@@ -245,13 +270,8 @@ If you enjoy this extension, consider supporting development:
## 🙏 Credits
- Extension Development: Marinara with assistance from GitHub Copilot
- Immersive HTML concept: Credit to u/melted_walrus
- Info Box prompt inspiration: MidnightSleeper
- Stats Tracker concept: Community feedback
- Special thanks to Quack for helping me with the CSS
- Massive kudos to Paperboy for making the mobile version work, fixing bugs, and adding the inventory system
- Thanks to IDeathByte for solving some CSS scaling issues
**Contributors:**
SpicyMarinara, Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte, Chungchandev, Joenunezb, Amauragis, Tomt610, and Jakstein.
## 🚀 Planned Features
@@ -273,4 +293,4 @@ If you enjoy this extension, consider supporting development:
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
[mgrabower97@gmail.com](mailto:mgrabower97@gmail.com)
-264
View File
@@ -1,264 +0,0 @@
{
"chat_completion_source": "custom",
"openai_model": "gpt-4o",
"claude_model": "claude-3-sonnet-20240229",
"openrouter_model": "OR_Website",
"openrouter_use_fallback": false,
"openrouter_group_models": false,
"openrouter_sort_models": "alphabetically",
"openrouter_providers": [],
"openrouter_allow_fallbacks": true,
"openrouter_middleout": "on",
"ai21_model": "jamba-large",
"mistralai_model": "mistral-large-latest",
"cohere_model": "command-r-plus",
"perplexity_model": "llama-3-70b-instruct",
"groq_model": "llama3-70b-8192",
"xai_model": "grok-4-0709",
"pollinations_model": "openai",
"aimlapi_model": "gpt-4o-mini-2024-07-18",
"electronhub_model": "gpt-4o-mini",
"electronhub_sort_models": "alphabetically",
"electronhub_group_models": false,
"moonshot_model": "kimi-latest",
"fireworks_model": "accounts/fireworks/models/kimi-k2-instruct",
"cometapi_model": "gpt-4o",
"custom_model": "",
"custom_prompt_post_processing": "semi",
"google_model": "gemini-pro",
"vertexai_model": "gemini-2.5-pro-exp-03-25",
"azure_api_version": "2024-02-15-preview",
"azure_openai_model": "",
"temperature": 1,
"frequency_penalty": 0,
"presence_penalty": 0,
"top_p": 1,
"top_k": 0,
"top_a": 1,
"min_p": 0,
"repetition_penalty": 1,
"openai_max_context": 16384,
"openai_max_tokens": 8192,
"wrap_in_quotes": false,
"names_behavior": -1,
"send_if_empty": "",
"impersonation_prompt": "",
"new_chat_prompt": "",
"new_group_chat_prompt": "",
"new_example_chat_prompt": "",
"continue_nudge_prompt": "",
"bias_preset_selected": "Default (none)",
"max_context_unlocked": false,
"wi_format": "",
"scenario_format": "",
"personality_format": "",
"group_nudge_prompt": "",
"stream_openai": false,
"prompts": [
{
"name": "Main Prompt",
"system_prompt": true,
"role": "system",
"content": "",
"identifier": "main",
"injection_position": 0,
"injection_depth": 4,
"forbid_overrides": false
},
{
"name": "NSFW Prompt",
"system_prompt": true,
"role": "system",
"content": "",
"identifier": "nsfw"
},
{
"identifier": "dialogueExamples",
"name": "Chat Examples",
"system_prompt": true,
"marker": true
},
{
"name": "Jailbreak Prompt",
"system_prompt": true,
"role": "system",
"content": "",
"identifier": "jailbreak"
},
{
"identifier": "chatHistory",
"name": "Chat History",
"system_prompt": true,
"marker": true
},
{
"identifier": "worldInfoAfter",
"name": "World Info (after)",
"system_prompt": true,
"marker": true
},
{
"identifier": "worldInfoBefore",
"name": "World Info (before)",
"system_prompt": true,
"marker": true
},
{
"identifier": "enhanceDefinitions",
"role": "system",
"name": "Enhance Definitions",
"content": "If you have more knowledge of {{char}}, add to the character's lore and personality to enhance them but keep the Character Sheet's definitions absolute.",
"system_prompt": true,
"marker": false
},
{
"identifier": "charDescription",
"name": "Char Description",
"system_prompt": true,
"marker": true
},
{
"identifier": "charPersonality",
"name": "Char Personality",
"system_prompt": true,
"marker": true
},
{
"identifier": "scenario",
"name": "Scenario",
"system_prompt": true,
"marker": true
},
{
"identifier": "personaDescription",
"name": "Persona Description",
"system_prompt": true,
"marker": true
}
],
"prompt_order": [
{
"character_id": 100000,
"order": [
{
"identifier": "main",
"enabled": true
},
{
"identifier": "worldInfoBefore",
"enabled": true
},
{
"identifier": "charDescription",
"enabled": true
},
{
"identifier": "charPersonality",
"enabled": true
},
{
"identifier": "scenario",
"enabled": true
},
{
"identifier": "enhanceDefinitions",
"enabled": false
},
{
"identifier": "nsfw",
"enabled": true
},
{
"identifier": "worldInfoAfter",
"enabled": true
},
{
"identifier": "dialogueExamples",
"enabled": true
},
{
"identifier": "chatHistory",
"enabled": true
},
{
"identifier": "jailbreak",
"enabled": true
}
]
},
{
"character_id": 100001,
"order": [
{
"identifier": "main",
"enabled": false
},
{
"identifier": "worldInfoBefore",
"enabled": false
},
{
"identifier": "personaDescription",
"enabled": false
},
{
"identifier": "charDescription",
"enabled": false
},
{
"identifier": "charPersonality",
"enabled": false
},
{
"identifier": "scenario",
"enabled": false
},
{
"identifier": "enhanceDefinitions",
"enabled": false
},
{
"identifier": "nsfw",
"enabled": false
},
{
"identifier": "worldInfoAfter",
"enabled": false
},
{
"identifier": "dialogueExamples",
"enabled": false
},
{
"identifier": "chatHistory",
"enabled": false
},
{
"identifier": "jailbreak",
"enabled": false
}
]
}
],
"show_external_models": false,
"assistant_prefill": "",
"assistant_impersonation": "",
"claude_use_sysprompt": true,
"use_makersuite_sysprompt": true,
"vertexai_auth_mode": "full",
"squash_system_messages": true,
"image_inlining": false,
"inline_image_quality": "auto",
"video_inlining": false,
"bypass_status_check": false,
"continue_prefill": false,
"continue_postfix": "",
"function_calling": false,
"show_thoughts": false,
"reasoning_effort": "auto",
"enable_web_search": false,
"request_images": false,
"seed": -1,
"n": 1,
"extensions": {}
}
+1083 -156
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -5,7 +5,7 @@
"optional": [],
"js": "index.js",
"css": "style.css",
"author": "Marysia",
"version": "1.1.0",
"author": "Marinara",
"version": "3.7.4",
"homePage": "https://github.com/SpicyMarinara/rpg-companion-sillytavern"
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "rpg-companion-sillytavern",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "rpg-complanion-sillytavern",
"version": "3.7.4",
"description": "",
"main": "index.js",
"scripts": {
"validate_locale": "node src/i18n/validator.js --watch",
"validate_locale_once": "node src/i18n/validator.js"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"chokidar": "^5.0.0",
"fs-extra": "^11.3.3",
"glob": "^13.0.6"
},
"dependencies": {}
}
+47 -6
View File
@@ -7,18 +7,59 @@
<div class="inline-drawer-content">
<label class="checkbox_label" for="rpg-extension-enabled">
<input type="checkbox" id="rpg-extension-enabled" />
<span>Enable RPG Companion</span>
<span data-i18n-key="settings.extensionEnabled">Enable RPG Companion</span>
</label>
<small class="notes">Toggle to enable/disable the RPG Companion extension. Configure additional settings within the panel itself.</small>
<div class="form-group" style="margin-top: 10px;">
<label for="rpg-companion-language-select" data-i18n-key="settings.language.label">Language</label>
<select id="rpg-companion-language-select" class="text_pole">
<option value="en" data-i18n-key="settings.language.option.en">English</option>
<option value="zh-cn" data-i18n-key="settings.language.option.zh-cn">简体中文</option>
<option value="zh-tw" data-i18n-key="settings.language.option.zh-tw">繁體中文</option>
<option value="ru" data-i18n-key="settings.language.option.ru">Русский</option>
<option value="fr" data-i18n-key="settings.language.option.fr">Français</option>
</select>
</div>
<small class="notes" data-i18n-key="settings.note">Toggle to enable/disable the RPG Companion extension.
Configure additional settings within the panel itself.</small>
<div style="margin-top: 10px; display: flex; gap: 10px;">
<a href="https://discord.com/invite/KdAkTg94ME" target="_blank" class="menu_button" style="flex: 1; text-align: center; text-decoration: none;">
<i class="fa-brands fa-discord"></i> Discord
<a href="https://discord.com/invite/KdAkTg94ME" target="_blank" class="menu_button"
style="flex: 1; text-align: center; text-decoration: none;">
<i class="fa-brands fa-discord"></i>&nbsp;Discord
</a>
<a href="https://ko-fi.com/marinara_spaghetti" target="_blank" class="menu_button" style="flex: 1; text-align: center; text-decoration: none;">
<i class="fa-solid fa-heart"></i> Support Creator
<a href="https://ko-fi.com/marinara_spaghetti" target="_blank" class="menu_button"
style="flex: 1; text-align: center; text-decoration: none;">
<i class="fa-solid fa-heart"></i>&nbsp;Support
</a>
</div>
<div style="margin-top: 15px; text-align: center; opacity: 0.7; font-size: 0.8em; line-height: 1.5;">
<div style="margin-bottom: 5px;">
<i class="fa-solid fa-microchip"></i> <strong
data-i18n="settings.recommendedModels.title">Recommended Models:</strong>
</div>
<div style="opacity: 0.8; font-size: 0.9em;" data-i18n="settings.recommendedModels.description">
For the extension to work properly, <strong>it is not recommended to use any models below 20B,
especially if they're old.</strong> It works best with the SOTA models such as Deepseek, Claude,
GPT, or Gemini.
</div>
</div>
<div style="margin-top: 15px; text-align: center; opacity: 0.7; font-size: 0.8em; line-height: 1.5;">
<div style="margin-bottom: 5px;">
<i class="fa-solid fa-users"></i> <strong>Contributors:</strong>
</div>
<div style="opacity: 0.8; font-size: 0.9em;">
SpicyMarinara, Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte,
Chungchandev, Joenunezb, Amauragis, Tomt610, and Jakstein.
</div>
</div>
<div style="margin-top: 10px; text-align: center; opacity: 0.6; font-size: 0.85em;">
v3.7.2
</div>
</div>
</div>
</div>
+17 -5
View File
@@ -26,15 +26,29 @@ export const defaultSettings = {
autoUpdate: true,
updateDepth: 4, // How many messages to include in the context
generationMode: 'together', // 'separate' or 'together' - whether to generate with main response or separately
useSeparatePreset: false, // Use 'RPG Companion Trackers' preset for tracker generation instead of main API model
showUserStats: true,
showInfoBox: true,
showCharacterThoughts: true,
showAlternatePresentCharactersPanel: false,
enableThoughtBasedExpressions: false,
hideDefaultExpressionDisplay: false,
showInventory: true, // Show inventory section (v2 system)
showQuests: true, // Show quests section
showLockIcons: true, // Show lock/unlock icons on tracker items
showThoughtsInChat: true, // Show thoughts overlay in chat
alwaysShowThoughtBubble: false, // Auto-expand thought bubble without clicking icon
thoughtsInChatStyle: 'corner', // 'corner' or 'inline'
enableHtmlPrompt: false, // Enable immersive HTML prompt injection
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
panelPosition: 'right', // 'left', 'right', or 'top'
theme: 'default', // Theme: default, sci-fi, fantasy, cyberpunk, custom
customColors: {
@@ -75,7 +89,5 @@ export const defaultSettings = {
cha: 10
},
lastDiceRoll: null, // Store last dice roll result
collapsedInventoryLocations: [], // Array of collapsed storage location names
debugMode: false, // Enable debug logging visible in UI (for mobile debugging)
memoryMessagesToProcess: 16 // Number of messages to process per batch in memory recollection
collapsedInventoryLocations: [] // Array of collapsed storage location names
};
+103
View File
@@ -0,0 +1,103 @@
//- No-op in case this is running outside of SillyTavern
const { extension_settings } = typeof self.SillyTavern !== 'undefined' ? self.SillyTavern.getContext() : { extension_settings: {} };
class Internationalization {
constructor() {
this.currentLanguage = 'en';
this.translations = {};
this._listeners = {};
}
addEventListener(event, callback) {
if (!this._listeners[event]) {
this._listeners[event] = [];
}
this._listeners[event].push(callback);
}
dispatchEvent(event, data) {
if (this._listeners[event]) {
this._listeners[event].forEach(callback => callback(data));
}
}
async init() {
const savedLanguage = localStorage.getItem('rpgCompanionLanguage') || 'en';
this.currentLanguage = savedLanguage;
await this.loadTranslations(this.currentLanguage);
this.applyTranslations(document.body);
const langSelect = document.getElementById('rpg-companion-language-select');
if (langSelect) {
langSelect.value = this.currentLanguage;
}
}
async loadTranslations(lang) {
const fetchUrl = `/scripts/extensions/third-party/rpg-companion-sillytavern/src/i18n/${lang}.json`;
try {
const response = await fetch(fetchUrl);
if (!response.ok) {
console.error(`[RPG-Companion-i18n] Failed to load translation file for ${lang}. Status: ${response.status}`);
if (lang !== 'en') {
return this.loadTranslations('en');
}
return;
}
this.translations = await response.json();
} catch (error) {
console.error('[RPG-Companion-i18n] CRITICAL error loading translation file:', error);
}
}
applyTranslations(rootElement) {
if (!rootElement) {
return;
}
// 1. Translate textContent
const textElements = rootElement.querySelectorAll('[data-i18n-key]');
textElements.forEach(element => {
const key = element.dataset.i18nKey;
const translation = this.getTranslation(key);
if (translation) {
element.textContent = translation;
}
});
// 2. Translate title attribute
const titleElements = rootElement.querySelectorAll('[data-i18n-title]');
titleElements.forEach(element => {
const key = element.dataset.i18nTitle;
const translation = this.getTranslation(key);
if (translation) {
element.setAttribute('title', translation);
}
});
// 3. Translate aria-label attribute
const ariaLabelElements = rootElement.querySelectorAll('[data-i18n-aria-label]');
ariaLabelElements.forEach(element => {
const key = element.dataset.i18nAriaLabel;
const translation = this.getTranslation(key);
if (translation) {
element.setAttribute('aria-label', translation);
}
});
}
getTranslation(key) {
return this.translations[key] || null;
}
async setLanguage(lang) {
this.currentLanguage = lang;
localStorage.setItem('rpgCompanionLanguage', lang);
await this.loadTranslations(lang);
this.applyTranslations(document.body);
this.dispatchEvent('languageChanged');
}
}
export const i18n = new Internationalization();
+1417 -53
View File
File diff suppressed because it is too large Load Diff
+311 -32
View File
@@ -10,33 +10,105 @@
* Extension settings - persisted to SillyTavern settings
*/
export let extensionSettings = {
settingsVersion: 6, // Version number for settings migrations
enabled: true,
autoUpdate: 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
useSeparatePreset: false, // Use 'RPG Companion Trackers' preset for tracker generation instead of main API model
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
enablePlotButtons: true, // Show plot progression buttons above chat input
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',
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,
@@ -45,12 +117,37 @@ export let extensionSettings = {
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: {
@@ -63,53 +160,75 @@ export let extensionSettings = {
// Tracker customization configuration
trackerConfig: {
userStats: {
// Stats display mode: 'percentage' or 'number'
statsDisplayMode: 'percentage',
// Array of custom stats (allows add/remove/rename)
customStats: [
{ id: 'health', name: 'Health', enabled: true },
{ id: 'satiety', name: 'Satiety', enabled: true },
{ id: 'energy', name: 'Energy', enabled: true },
{ id: 'hygiene', name: 'Hygiene', enabled: true },
{ id: 'arousal', name: 'Arousal', enabled: true }
{ id: 'health', name: 'Health', enabled: true, persistInHistory: false, maxValue: 100 },
{ id: 'satiety', name: 'Satiety', enabled: true, persistInHistory: false, maxValue: 100 },
{ id: 'energy', name: 'Energy', enabled: true, persistInHistory: false, maxValue: 100 },
{ id: 'hygiene', name: 'Hygiene', enabled: true, persistInHistory: false, maxValue: 100 },
{ id: 'arousal', name: 'Arousal', enabled: true, persistInHistory: false, maxValue: 100 }
],
// RPG Attributes (customizable D&D-style attributes)
showRPGAttributes: true,
showLevel: true, // Show/hide level in UI and prompts
alwaysSendAttributes: false, // If true, always send attributes; if false, only send with dice rolls
rpgAttributes: [
{ id: 'str', name: 'STR', enabled: true },
{ id: 'dex', name: 'DEX', enabled: true },
{ id: 'con', name: 'CON', enabled: true },
{ id: 'int', name: 'INT', enabled: true },
{ id: 'wis', name: 'WIS', enabled: true },
{ id: 'cha', name: 'CHA', enabled: true }
{ id: 'str', name: 'STR', enabled: true, persistInHistory: false },
{ id: 'dex', name: 'DEX', enabled: true, persistInHistory: false },
{ id: 'con', name: 'CON', enabled: true, persistInHistory: false },
{ id: 'int', name: 'INT', enabled: true, persistInHistory: false },
{ id: 'wis', name: 'WIS', enabled: true, persistInHistory: false },
{ id: 'cha', name: 'CHA', enabled: true, persistInHistory: false }
],
// Status section config
statusSection: {
enabled: true,
showMoodEmoji: true,
customFields: ['Conditions'] // User can edit what to track
customFields: ['Conditions'], // User can edit what to track
persistInHistory: false // Persist status in historical messages
},
// Optional skills field
skillsSection: {
enabled: false,
label: 'Skills' // User-editable
}
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' }, // Format options in UI
weather: { enabled: true },
temperature: { enabled: true, unit: 'C' }, // 'C' or 'F'
time: { enabled: true },
location: { enabled: true },
recentEvents: { enabled: true }
date: { enabled: true, format: 'Weekday, Month, Year', persistInHistory: true }, // Date enabled by default for history
weather: { enabled: true, persistInHistory: true }, // Weather enabled by default for history
temperature: { enabled: true, unit: 'C', persistInHistory: false }, // 'C' or 'F'
time: { enabled: true, persistInHistory: true }, // Time enabled by default for history
location: { enabled: true, persistInHistory: true }, // Location enabled by default for history
recentEvents: { enabled: true, persistInHistory: false }
}
},
presentCharacters: {
// Fixed fields (always shown)
showEmoji: true,
showName: true,
// Relationship fields (shown after name, separated by /)
relationshipFields: ['Lover', 'Friend', 'Ally', 'Enemy', 'Neutral'],
// 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': '⭐',
@@ -119,14 +238,15 @@ export let extensionSettings = {
},
// Custom fields (appearance, demeanor, etc. - shown after relationship, separated by |)
customFields: [
{ id: 'appearance', name: 'Appearance', enabled: true, description: 'Visible physical appearance (clothing, hair, notable features)' },
{ id: 'demeanor', name: 'Demeanor', enabled: true, description: 'Observable demeanor or emotional state' }
{ id: 'appearance', name: 'Appearance', enabled: true, description: 'Visible physical appearance (clothing, hair, notable features)', persistInHistory: false },
{ id: 'demeanor', name: 'Demeanor', enabled: true, description: 'Observable demeanor or emotional state', persistInHistory: false }
],
// Thoughts configuration (separate line)
thoughts: {
enabled: true,
name: 'Thoughts',
description: 'Internal monologue (in first person POV, up to three sentences long)'
description: 'Internal Monologue (in first person from character\'s POV, up to three sentences long)',
persistInHistory: false
},
// Character stats toggle (optional feature)
characterStats: {
@@ -142,6 +262,16 @@ export let extensionSettings = {
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,
@@ -152,14 +282,67 @@ export let extensionSettings = {
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
},
debugMode: false, // Enable debug logging visible in UI (for mobile debugging)
memoryMessagesToProcess: 16 // Number of messages to process per batch in memory recollection
npcAvatars: {}, // Store custom avatar images for NPCs (key: character name, value: base64 data URI)
// 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
}
};
/**
@@ -182,6 +365,43 @@ export let committedTrackerData = {
characterThoughts: null
};
/**
* Session-only storage for LLM-generated avatar prompts
* Maps character names to their generated prompts
* Resets on new chat (not persisted to extensionSettings)
*/
export let sessionAvatarPrompts = {};
export function setSessionAvatarPrompt(characterName, prompt) {
sessionAvatarPrompts[characterName] = prompt;
}
export function getSessionAvatarPrompt(characterName) {
return sessionAvatarPrompts[characterName] || null;
}
export function clearSessionAvatarPrompts() {
sessionAvatarPrompts = {};
}
/**
* Per-chat storage for thought-based Character Expressions portraits.
* Maps normalized character names to the current below-chat portrait URL.
*/
export let thoughtBasedExpressionPortraits = {};
export function setThoughtBasedExpressionPortraits(portraits) {
thoughtBasedExpressionPortraits = portraits && typeof portraits === 'object' ? { ...portraits } : {};
}
export function getThoughtBasedExpressionPortrait(characterName) {
return thoughtBasedExpressionPortraits[characterName] || null;
}
export function clearThoughtBasedExpressionPortraits() {
thoughtBasedExpressionPortraits = {};
}
/**
* Tracks whether the last action was a swipe (for separate mode)
* Used to determine whether to commit lastGeneratedData to committedTrackerData
@@ -198,6 +418,38 @@ export let isGenerating = false;
*/
export let isPlotProgression = false;
/**
* Flag indicating if we're actively expecting a new message from generation
* (as opposed to loading chat history)
*/
export let isAwaitingNewMessage = false;
/**
* Monotonically-increasing counter used to detect stale separate-mode tracker
* generation results. Incremented each time a new automated generation is
* triggered or a message deletion occurs so any in-flight (or pending) call
* from a previous generation can recognise that its result is no longer valid.
*/
let separateGenerationId = 0;
/**
* Returns the current separate generation ID.
* @returns {number}
*/
export function getSeparateGenerationId() {
return separateGenerationId;
}
/**
* Increments and returns the new separate generation ID.
* Call this when starting a new generation or when a deletion
* invalidates any pending/in-flight generation.
* @returns {number} The new ID
*/
export function incrementSeparateGenerationId() {
return ++separateGenerationId;
}
/**
* Temporary storage for pending dice roll (not saved until user clicks "Save Roll")
*/
@@ -244,6 +496,8 @@ export let $infoBoxContainer = null;
export let $thoughtsContainer = null;
export let $inventoryContainer = null;
export let $questsContainer = null;
export let $musicPlayerContainer = null;
export let $equipmentContainer = null;
/**
* State setters - provide controlled mutation of state variables
@@ -265,11 +519,24 @@ export function updateLastGeneratedData(updates) {
}
export function setCommittedTrackerData(data) {
// console.log('[RPG State] setCommittedTrackerData called with:', data);
// console.log('[RPG State] Type check on input:', {
// userStatsType: typeof data.userStats,
// infoBoxType: typeof data.infoBox,
// characterThoughtsType: typeof data.characterThoughts,
// userStatsValue: data.userStats,
// infoBoxValue: data.infoBox,
// characterThoughtsValue: data.characterThoughts
// });
committedTrackerData = data;
// console.log('[RPG State] committedTrackerData after assignment:', committedTrackerData);
}
export function updateCommittedTrackerData(updates) {
// console.log('[RPG State] updateCommittedTrackerData called with:', updates);
// console.log('[RPG State] committedTrackerData before update:', committedTrackerData);
Object.assign(committedTrackerData, updates);
// console.log('[RPG State] committedTrackerData after update:', committedTrackerData);
}
export function setLastActionWasSwipe(value) {
@@ -284,6 +551,10 @@ export function setIsPlotProgression(value) {
isPlotProgression = value;
}
export function setIsAwaitingNewMessage(value) {
isAwaitingNewMessage = value;
}
export function setPendingDiceRoll(roll) {
pendingDiceRoll = roll;
}
@@ -315,3 +586,11 @@ export function setInventoryContainer($element) {
export function setQuestsContainer($element) {
$questsContainer = $element;
}
export function setMusicPlayerContainer($element) {
$musicPlayerContainer = $element;
}
export function setEquipmentContainer($element) {
$equipmentContainer = $element;
}
+513
View File
@@ -0,0 +1,513 @@
{
"settings.language.label": "Language",
"settings.language.option.en": "English",
"settings.language.option.zh-cn": "简体中文",
"settings.language.option.zh-tw": "繁體中文",
"settings.language.option.ru": "Русский",
"settings.language.option.fr": "Français",
"settings.extensionEnabled": "Enable RPG Companion",
"settings.note": "Toggle to enable/disable the RPG Companion extension. Configure additional settings within the panel itself.",
"template.settingsTitle": "RPG Companion Settings",
"template.settingsModal.themeTitle": "Theme",
"template.settingsModal.themeLabel": "Visual Theme:",
"template.settingsModal.themeOptions.default": "Default",
"template.settingsModal.themeOptions.sciFi": "Sci-Fi (Synthwave)",
"template.settingsModal.themeOptions.fantasy": "Fantasy (Rustic Parchment)",
"template.settingsModal.themeOptions.cyberpunk": "Cyberpunk (Neon Grid)",
"template.settingsModal.themeOptions.custom": "Custom",
"template.settingsModal.themeOptions.custom.background": "Background:",
"template.settingsModal.themeOptions.custom.accent": "Accent:",
"template.settingsModal.themeOptions.custom.text": "Text:",
"template.settingsModal.themeOptions.custom.highlight": "Highlight:",
"template.settingsModal.theme.statBarLow": "Stat Bar Color (Low):",
"template.settingsModal.theme.statBarLowNote": "Color when stats are at 0%.",
"template.settingsModal.theme.statBarHigh": "Stat Bar Color (High):",
"template.settingsModal.theme.statBarHighNote": "Color when stats are at 100%.",
"template.settingsModal.displayTitle": "Display Options",
"template.settingsModal.displayNote": "You can enable/disable the entire RPG Companion extension in the Extensions tab of the SillyTavern.",
"template.settingsModal.display.panelPosition": "Panel Position:",
"template.settingsModal.display.panelPositionOptions.right": "Right Sidebar",
"template.settingsModal.display.panelPositionOptions.left": "Left Sidebar",
"template.settingsModal.display.toggleAutoUpdate": "Auto-update after messages",
"template.settingsModal.display.toggleAutoUpdateNote": "Automatically refresh RPG info after each message.",
"template.settingsModal.display.showUserStats": "Show User Stats",
"template.settingsModal.display.showUserStatsNote": "Enable User Stats that track your persona's statistics, mood, attributes, skills, etc.",
"template.settingsModal.display.showInfoBox": "Show Info Box",
"template.settingsModal.display.showInfoBoxNote": "Display location, time, weather, and recent events.",
"template.settingsModal.display.showPresentCharacters": "Show Present Characters",
"template.settingsModal.display.showPresentCharactersNote": "Display character portraits with their current thoughts and status.",
"template.settingsModal.display.showBelowChatPresentCharacters": "Show Below-Chat Present Characters",
"template.settingsModal.display.showBelowChatPresentCharactersNote": "Display a compact Present Characters panel below the chat.",
"template.settingsModal.display.thoughtBasedExpressions": "Thought-Based Expressions",
"template.settingsModal.display.thoughtBasedExpressionsNote": "Use SillyTavern Character Expressions to classify each present character's thoughts for the below-chat panel. May increase token usage depending on the selected Classifier API.",
"template.settingsModal.display.hideDefaultExpressionDisplay": "Hide Default Expression Display",
"template.settingsModal.display.hideDefaultExpressionDisplayNote": "Hide SillyTavern's built-in Character Expressions display.",
"template.settingsModal.display.narratorMode": "Narrator Mode",
"template.settingsModal.display.narratorModeNote": "Use character card as narrator. Infer characters from context instead of using fixed character references.",
"template.settingsModal.display.showInventory": "Show Inventory",
"template.settingsModal.display.showInventoryNote": "Track items carried, clothing worn, stored items, and assets.",
"template.settingsModal.display.showQuests": "Show Quests",
"template.settingsModal.display.showQuestsNote": "Manage main and optional quests with objectives.",
"template.settingsModal.display.showLockIcons": "Show Locking/Unlocking Trackers",
"template.settingsModal.display.showLockIconsNote": "Display lock/unlock icons on tracker items to prevent AI from modifying them.",
"template.settingsModal.display.showThoughtsInChat": "Show Thoughts",
"template.settingsModal.display.showThoughtsInChatNote": "Display character thoughts as overlay bubbles next to their messages.",
"template.settingsModal.display.showInlineThoughts": "Show Thoughts Below Message Text",
"template.settingsModal.display.showInlineThoughtsNote": "Switch between the default corner thought bubbles and thought cards below the message text.",
"template.settingsModal.display.alwaysShowThoughtBubble": "Always Show Thought Bubble",
"template.settingsModal.display.alwaysShowThoughtBubbleNote": "Auto-expand thought bubble without clicking the icon first",
"template.settingsModal.display.enableAnimations": "Enable Animations",
"template.settingsModal.display.enableAnimationsNote": "Smooth transitions for stats, content updates, and dice rolls.",
"template.settingsModal.display.showImmersiveHtmlToggle": "Show Immersive HTML",
"template.settingsModal.display.showImmersiveHtmlToggleNote": "Display a toggle button to enable/disable HTML formatting in messages.",
"template.settingsModal.display.showDialogueColoringToggle": "Show Colored Dialogues",
"template.settingsModal.display.showDialogueColoringToggleNote": "Display a toggle button to enable/disable colored dialogue formatting.",
"template.settingsModal.display.showDeceptionToggle": "Show Deception System",
"template.settingsModal.display.showDeceptionToggleNote": "Display a toggle button to enable/disable the Deception System for marking lies and deceptions.",
"template.settingsModal.display.showOmniscienceToggle": "Show Omniscience Filter",
"template.settingsModal.display.showOmniscienceToggleNote": "Display a toggle button to enable/disable the Omniscience Filter for filtering hidden events.",
"template.settingsModal.display.showSpotifyMusicToggle": "Show Spotify Music",
"template.settingsModal.display.showSpotifyMusicToggleNote": "Display Spotify music player with AI-suggested scene-appropriate tracks.",
"template.settingsModal.display.showSnowflakesToggle": "Show Snowflakes Effect",
"template.settingsModal.display.showDynamicWeatherToggle": "Show Dynamic Weather Effects",
"template.settingsModal.display.showDynamicWeatherToggleNote": "Display a toggle button to enable/disable animated weather effects.",
"template.settingsModal.display.showNarratorMode": "Show Narrator Mode",
"template.settingsModal.display.showNarratorModeNote": "Display a toggle button to enable/disable narrator mode (infer characters from context).",
"template.settingsModal.display.showAutoAvatars": "Show Auto-generate Avatars",
"template.settingsModal.display.showAutoAvatarsNote": "Display a toggle button to automatically generate avatars for characters without images.",
"template.settingsModal.display.showRandomizedPlot": "Show Randomized Plot Progression",
"template.settingsModal.display.showRandomizedPlotNote": "Display button for AI-generated random plot progression prompts.",
"template.settingsModal.display.showNaturalPlot": "Show Natural Plot Progression",
"template.settingsModal.display.showNaturalPlotNote": "Display button for context-aware narrative continuation prompts.",
"template.settingsModal.display.showStartEncounter": "Show Start Encounter",
"template.settingsModal.display.showStartEncounterNote": "Display button to initiate interactive combat encounters.",
"template.settingsModal.display.showDiceDisplay": "Show Dice Roll Display",
"template.settingsModal.display.showDiceDisplayNote": "Display the \"Last Roll\" indicator in the panel.",
"template.settingsModal.display.showCYOAToggle": "Show CYOA",
"template.settingsModal.display.showCYOAToggleNote": "Display a toggle button to enable/disable \"Choose Your Own Adventure\" formatting instruction that makes the model produce five possible actions/dialogues for you to choose from at the end of the output.",
"template.settingsModal.display.weatherPosition.background": "Show in Background",
"template.settingsModal.display.weatherPosition.backgroundNote": "Display weather effects behind the chat (standard behavior).",
"template.settingsModal.display.weatherPosition.foreground": "Show in Foreground",
"template.settingsModal.display.weatherPosition.foregroundNote": "Display weather effects in front of the chat (experimental).",
"template.mainPanel.autoAvatars": "Auto Avatars",
"template.settingsModal.advancedTitle": "Advanced",
"template.settingsModal.advanced.encounterHistoryDepth": "Chat History Depth For Encounters:",
"template.settingsModal.advanced.encounterHistoryDepthNote": "Number of recent messages to include in combat initialization.",
"template.settingsModal.advanced.autoSaveCombatLogs": "Auto-save Combat Logs",
"template.settingsModal.advanced.autoSaveCombatLogsNote": "Save detailed combat logs to file for future reference and analysis.",
"template.settingsModal.advanced.clearCacheNote": "Clears committed and displayed tracker data for your currently active chat.",
"template.settingsModal.advanced.generationMode": "Generation Mode:",
"template.settingsModal.advanced.generationModeOptions.together": "Together with Main Generation",
"template.settingsModal.advanced.generationModeOptions.separate": "Separate Generation",
"template.settingsModal.advanced.generationModeNote": "Together: Adds RPG tracking to main roleplay. Separate: Generates RPG data separately (manual or auto). External: Connects to an OpenAI-compatible endpoint directly.",
"template.settingsModal.advanced.generationModeOptions.external": "External API",
"template.settingsModal.advanced.externalApi.title": "External API Settings",
"template.settingsModal.advanced.externalApi.baseUrl": "API Base URL",
"template.settingsModal.advanced.externalApi.baseUrlNote": "OpenAI-compatible endpoint (e.g., OpenAI, OpenRouter, local LLM server).",
"template.settingsModal.advanced.externalApi.apiKey": "API Key",
"template.settingsModal.advanced.externalApi.apiKeyNote": "Your API key for the external service.",
"template.settingsModal.advanced.externalApi.model": "Model",
"template.settingsModal.advanced.externalApi.modelNote": "Model identifier (e.g., gpt-4o-mini, claude-3-haiku, mistral-7b).",
"template.settingsModal.advanced.externalApi.maxTokens": "Max Tokens",
"template.settingsModal.advanced.externalApi.temperature": "Temperature",
"template.settingsModal.advanced.externalApi.testConnection": "Test Connection",
"template.settingsModal.advanced.contextMessages": "Context Messages:",
"template.settingsModal.advanced.contextMessagesNote": "Number of recent messages to include.",
"template.settingsModal.advanced.useSeparatePreset": "Use model connected to RPG Companion Trackers preset",
"template.settingsModal.advanced.useSeparatePresetNote": "When enabled, tracker generation will use the model from the \"RPG Companion Trackers\" preset instead of your main API model. The preset will be switched automatically during generation and restored afterward. Select the desired model in that preset and make sure the \"Bind presets to API connections\" toggle is on (next to the import/export preset buttons).",
"template.settingsModal.advanced.skipInjections": "Skip Injections During Guided Generations:",
"template.settingsModal.advanced.skipInjectionsOptions.none": "Never skip",
"template.settingsModal.advanced.skipInjectionsOptions.impersonation": "Only on impersonation requests",
"template.settingsModal.advanced.skipInjectionsOptions.guided": "Always for guided or quiet prompts",
"template.settingsModal.advanced.skipInjectionsNote": "When set, the extension will not inject tracker prompts, examples, or HTML instructions according to the selected mode when a guided generation (via `instruct` or `quiet_prompt`) is detected. Useful when using GuidedGenerations or similar extensions.",
"template.settingsModal.advanced.customHtmlPromptTitle": "Custom HTML Prompt:",
"template.settingsModal.advanced.restoreDefaultHtmlPrompt": "Restore Default",
"template.settingsModal.advanced.customHtmlPromptNote": "Customize the HTML prompt injected when \"Enable Immersive HTML\" is enabled. The default prompt is shown above - you can edit it directly or replace it entirely. Click \"Restore Default\" to reset. This affects all generation modes (together, separate, and plot progression).",
"template.settingsModal.advanced.clearCache": "Clear Extension Cache",
"template.settingsModal.advanced.resetFabPositions": "Reset Button Positions",
"template.settingsModal.advanced.resetFabPositionsNote": "Resets all floating action buttons (toggle, refresh, debug) to default top-left positions. Useful if buttons are off-screen.",
"template.trackerEditorModal.title": "Edit Trackers",
"template.trackerEditorModal.tabs.userStats": "User Stats",
"template.trackerEditorModal.tabs.infoBox": "Info Box",
"template.trackerEditorModal.tabs.presentCharacters": "Present Characters",
"template.trackerEditorModal.buttons.reset": "Reset",
"template.trackerEditorModal.buttons.cancel": "Cancel",
"template.trackerEditorModal.buttons.save": "Save & Apply",
"template.trackerEditorModal.buttons.export": "Export",
"template.trackerEditorModal.buttons.import": "Import",
"template.trackerEditorModal.messages.exportSuccess": "Tracker preset exported successfully!",
"template.trackerEditorModal.messages.exportError": "Failed to export tracker preset. Check console for details.",
"template.trackerEditorModal.messages.importSuccess": "Tracker preset imported successfully!",
"template.trackerEditorModal.messages.importError": "Failed to import tracker preset",
"template.trackerEditorModal.messages.importConfirm": "This will replace your current tracker configuration. Continue?",
"template.trackerEditorModal.userStatsTab.customStatsTitle": "Custom Stats",
"template.trackerEditorModal.userStatsTab.addCustomStatButton": "Add Custom Stat",
"template.trackerEditorModal.userStatsTab.rpgAttributesTitle": "RPG Attributes",
"template.trackerEditorModal.userStatsTab.enableRpgAttributes": "Enable RPG Attributes Section",
"template.trackerEditorModal.userStatsTab.alwaysIncludeAttributes": "Always Include Attributes in Prompt",
"template.trackerEditorModal.userStatsTab.alwaysIncludeAttributesNote": "If disabled, attributes are only sent when a dice roll is active.",
"template.trackerEditorModal.userStatsTab.addAttributeButton": "Add Attribute",
"template.trackerEditorModal.userStatsTab.statusSectionTitle": "Status Section",
"template.trackerEditorModal.userStatsTab.enableStatusSection": "Enable Status Section",
"template.trackerEditorModal.userStatsTab.showMoodEmoji": "Show Mood Emoji",
"template.trackerEditorModal.userStatsTab.statusFieldsLabel": "Status Fields (comma-separated):",
"template.trackerEditorModal.userStatsTab.skillsSectionTitle": "Skills Section",
"template.trackerEditorModal.userStatsTab.enableSkillsSection": "Enable Skills Section",
"template.trackerEditorModal.userStatsTab.skillsLabelLabel": "Skills Label:",
"template.trackerEditorModal.userStatsTab.skillsListLabel": "Skills List (comma-separated):",
"template.trackerEditorModal.infoBoxTab.widgetsTitle": "Widgets",
"template.trackerEditorModal.infoBoxTab.dateWidget": "Date",
"template.trackerEditorModal.infoBoxTab.weatherWidget": "Weather",
"template.trackerEditorModal.infoBoxTab.temperatureWidget": "Temperature",
"template.trackerEditorModal.infoBoxTab.timeWidget": "Time",
"template.trackerEditorModal.infoBoxTab.locationWidget": "Location",
"template.trackerEditorModal.infoBoxTab.recentEventsWidget": "Recent Events",
"template.trackerEditorModal.presentCharactersTab.relationshipStatusTitle": "Relationship Status Fields",
"template.trackerEditorModal.presentCharactersTab.enableRelationshipStatus": "Enable Relationship Status Fields",
"template.trackerEditorModal.presentCharactersTab.relationshipStatusHint": "Define relationship types with corresponding emojis shown on character portraits.",
"template.trackerEditorModal.presentCharactersTab.newRelationshipButton": "New Relationship",
"template.trackerEditorModal.presentCharactersTab.appearanceDemeanorTitle": "Appearance/Demeanor Fields",
"template.trackerEditorModal.presentCharactersTab.appearanceDemeanorHint": "Fields shown below character name.",
"template.trackerEditorModal.presentCharactersTab.addCustomFieldButton": "Add Custom Field",
"template.trackerEditorModal.presentCharactersTab.thoughtsConfigTitle": "Thoughts Configuration",
"template.trackerEditorModal.presentCharactersTab.enableCharacterThoughts": "Enable Character Thoughts",
"template.trackerEditorModal.presentCharactersTab.thoughtsLabelLabel": "Thoughts Label:",
"template.trackerEditorModal.presentCharactersTab.aiInstructionLabel": "AI Instruction:",
"template.trackerEditorModal.presentCharactersTab.characterStatsTitle": "Character Stats",
"template.trackerEditorModal.presentCharactersTab.trackCharacterStats": "Track Character Stats",
"template.trackerEditorModal.presentCharactersTab.characterStatsHint": "Create stats to track for each character (displayed as colored numbers).",
"template.trackerEditorModal.presentCharactersTab.addCharacterStatButton": "Add Character Stat",
"template.mainPanel.title": "RPG Companion",
"template.mainPanel.lastRoll": "Last Roll:",
"template.mainPanel.clearLastRoll": "Clear last roll",
"template.mainPanel.immersiveHtml": "Immersive HTML",
"template.mainPanel.coloredDialogues": "Colored Dialogues",
"template.mainPanel.deceptionSystem": "Deception System",
"template.mainPanel.omniscienceFilter": "Omniscience Filter",
"template.mainPanel.cyoa": "CYOA",
"template.mainPanel.spotifyMusic": "Spotify Music",
"template.mainPanel.snowflakesEffect": "Snowflakes Effect",
"template.mainPanel.dynamicWeatherEffects": "Dynamic Weather",
"template.mainPanel.narratorMode": "Narrator Mode",
"template.mainPanel.refreshRpgInfo": "Refresh RPG Info",
"template.mainPanel.updating": "Updating...",
"template.mainPanel.editTrackersButton": "Edit Trackers",
"template.mainPanel.settingsButton": "Settings",
"global.none": "None",
"global.add": "Add",
"global.cancel": "Cancel",
"global.listView": "List view",
"global.gridView": "Grid view",
"global.save": "Save",
"global.status": "Status",
"global.inventory": "Inventory",
"global.quests": "Quests",
"global.info": "Info",
"global.removeItem": "Remove item",
"global.clickToEdit": "Click to edit",
"global.collapseExpandPanel": "Collapse/Expand Panel",
"global.refreshRpgInfo": "Refresh RPG Info",
"global.showHideApiKey": "Show/Hide API Key",
"global.closeDialog": "Close dialog",
"infobox.noData.title": "No data yet",
"infobox.noData.instruction": "Generate a new response in the roleplay or switch to \"Separate Generation\" in Settings to access and click the \"Refresh RPG Info\" button",
"infobox.recentEvents.title": "Recent Events",
"infobox.recentEvents.addEventPlaceholder": "Add event...",
"inventory.section.onPerson": "On Person",
"inventory.section.clothing": "Clothing",
"inventory.section.stored": "Stored",
"inventory.section.assets": "Assets",
"inventory.onPerson.empty": "No items carried",
"inventory.onPerson.title": "Items Currently Carried",
"inventory.onPerson.addItemButton": "Add Item",
"inventory.onPerson.addItemPlaceholder": "Enter item name...",
"inventory.clothing.empty": "Not wearing anything",
"inventory.clothing.title": "Clothing & Armor",
"inventory.clothing.addItemButton": "Add Clothing",
"inventory.clothing.addItemPlaceholder": "Enter clothing item...",
"inventory.stored.title": "Storage Locations",
"inventory.stored.addLocationButton": "Add Location",
"inventory.stored.addLocationPlaceholder": "Enter location name...",
"inventory.stored.saveButton": "Save",
"inventory.stored.empty": "No storage locations yet. Click \"Add Location\" to create one.",
"inventory.stored.noItems": "No items stored here",
"inventory.stored.addItemToLocationPlaceholder": "Enter item name...",
"inventory.stored.addItemButton": "Add Item",
"inventory.stored.confirmRemoveLocationMessage": "Remove \"${location}\"? This will delete all items stored there.",
"inventory.stored.confirmRemoveLocationConfirmButton": "Confirm",
"inventory.assets.empty": "No assets owned",
"inventory.assets.title": "Vehicles, Property & Major Possessions",
"inventory.assets.addAssetModalTitle": "Add Asset",
"inventory.assets.addAssetButton": "Add Asset",
"inventory.assets.addAssetPlaceholder": "Enter asset name...",
"inventory.assets.description": "Assets include vehicles (cars, motorcycles), property (homes, apartments), and major equipment (workshop tools, special items).",
"inventory.onPerson.addItemTitle": "Add new item",
"inventory.clothing.addItemTitle": "Add new clothing item",
"inventory.stored.addLocationTitle": "Add new storage location",
"inventory.stored.addItemToLocationTitle": "Add item to this location",
"inventory.stored.removeLocationTitle": "Remove this storage location",
"inventory.assets.addItemTitle": "Add new asset",
"inventory.assets.removeAssetTitle": "Remove asset",
"quests.section.main": "Main Quest",
"quests.section.optional": "Optional Quests",
"quests.main.title": "Main Quests",
"quests.main.addQuestButton": "Add Quest",
"quests.main.addQuestPlaceholder": "Enter main quest title...",
"quests.main.empty": "No active main quests",
"quests.main.hint": "The main quest represents your primary objective in the story.",
"quests.optional.title": "Optional Quests",
"quests.optional.addQuestButton": "Add Quest",
"quests.optional.addQuestPlaceholder": "Enter optional quest title...",
"quests.optional.empty": "No active optional quests",
"quests.optional.hint": "Optional quests are side objectives that complement your main story.",
"quests.editQuestTitle": "Edit quest",
"quests.removeQuestTitle": "Complete/Remove quest",
"checkpoint.setChapterStart": "Set Chapter Start",
"checkpoint.clearChapterStart": "Clear Chapter Start",
"checkpoint.indicator": "Chapter Start",
"checkpoint.tooltip": "Messages before this point are excluded from context",
"musicPlayer.title": "Scene Music",
"musicPlayer.noMusic": "AI will suggest music when appropriate for the scene",
"errors.parsingError": "RPG Companion Trackers' parsing error! The model returned an incorrect format. If the issue persists, consider changing the model for generations.",
"settings.recommendedModels.title": "Recommended Models",
"settings.recommendedModels.description": "For the extension to work properly, **it is not recommended to use any models below 20B, especially if they're old.** It works best with the SOTA models such as Deepseek, Claude, GPT, or Gemini.",
"thoughts.addCharacter": "Add Character",
"thoughts.locked": "Locked",
"thoughts.unlocked": "Unlocked",
"thoughts.clickToEdit": "Click to edit",
"thoughts.clickToUpload": "Click to upload avatar",
"thoughts.removeCharacter": "Remove character",
"thoughts.empty": "No character data generated yet",
"userStats.level": "LVL",
"userStats.clickToEditLevel": "Click to edit level",
"userStats.statsLocked": "Locked - AI cannot change stats",
"userStats.statsUnlocked": "Unlocked - AI can change stats",
"userStats.clickToEditStatName": "Click to edit stat name",
"userStats.clickToEditStatValue": "Click to edit",
"userStats.moodLocked": "Locked - AI cannot change mood",
"userStats.moodUnlocked": "Unlocked - AI can change mood",
"userStats.clickToEditEmoji": "Click to edit emoji",
"userStats.skillsLocked": "Locked - AI cannot change skills",
"userStats.skillsUnlocked": "Unlocked - AI can change skills",
"userStats.clickToEditSkills": "Click to edit skills",
"userStats.empty": "No statuses generated yet",
"infoBox.clickToEdit": "Click to edit",
"infoBox.locked": "Locked - AI cannot change this",
"infoBox.unlocked": "Unlocked - AI can change this",
"infoBox.weatherFallback": "Weather",
"infoBox.locationFallback": "Location",
"stats.health": "Health",
"stats.satiety": "Satiety",
"stats.energy": "Energy",
"stats.hygiene": "Hygiene",
"stats.arousal": "Arousal",
"stats.str": "STR",
"stats.dex": "DEX",
"stats.con": "CON",
"stats.int": "INT",
"stats.wis": "WIS",
"stats.cha": "CHA",
"stats.displayMode": "Display Mode:",
"stats.displayMode.percentage": "Percentage",
"stats.displayMode.number": "Number",
"dice.title": "Roll Dice",
"dice.numberOfDice": "Number of Dice:",
"dice.diceType": "Dice Type:",
"dice.rolling": "Rolling...",
"dice.result": "Result:",
"dice.saveRoll": "Save Roll",
"preset.createNewPresetTitle": "Create New Preset",
"preset.deleteCurrentPresetTitle": "Delete Current Preset",
"preset.setDefaultPresetTitle": "Set as Default Preset",
"preset.defaultPresetDescription": "This is the default preset",
"preset.label": "Preset:",
"preset.useThisPresetFor": "Use this preset for: ",
"stats.showLevel": "Show Level",
"dateFormat.weekdayMonthYear": "Weekday, Month, Year",
"dateFormat.dayNumericalMonthYear": "Day (Numerical), Month, Year",
"historyPersistence.tabTitle": "History Persistence",
"historyPersistence.settingsTitle": "History Persistence Settings",
"historyPersistence.enable": "Enable History Persistence",
"template.trackerEditorModal.tabs.historyPersistence": "History Persistence",
"historyPersistence.hint": "Inject selected tracker data into historical messages to help the AI maintain continuity for time-sensitive events, weather changes, and location tracking.",
"historyPersistence.sendAllEnabledStats": "Send All Enabled Stats on Refresh",
"historyPersistence.sendAllEnabledStatsHint": "When enabled, Refresh RPG Info will include all enabled stats from the preset in history context, ignoring the individual selections below.",
"historyPersistence.numberOfMessages": "Number of messages to include (0 = all available):",
"historyPersistence.injectionPosition": "Injection Position:",
"historyPersistence.injectionPosition.userMessageEnd": "End of the User's Message",
"historyPersistence.injectionPosition.assistantMessageEnd": "End of the Assistant's Message",
"historyPersistence.customContextPreamble": "Custom Context Preamble:",
"historyPersistence.customContextPreamblePlaceholder": "Context for that moment:",
"historyPersistence.userStatsSection": "User Stats",
"historyPersistence.userStatsHint": "Select which stats should be included in historical messages.",
"historyPersistence.statusSection": "Status (Mood/Conditions)",
"historyPersistence.inventory": "Inventory",
"historyPersistence.quests": "Quests",
"historyPersistence.infoBoxSection": "Info Box",
"historyPersistence.infoBoxHint": "Select which info box fields should be included in historical messages. These are recommended for time tracking.",
"historyPersistence.presentCharactersSection": "Present Characters",
"historyPersistence.presentCharactersHint": "Select which character fields should be included in historical messages.",
"historyPersistence.widget.date": "Date",
"historyPersistence.widget.weather": "Weather",
"historyPersistence.widget.temperature": "Temperature",
"historyPersistence.widget.time": "Time",
"historyPersistence.widget.location": "Location",
"historyPersistence.widget.recentEvents": "Recent Events",
"historyPersistence.thoughts": "Thoughts",
"historyPersistence.skills": "Skills",
"template.promptsEditor.button": "Customize Prompts",
"template.promptsEditor.buttonNote": "Edit all AI prompts used for generation, plot progression, and combat encounters.",
"template.promptsEditor.title": "Customize Prompts",
"template.promptsEditor.description": "Customize the AI prompts used throughout the extension. Leave fields empty to use defaults.",
"template.promptsEditor.restoreDefault": "Restore Default",
"template.promptsEditor.htmlPrompt.title": "HTML Prompt",
"template.promptsEditor.htmlPrompt.note": "Injected when \"Enable Immersive HTML\" is enabled. Affects all generation modes.",
"template.promptsEditor.dialogueColoringPrompt.title": "Dialogue Coloring Prompt",
"template.promptsEditor.dialogueColoringPrompt.note": "Injected when \"Enable Colored Dialogues\" is enabled. Affects all generation modes.",
"template.promptsEditor.deceptionPrompt.title": "Deception System Prompt",
"template.promptsEditor.deceptionPrompt.note": "Injected when \"Enable Deception System\" is enabled. Instructs AI to mark lies and deceptions with hidden tags.",
"template.promptsEditor.omnisciencePrompt.title": "Omniscience Filter Prompt",
"template.promptsEditor.omnisciencePrompt.note": "Injected when \"Enable Omniscience Filter\" is enabled. Instructs AI to separate information the player character cannot perceive into hidden <ofilter> tags.",
"template.promptsEditor.cyoaPrompt.title": "CYOA Prompt",
"template.promptsEditor.cyoaPrompt.note": "Injected when \"Enable CYOA\" is enabled. Instructs AI to end responses with numbered action choices. Uses very high priority (depth 102) to ensure it's the last instruction.",
"template.promptsEditor.spotifyPrompt.title": "Spotify Music Prompt",
"template.promptsEditor.spotifyPrompt.note": "Injected when \"Enable Spotify Music\" is enabled. Asks AI to suggest appropriate music for the scene.",
"template.promptsEditor.narratorPrompt.title": "Narrator Mode Prompt",
"template.promptsEditor.narratorPrompt.note": "Injected when \"Narrator Mode\" is enabled. Instructs AI to infer characters from context.",
"template.promptsEditor.contextPrompt.title": "Context Instructions Prompt",
"template.promptsEditor.contextPrompt.note": "Injected in Separate/External mode after the context summary. Tells the AI how to use the context.",
"template.promptsEditor.randomPlotPrompt.title": "Random Plot Progression Prompt",
"template.promptsEditor.randomPlotPrompt.note": "Injected when the \"Randomized Plot\" button is clicked. Introduces random elements to the story.",
"template.promptsEditor.naturalPlotPrompt.title": "Natural Plot Progression Prompt",
"template.promptsEditor.naturalPlotPrompt.note": "Injected when the \"Natural Plot\" button is clicked. Progresses the story naturally.",
"template.promptsEditor.avatarPrompt.title": "Avatar Generation Instruction",
"template.promptsEditor.avatarPrompt.note": "Instructions for LLM when generating avatar image prompts. Used by Auto-generate Missing Avatars feature.",
"template.promptsEditor.trackerPrompt.title": "Tracker Instructions",
"template.promptsEditor.trackerPrompt.note": "Instruction portion only (format specification is hardcoded). {userName} will be replaced with the user's name.",
"template.promptsEditor.trackerContinuationPrompt.title": "Tracker Continuation Instruction",
"template.promptsEditor.trackerContinuationPrompt.note": "Instructions added after tracker format specifications, telling the AI how to continue the narrative.",
"template.promptsEditor.combatPrompt.title": "Combat Narrative Style Instruction",
"template.promptsEditor.combatPrompt.note": "Writing style instructions for combat encounters. Includes prose quality guidelines and anti-repetition rules. {userName} will be replaced with the user's name.",
"template.settingsModal.mobileFabTitle": "Mobile Button Widgets",
"template.settingsModal.mobileFabNote": "Show compact info widgets around the floating button on mobile. Widgets are positioned automatically.",
"template.settingsModal.mobileFab.enabled": "Enable Floating Mobile Widgets",
"template.settingsModal.mobileFab.enabledNote": "Master toggle to show info widgets around the mobile floating button.",
"template.settingsModal.mobileFab.weatherIcon": "Weather Icon",
"template.settingsModal.mobileFab.weatherDesc": "Weather Description",
"template.settingsModal.mobileFab.clock": "Time/Clock",
"template.settingsModal.mobileFab.date": "Date",
"template.settingsModal.mobileFab.location": "Location",
"template.settingsModal.mobileFab.stats": "Stats (Health, Energy, etc.)",
"template.settingsModal.mobileFab.attributes": "RPG Attributes (STR, DEX, etc.)",
"template.settingsModal.desktopStripTitle": "Desktop Collapsed Strip Widgets",
"template.settingsModal.desktopStripNote": "Show compact info widgets in the collapsed panel strip on desktop. Displays stats vertically without needing to expand the panel.",
"template.settingsModal.desktopStrip.enabled": "Enable Strip Widgets",
"template.settingsModal.desktopStrip.enabledNote": "Shows widgets in the collapsed panel strip for quick access to stats.",
"template.settingsModal.desktopStrip.weatherIcon": "Weather Icon",
"template.settingsModal.desktopStrip.clock": "Time/Clock",
"template.settingsModal.desktopStrip.date": "Date",
"template.settingsModal.desktopStrip.location": "Location",
"template.settingsModal.desktopStrip.stats": "Stats (Health, Energy, etc.)",
"template.settingsModal.desktopStrip.attributes": "RPG Attributes (STR, DEX, etc.)",
"plotProgression.buttons.randomizedPlot": "Randomized Plot",
"plotProgression.buttons.naturalPlot": "Natural Plot",
"plotProgression.buttons.enterEncounter": "Enter Encounter",
"plotProgression.tooltips.randomizedPlot": "Generate a random plot twist or event",
"plotProgression.tooltips.naturalPlot": "Continue the story naturally without twists",
"plotProgression.tooltips.enterEncounter": "Enter combat encounter",
"encounter.configModal.title": "Configure Combat Narrative",
"encounter.configModal.combatNarrativeStyle": "Combat Narrative Style",
"encounter.configModal.combatSummaryStyle": "Combat Summary Style",
"encounter.configModal.labels.tense": "Tense:",
"encounter.configModal.labels.person": "Person:",
"encounter.configModal.labels.narration": "Narration:",
"encounter.configModal.labels.pointOfView": "Point of View:",
"encounter.configModal.options.present": "Present",
"encounter.configModal.options.past": "Past",
"encounter.configModal.options.firstPerson": "First Person",
"encounter.configModal.options.secondPerson": "Second Person",
"encounter.configModal.options.thirdPerson": "Third Person",
"encounter.configModal.options.omniscient": "Omniscient",
"encounter.configModal.options.limited": "Limited",
"encounter.configModal.placeholders.narrator": "narrator",
"encounter.configModal.rememberSettings": "Remember these settings for future encounters",
"encounter.configModal.buttons.proceed": "Proceed",
"encounter.ui.concludeEncounterTitle": "Conclude encounter early",
"encounter.ui.closeTitle": "Close (ends combat)",
"encounter.ui.initializingCombat": "Initializing combat...",
"encounter.ui.combatBegins": "Combat begins!",
"encounter.ui.allEnemies": "All Enemies",
"encounter.ui.areaOfEffect": "Area of Effect",
"encounter.ui.youHaveBeenDefeated": "You have been defeated...",
"encounter.ui.attacks": "Attacks",
"encounter.ui.items": "Items",
"encounter.ui.customAction": "Custom Action",
"encounter.ui.customActionPlaceholder": "Describe what you want to do...",
"encounter.ui.generatingCombatSummary": "Generating combat summary...",
"encounter.ui.pleaseWait": "Please wait...",
"encounter.ui.failedToCreateSummary": "Failed to create summary. You can close this window.",
"encounter.ui.wrongFormatDetected": "Wrong Format Detected",
"encounter.ui.concludeEncounterButton": "Conclude Encounter",
"encounter.ui.combatEncounterTitle": "Combat Encounter",
"encounter.ui.errorGeneratingCombatSummary": "Error generating combat summary.",
"encounter.ui.closeCombatWindow": "Close Combat Window",
"encounter.ui.combatLog": "Combat Log",
"encounter.ui.selectTarget": "Select Target",
"encounter.ui.submit": "Submit",
"encounter.ui.regenerate": "Regenerate",
"encounter.ui.or": "OR",
"global.locked": "Locked",
"global.unlocked": "Unlocked",
"global.confirm": "Confirm",
"global.equipment": "Equipment",
"equipment.title": "Equipment",
"equipment.createItem": "Create Equipment",
"equipment.createItemTitle": "Create Equipment",
"equipment.editItemTitle": "Edit Equipment",
"equipment.name": "Name",
"equipment.namePlaceholder": "Enter equipment name...",
"equipment.type": "Type",
"equipment.stats": "Stats",
"equipment.description": "Description",
"equipment.descriptionPlaceholder": "Enter description (optional)...",
"equipment.emptySlot": "Empty",
"equipment.unequip": "Unequip",
"equipment.equip": "Equip",
"equipment.editItem": "Edit item",
"equipment.deleteItem": "Delete item",
"equipment.inventoryTitle": "Inventory",
"equipment.slots.helmet": "Helmet",
"equipment.slots.necklace": "Necklace",
"equipment.slots.bodyArmor": "Body Armor",
"equipment.slots.gloves": "Gloves",
"equipment.slots.pants": "Pants",
"equipment.slots.shoes": "Shoes",
"equipment.slots.ring1": "Ring 1",
"equipment.slots.ring2": "Ring 2",
"equipment.slots.ring3": "Ring 3",
"equipment.slots.ring4": "Ring 4",
"equipment.slots.ring5": "Ring 5",
"equipment.slots.ring6": "Ring 6",
"equipment.slots.ring7": "Ring 7",
"equipment.slots.ring8": "Ring 8",
"equipment.slots.ring9": "Ring 9",
"equipment.slots.ring10": "Ring 10",
"equipment.slots.accessory1": "Accessory 1",
"equipment.slots.accessory2": "Accessory 2",
"equipment.slots.accessory3": "Accessory 3",
"equipment.types.helmet": "Helmet",
"equipment.types.necklace": "Necklace",
"equipment.types.bodyArmor": "Body Armor",
"equipment.types.gloves": "Gloves",
"equipment.types.pants": "Pants",
"equipment.types.shoes": "Shoes",
"equipment.types.ring": "Ring",
"equipment.types.accessory": "Accessory",
"template.settingsModal.display.showEquipment": "Show Equipment",
"template.settingsModal.display.showEquipmentNote": "Manage equipped gear and stat bonuses from items.",
"inventory.addItemPlaceholder": "Enter item name...",
"inventory.stored.removeLocationConfirm": "Remove \"{location}\"? This will delete all items stored there.",
"userStats.clickToEdit": "Click to edit",
"quests.main.addQuestTitle": "Add main quests",
"quests.optional.addQuestTitle": "Add optional quest"
}
+286
View File
@@ -0,0 +1,286 @@
{
"settings.language.label": "Langue",
"settings.language.option.en": "English",
"settings.language.option.zh-cn": "简体中文",
"settings.language.option.zh-tw": "繁體中文",
"settings.language.option.ru": "Русский",
"settings.language.option.fr": "Français",
"settings.extensionEnabled": "Activer RPG Companion",
"settings.note": "Basculez pour activer/désactiver l'extension RPG Companion. Configurez des paramètres supplémentaires dans le panneau lui-même.",
"template.settingsTitle": "Paramètres RPG Companion",
"template.settingsModal.themeTitle": "Thème",
"template.settingsModal.themeLabel": "Thème Visuel :",
"template.settingsModal.themeOptions.default": "Défaut",
"template.settingsModal.themeOptions.sciFi": "Sci-Fi (Synthwave)",
"template.settingsModal.themeOptions.fantasy": "Fantasy (Parchemin Rustique)",
"template.settingsModal.themeOptions.cyberpunk": "Cyberpunk (Grille Néon)",
"template.settingsModal.themeOptions.custom": "Personnalisé",
"template.settingsModal.themeOptions.custom.background": "Arrière-plan :",
"template.settingsModal.themeOptions.custom.accent": "Accent :",
"template.settingsModal.themeOptions.custom.text": "Texte :",
"template.settingsModal.themeOptions.custom.highlight": "Surlignage :",
"template.settingsModal.theme.statBarLow": "Couleur Barre Stat (Bas) :",
"template.settingsModal.theme.statBarLowNote": "Couleur lorsque les stats sont à 0%.",
"template.settingsModal.theme.statBarHigh": "Couleur Barre Stat (Haut) :",
"template.settingsModal.theme.statBarHighNote": "Couleur lorsque les stats sont à 100%.",
"template.settingsModal.displayTitle": "Options d'Affichage",
"template.settingsModal.displayNote": "Vous pouvez activer/désactiver l'extension complète RPG Companion dans l'onglet Extensions de SillyTavern.",
"template.settingsModal.display.panelPosition": "Position du Panneau :",
"template.settingsModal.display.panelPositionOptions.right": "Barre Latérale Droite",
"template.settingsModal.display.panelPositionOptions.left": "Barre Latérale Gauche",
"template.settingsModal.display.toggleAutoUpdate": "Mise à jour auto après messages",
"template.settingsModal.display.toggleAutoUpdateNote": "Rafraîchir automatiquement les infos RPG après chaque message.",
"template.settingsModal.display.showUserStats": "Afficher Stats Utilisateur",
"template.settingsModal.display.showUserStatsNote": "Activer les Stats Utilisateur pour suivre les statistiques, l'humeur, les attributs, les compétences, etc. de votre persona.",
"template.settingsModal.display.showInfoBox": "Afficher Boîte Info",
"template.settingsModal.display.showInfoBoxNote": "Afficher le lieu, l'heure, la météo et les événements récents.",
"template.settingsModal.display.showPresentCharacters": "Afficher Personnages Présents",
"template.settingsModal.display.showPresentCharactersNote": "Afficher les portraits des personnages avec leurs pensées actuelles et leur statut.",
"template.settingsModal.display.showBelowChatPresentCharacters": "Afficher les personnages sous le chat",
"template.settingsModal.display.showBelowChatPresentCharactersNote": "Afficher un panneau compact des personnages présents sous le chat.",
"template.settingsModal.display.thoughtBasedExpressions": "Expressions basées sur les pensées",
"template.settingsModal.display.thoughtBasedExpressionsNote": "Utiliser Character Expressions de SillyTavern pour classifier les pensées de chaque personnage présent dans le panneau sous le chat. L'utilisation de tokens peut augmenter selon l'API de classification sélectionnée.",
"template.settingsModal.display.hideDefaultExpressionDisplay": "Masquer l'affichage d'expressions par défaut",
"template.settingsModal.display.hideDefaultExpressionDisplayNote": "Masquer l'affichage intégré des expressions de personnage de SillyTavern.",
"template.settingsModal.display.narratorMode": "Mode Narrateur",
"template.settingsModal.display.narratorModeNote": "Utiliser la carte de personnage comme narrateur. Déduire les personnages du contexte au lieu d'utiliser des références de personnages fixes.",
"template.settingsModal.display.showInventory": "Afficher Inventaire",
"template.settingsModal.display.showInventoryNote": "Suivre les objets transportés, les vêtements portés, les objets stockés et les biens.",
"template.settingsModal.display.showQuests": "Afficher Quêtes",
"template.settingsModal.display.showQuestsNote": "Gérer les quêtes principales et optionnelles avec des objectifs.",
"template.settingsModal.display.showLockIcons": "Afficher Icônes Verrouillage",
"template.settingsModal.display.showLockIconsNote": "Afficher les icônes de verrouillage/déverrouillage sur les éléments de suivi pour empêcher l'IA de les modifier.",
"template.settingsModal.display.showThoughtsInChat": "Afficher Pensées",
"template.settingsModal.display.showThoughtsInChatNote": "Afficher les pensées des personnages sous forme de bulles superposées à côté de leurs messages.",
"template.settingsModal.display.showInlineThoughts": "Afficher les pensées sous le texte du message",
"template.settingsModal.display.showInlineThoughtsNote": "Basculer entre les bulles de pensée dans le coin par défaut et des cartes de pensée sous le texte du message.",
"template.settingsModal.display.alwaysShowThoughtBubble": "Toujours Afficher Bulle Pensée",
"template.settingsModal.display.alwaysShowThoughtBubbleNote": "Développer automatiquement la bulle de pensée sans cliquer sur l'icône d'abord",
"template.settingsModal.display.enableAnimations": "Activer Animations",
"template.settingsModal.display.enableAnimationsNote": "Transitions fluides pour les stats, les mises à jour de contenu et les lancers de dés.",
"template.settingsModal.display.showImmersiveHtmlToggle": "Afficher HTML Immersif",
"template.settingsModal.display.showImmersiveHtmlToggleNote": "Afficher un bouton pour activer/désactiver le formatage HTML dans les messages.",
"template.settingsModal.display.showDialogueColoringToggle": "Afficher Dialogues Colorés",
"template.settingsModal.display.showDialogueColoringToggleNote": "Afficher un bouton pour activer/désactiver le formatage des dialogues colorés.",
"template.settingsModal.display.showDeceptionToggle": "Afficher Système Déception",
"template.settingsModal.display.showDeceptionToggleNote": "Afficher un bouton pour activer/désactiver le Système de Déception pour marquer les mensonges.",
"template.settingsModal.display.showOmniscienceToggle": "Afficher Filtre Omniscience",
"template.settingsModal.display.showOmniscienceToggleNote": "Afficher un bouton pour activer/désactiver le Filtre d'Omniscience pour filtrer les événements cachés.",
"template.settingsModal.display.showSpotifyMusicToggle": "Afficher Musique Spotify",
"template.settingsModal.display.showSpotifyMusicToggleNote": "Afficher le lecteur Spotify avec des pistes suggérées par l'IA appropriées à la scène.",
"template.settingsModal.display.showSnowflakesToggle": "Afficher Effet Flocons",
"template.settingsModal.display.showDynamicWeatherToggle": "Afficher Effets Météo Dynamiques",
"template.settingsModal.display.showDynamicWeatherToggleNote": "Afficher un bouton pour activer/désactiver les effets météo animés.",
"template.settingsModal.display.showNarratorMode": "Afficher Mode Narrateur",
"template.settingsModal.display.showNarratorModeNote": "Afficher un bouton pour activer/désactiver le mode narrateur (déduire les personnages du contexte).",
"template.settingsModal.display.showAutoAvatars": "Afficher Génération Auto Avatars",
"template.settingsModal.display.showAutoAvatarsNote": "Afficher un bouton pour générer automatiquement des avatars pour les personnages sans image.",
"template.settingsModal.display.showRandomizedPlot": "Afficher Progression Intrigue Aléatoire",
"template.settingsModal.display.showRandomizedPlotNote": "Afficher un bouton pour des invites de progression d'intrigue générées aléatoirement par l'IA.",
"template.settingsModal.display.showNaturalPlot": "Afficher Progression Intrigue Naturelle",
"template.settingsModal.display.showNaturalPlotNote": "Afficher un bouton pour des invites de continuation narrative conscientes du contexte.",
"template.settingsModal.display.showStartEncounter": "Afficher Démarrer Rencontre",
"template.settingsModal.display.showStartEncounterNote": "Afficher un bouton pour initier des rencontres de combat interactives.",
"template.settingsModal.display.showDiceDisplay": "Afficher Lancer de Dés",
"template.settingsModal.display.showDiceDisplayNote": "Afficher l'indicateur \"Dernier Lancer\" dans le panneau.",
"template.mainPanel.autoAvatars": "Avatars Auto",
"template.settingsModal.advancedTitle": "Avancé",
"template.settingsModal.advanced.encounterHistoryDepth": "Profondeur Historique Rencontre :",
"template.settingsModal.advanced.encounterHistoryDepthNote": "Nombre de messages récents à inclure dans l'initialisation du combat.",
"template.settingsModal.advanced.autoSaveCombatLogs": "Sauvegarde Auto Journaux Combat",
"template.settingsModal.advanced.autoSaveCombatLogsNote": "Sauvegarder les journaux de combat détaillés dans un fichier pour référence future et analyse.",
"template.settingsModal.advanced.clearCacheNote": "Efface les données de suivi validées et affichées pour votre chat actuellement actif.",
"template.settingsModal.advanced.generationMode": "Mode de Génération :",
"template.settingsModal.advanced.generationModeOptions.together": "Ensemble avec Génération Principale",
"template.settingsModal.advanced.generationModeOptions.separate": "Génération Séparée",
"template.settingsModal.advanced.generationModeNote": "Ensemble : Ajoute le suivi RPG au jeu de rôle principal. Séparé : Génère les données RPG séparément (manuel ou auto). Externe : Se connecte directement à un point de terminaison compatible OpenAI.",
"template.settingsModal.advanced.generationModeOptions.external": "API Externe",
"template.settingsModal.advanced.externalApi.title": "Paramètres API Externe",
"template.settingsModal.advanced.externalApi.baseUrl": "URL de base API",
"template.settingsModal.advanced.externalApi.baseUrlNote": "Point de terminaison compatible OpenAI (ex: OpenAI, OpenRouter, serveur LLM local).",
"template.settingsModal.advanced.externalApi.apiKey": "Clé API",
"template.settingsModal.advanced.externalApi.apiKeyNote": "Votre clé API pour le service externe.",
"template.settingsModal.advanced.externalApi.model": "Modèle",
"template.settingsModal.advanced.externalApi.modelNote": "Identifiant du modèle (ex: gpt-4o-mini, claude-3-haiku, mistral-7b).",
"template.settingsModal.advanced.externalApi.maxTokens": "Max Tokens",
"template.settingsModal.advanced.externalApi.temperature": "Température",
"template.settingsModal.advanced.externalApi.testConnection": "Tester Connexion",
"template.settingsModal.advanced.contextMessages": "Messages de Contexte :",
"template.settingsModal.advanced.contextMessagesNote": "Nombre de messages récents à inclure.",
"template.settingsModal.advanced.useSeparatePreset": "Utiliser modèle connecté au preset RPG Companion Trackers",
"template.settingsModal.advanced.useSeparatePresetNote": "Si activé, la génération de suivi utilisera le modèle du preset \"RPG Companion Trackers\" au lieu de votre modèle API principal. Le preset sera commuté automatiquement pendant la génération et restauré après. Sélectionnez le modèle souhaité dans ce preset et assurez-vous que l'option \"Lier les presets aux connexions API\" est activée (à côté des boutons import/export preset).",
"template.settingsModal.advanced.skipInjections": "Sauter Injections Pendant Générations Guidées :",
"template.settingsModal.advanced.skipInjectionsOptions.none": "Ne jamais sauter",
"template.settingsModal.advanced.skipInjectionsOptions.impersonation": "Seulement sur demandes d'imitation",
"template.settingsModal.advanced.skipInjectionsOptions.guided": "Toujours pour invites guidées ou silencieuses",
"template.settingsModal.advanced.skipInjectionsNote": "Si défini, l'extension n'injectera pas les invites de suivi, exemples ou instructions HTML selon le mode sélectionné lorsqu'une génération guidée (via `instruct` ou `quiet_prompt`) est détectée. Utile lors de l'utilisation de GuidedGenerations ou extensions similaires.",
"template.settingsModal.advanced.customHtmlPromptTitle": "Invite HTML Personnalisée :",
"template.settingsModal.advanced.restoreDefaultHtmlPrompt": "Restaurer Défaut",
"template.settingsModal.advanced.customHtmlPromptNote": "Personnalisez l'invite HTML injectée lorsque \"Activer HTML Immersif\" est activé. L'invite par défaut est affichée ci-dessus - vous pouvez l'éditer directement ou la remplacer entièrement. Cliquez sur \"Restaurer Défaut\" pour réinitialiser. Cela affecte tous les modes de génération (ensemble, séparé et progression d'intrigue).",
"template.settingsModal.advanced.clearCache": "Effacer Cache Extension",
"template.settingsModal.advanced.resetFabPositions": "Réinitialiser Positions Boutons",
"template.settingsModal.advanced.resetFabPositionsNote": "Réinitialise tous les boutons d'action flottants (bascule, rafraîchir, debug) à leurs positions par défaut en haut à gauche. Utile si les boutons sont hors écran.",
"template.trackerEditorModal.title": "Éditer Suivis",
"template.trackerEditorModal.tabs.userStats": "Stats Utilisateur",
"template.trackerEditorModal.tabs.infoBox": "Boîte Info",
"template.trackerEditorModal.tabs.presentCharacters": "Personnages Présents",
"template.trackerEditorModal.buttons.reset": "Réinitialiser",
"template.trackerEditorModal.buttons.cancel": "Annuler",
"template.trackerEditorModal.buttons.save": "Sauvegarder & Appliquer",
"template.trackerEditorModal.buttons.export": "Exporter",
"template.trackerEditorModal.buttons.import": "Importer",
"template.trackerEditorModal.messages.exportSuccess": "Preset de suivi exporté avec succès !",
"template.trackerEditorModal.messages.exportError": "Échec de l'exportation du preset. Vérifiez la console pour les détails.",
"template.trackerEditorModal.messages.importSuccess": "Preset de suivi importé avec succès !",
"template.trackerEditorModal.messages.importError": "Échec de l'importation du preset",
"template.trackerEditorModal.messages.importConfirm": "Ceci remplacera votre configuration actuelle de suivi. Continuer ?",
"template.trackerEditorModal.userStatsTab.customStatsTitle": "Stats Personnalisées",
"template.trackerEditorModal.userStatsTab.addCustomStatButton": "Ajouter Stat Perso",
"template.trackerEditorModal.userStatsTab.rpgAttributesTitle": "Attributs RPG",
"template.trackerEditorModal.userStatsTab.enableRpgAttributes": "Activer Section Attributs RPG",
"template.trackerEditorModal.userStatsTab.alwaysIncludeAttributes": "Toujours Inclure Attributs dans Invite",
"template.trackerEditorModal.userStatsTab.alwaysIncludeAttributesNote": "Si désactivé, les attributs ne sont envoyés que lorsqu'un lancer de dé est actif.",
"template.trackerEditorModal.userStatsTab.addAttributeButton": "Ajouter Attribut",
"template.trackerEditorModal.userStatsTab.statusSectionTitle": "Section Statut",
"template.trackerEditorModal.userStatsTab.enableStatusSection": "Activer Section Statut",
"template.trackerEditorModal.userStatsTab.showMoodEmoji": "Afficher Emoji Humeur",
"template.trackerEditorModal.userStatsTab.statusFieldsLabel": "Champs Statut (séparés par virgule) :",
"template.trackerEditorModal.userStatsTab.skillsSectionTitle": "Section Compétences",
"template.trackerEditorModal.userStatsTab.enableSkillsSection": "Activer Section Compétences",
"template.trackerEditorModal.userStatsTab.skillsLabelLabel": "Libellé Compétences :",
"template.trackerEditorModal.userStatsTab.skillsListLabel": "Liste Compétences (séparées par virgule) :",
"template.trackerEditorModal.infoBoxTab.widgetsTitle": "Widgets",
"template.trackerEditorModal.infoBoxTab.dateWidget": "Date",
"template.trackerEditorModal.infoBoxTab.weatherWidget": "Météo",
"template.trackerEditorModal.infoBoxTab.temperatureWidget": "Température",
"template.trackerEditorModal.infoBoxTab.timeWidget": "Heure",
"template.trackerEditorModal.infoBoxTab.locationWidget": "Lieu",
"template.trackerEditorModal.infoBoxTab.recentEventsWidget": "Événements Récents",
"template.trackerEditorModal.presentCharactersTab.relationshipStatusTitle": "Champs Statut Relation",
"template.trackerEditorModal.presentCharactersTab.enableRelationshipStatus": "Activer Champs Statut Relation",
"template.trackerEditorModal.presentCharactersTab.relationshipStatusHint": "Définir les types de relation avec les emojis correspondants affichés sur les portraits des personnages.",
"template.trackerEditorModal.presentCharactersTab.newRelationshipButton": "Nouvelle Relation",
"template.trackerEditorModal.presentCharactersTab.appearanceDemeanorTitle": "Champs Apparence/Comportement",
"template.trackerEditorModal.presentCharactersTab.appearanceDemeanorHint": "Champs affichés sous le nom du personnage.",
"template.trackerEditorModal.presentCharactersTab.addCustomFieldButton": "Ajouter Champ Perso",
"template.trackerEditorModal.presentCharactersTab.thoughtsConfigTitle": "Configuration Pensées",
"template.trackerEditorModal.presentCharactersTab.enableCharacterThoughts": "Activer Pensées Personnage",
"template.trackerEditorModal.presentCharactersTab.thoughtsLabelLabel": "Libellé Pensées :",
"template.trackerEditorModal.presentCharactersTab.aiInstructionLabel": "Instruction IA :",
"template.trackerEditorModal.presentCharactersTab.characterStatsTitle": "Stats Personnage",
"template.trackerEditorModal.presentCharactersTab.trackCharacterStats": "Suivre Stats Personnage",
"template.trackerEditorModal.presentCharactersTab.characterStatsHint": "Créer des stats à suivre pour chaque personnage (affichées comme nombres colorés).",
"template.trackerEditorModal.presentCharactersTab.addCharacterStatButton": "Ajouter Stat Personnage",
"template.mainPanel.title": "RPG Companion",
"template.mainPanel.lastRoll": "Dernier Lancer :",
"template.mainPanel.clearLastRoll": "Effacer dernier lancer",
"template.mainPanel.immersiveHtml": "HTML Immersif",
"template.mainPanel.coloredDialogues": "Dialogues Colorés",
"template.mainPanel.deceptionSystem": "Système Déception",
"template.mainPanel.omniscienceFilter": "Filtre Omniscience",
"template.mainPanel.spotifyMusic": "Musique Spotify",
"template.mainPanel.snowflakesEffect": "Effet Flocons",
"template.mainPanel.dynamicWeatherEffects": "Météo Dynamique",
"template.mainPanel.narratorMode": "Mode Narrateur",
"template.mainPanel.refreshRpgInfo": "Rafraîchir Infos RPG",
"template.mainPanel.updating": "Mise à jour...",
"template.mainPanel.editTrackersButton": "Éditer Suivis",
"template.mainPanel.settingsButton": "Paramètres",
"global.none": "Aucun",
"global.add": "Ajouter",
"global.cancel": "Annuler",
"global.listView": "Vue liste",
"global.gridView": "Vue grille",
"global.save": "Sauvegarder",
"global.status": "Statut",
"global.inventory": "Inventaire",
"global.quests": "Quêtes",
"global.info": "Info",
"infobox.noData.title": "Pas de données",
"infobox.noData.instruction": "Générez une nouvelle réponse dans le jeu de rôle ou basculez vers \"Génération Séparée\" dans les Paramètres pour accéder et cliquer sur le bouton \"Rafraîchir Infos RPG\"",
"infobox.recentEvents.title": "Événements Récents",
"infobox.recentEvents.addEventPlaceholder": "Ajouter événement...",
"inventory.section.onPerson": "Sur Soi",
"inventory.section.clothing": "Vêtements",
"inventory.section.stored": "Stocké",
"inventory.section.assets": "Biens",
"inventory.onPerson.empty": "Aucun objet porté",
"inventory.onPerson.title": "Objets Actuellement Portés",
"inventory.onPerson.addItemButton": "Ajouter Objet",
"inventory.onPerson.addItemPlaceholder": "Entrer nom objet...",
"inventory.clothing.empty": "Ne porte rien",
"inventory.clothing.title": "Vêtements & Armure",
"inventory.clothing.addItemButton": "Ajouter Vêtement",
"inventory.clothing.addItemPlaceholder": "Entrer vêtement...",
"inventory.stored.title": "Lieux de Stockage",
"inventory.stored.addLocationButton": "Ajouter Lieu",
"inventory.stored.addLocationPlaceholder": "Entrer nom lieu...",
"inventory.stored.saveButton": "Sauvegarder",
"inventory.stored.empty": "Aucun lieu de stockage. Cliquez sur \"Ajouter Lieu\" pour en créer un.",
"inventory.stored.noItems": "Aucun objet stocké ici",
"inventory.stored.addItemToLocationPlaceholder": "Entrer nom objet...",
"inventory.stored.addItemButton": "Ajouter Objet",
"inventory.stored.confirmRemoveLocationMessage": "Supprimer \"${location}\" ? Cela supprimera tous les objets stockés ici.",
"inventory.stored.confirmRemoveLocationConfirmButton": "Confirmer",
"inventory.assets.empty": "Aucun bien possédé",
"inventory.assets.title": "Véhicules, Propriétés & Possessions Majeures",
"inventory.assets.addAssetModalTitle": "Ajouter Bien",
"inventory.assets.addAssetButton": "Ajouter Bien",
"inventory.assets.addAssetPlaceholder": "Entrer nom bien...",
"inventory.assets.description": "Les biens incluent les véhicules (voitures, motos), les propriétés (maisons, appartements) et les équipements majeurs (outils d'atelier, objets spéciaux).",
"quests.section.main": "Quête Principale",
"quests.section.optional": "Quêtes Optionnelles",
"quests.main.title": "Quêtes Principales",
"quests.main.addQuestButton": "Ajouter Quête",
"quests.main.addQuestPlaceholder": "Entrer titre quête principale...",
"quests.main.empty": "Aucune quête principale active",
"quests.main.hint": "La quête principale représente votre objectif principal dans l'histoire.",
"quests.optional.title": "Quêtes Optionnelles",
"quests.optional.addQuestButton": "Ajouter Quête",
"quests.optional.addQuestPlaceholder": "Entrer titre quête optionnelle...",
"quests.optional.empty": "Aucune quête optionnelle active",
"quests.optional.hint": "Les quêtes optionnelles sont des objectifs secondaires qui complètent votre histoire principale.",
"checkpoint.setChapterStart": "Définir Début Chapitre",
"checkpoint.clearChapterStart": "Effacer Début Chapitre",
"checkpoint.indicator": "Début Chapitre",
"checkpoint.tooltip": "Les messages avant ce point sont exclus du contexte",
"musicPlayer.title": "Musique de Scène",
"musicPlayer.noMusic": "L'IA suggérera de la musique quand approprié pour la scène",
"errors.parsingError": "Erreur de parsing RPG Companion Trackers ! Le modèle a renvoyé un format incorrect. Si le problème persiste, envisagez de changer le modèle pour les générations.",
"settings.recommendedModels.title": "Modèles Recommandés",
"settings.recommendedModels.description": "Pour que l'extension fonctionne correctement, **il n'est pas recommandé d'utiliser des modèles de moins de 20B, surtout s'ils sont anciens.** Elle fonctionne mieux avec les modèles SOTA tels que Deepseek, Claude, GPT ou Gemini.",
"thoughts.addCharacter": "Ajouter un personnage",
"thoughts.locked": "Verrouillé",
"thoughts.unlocked": "Déverrouillé",
"thoughts.clickToEdit": "Cliquer pour modifier",
"thoughts.clickToUpload": "Cliquer pour télécharger un avatar",
"thoughts.removeCharacter": "Supprimer le personnage",
"userStats.level": "NIV",
"userStats.clickToEditLevel": "Cliquer pour modifier le niveau",
"userStats.statsLocked": "Verrouillé - L'IA ne peut pas modifier les stats",
"userStats.statsUnlocked": "Déverrouillé - L'IA peut modifier les stats",
"userStats.clickToEditStatName": "Cliquer pour modifier le nom",
"userStats.clickToEditStatValue": "Cliquer pour modifier",
"userStats.moodLocked": "Verrouillé - L'IA ne peut pas modifier l'humeur",
"userStats.moodUnlocked": "Déverrouillé - L'IA peut modifier l'humeur",
"userStats.clickToEditEmoji": "Cliquer pour modifier l'émoji",
"userStats.skillsLocked": "Verrouillé - L'IA ne peut pas modifier les compétences",
"userStats.skillsUnlocked": "Déverrouillé - L'IA peut modifier les compétences",
"userStats.clickToEditSkills": "Cliquer pour modifier les compétences",
"infoBox.clickToEdit": "Cliquer pour modifier",
"infoBox.locked": "Verrouillé - L'IA ne peut pas modifier ceci",
"infoBox.unlocked": "Déverrouillé - L'IA peut modifier ceci",
"infoBox.weatherFallback": "Météo",
"infoBox.locationFallback": "Lieu",
"stats.health": "Santé",
"stats.satiety": "Satiété",
"stats.energy": "Énergie",
"stats.hygiene": "Hygiène",
"stats.arousal": "Excitation",
"stats.str": "FOR",
"stats.dex": "DEX",
"stats.con": "CON",
"stats.int": "INT",
"stats.wis": "VOL",
"stats.cha": "CHA"
}
+245
View File
@@ -0,0 +1,245 @@
{
"settings.language.label": "Язык",
"settings.language.option.en": "English",
"settings.language.option.zh-cn": "简体中文",
"settings.language.option.zh-tw": "繁體中文",
"settings.language.option.ru": "Русский",
"settings.extensionEnabled": "Включить RPG Companion",
"settings.note": "Включить или отключить расширение RPG Companion. Дополнительные настройки производятся непосредственно в панели приложения.",
"template.settingsTitle": "Настройки RPG Companion",
"template.settingsModal.themeTitle": "Тема",
"template.settingsModal.themeLabel": "Стиль:",
"template.settingsModal.themeOptions.default": "По умолчанию",
"template.settingsModal.themeOptions.sciFi": "Скай-фай (Synthwave)",
"template.settingsModal.themeOptions.fantasy": "Фэнтези (Rustic Parchment)",
"template.settingsModal.themeOptions.cyberpunk": "Киберпанк (Neon Grid)",
"template.settingsModal.themeOptions.custom": "Своя",
"template.settingsModal.themeOptions.custom.background": "Фон:",
"template.settingsModal.themeOptions.custom.accent": "Акцент:",
"template.settingsModal.themeOptions.custom.text": "Текст:",
"template.settingsModal.themeOptions.custom.highlight": "Подсветка:",
"template.settingsModal.theme.statBarLow": "Цвет полоски характеристики (Низкие значения):",
"template.settingsModal.theme.statBarLowNote": "Цвет при значении показателей 0%.",
"template.settingsModal.theme.statBarHigh": "Цвет полоски характеристики (Высокие значения):",
"template.settingsModal.theme.statBarHighNote": "Цвет при значении показателей 100%.",
"template.settingsModal.displayTitle": "Настройки отображения",
"template.settingsModal.displayNote": "Вы можете вкючить/отключить расширение RPG Companion во вкладке расширений для SillyTavern.",
"template.settingsModal.display.panelPosition": "Положение боковой панели:",
"template.settingsModal.display.panelPositionOptions.right": "Справа",
"template.settingsModal.display.panelPositionOptions.left": "Слева",
"template.settingsModal.display.toggleAutoUpdate": "Авто-обновление после ответа",
"template.settingsModal.display.toggleAutoUpdateNote": "Автоматически обновлять информацию в трекрере после каждого ответа.",
"template.settingsModal.display.showUserStats": "Показать Характеристики Игрока",
"template.settingsModal.display.showUserStatsNote": "Включить Характеристики Игрока, которые отслеживают статистику используемой персоны - характеристики, настроение, навыки и т.д.",
"template.settingsModal.display.showInfoBox": "Показывать Инфо-панель",
"template.settingsModal.display.showInfoBoxNote": "Отображение локации, времени, погоды и недавних событий.",
"template.settingsModal.display.showPresentCharacters": "Показывать персонажей",
"template.settingsModal.display.showPresentCharactersNote": "Показывать портреты персонажей с их текущимы мыслями и статусом.",
"template.settingsModal.display.showBelowChatPresentCharacters": "Показывать персонажей под чатом",
"template.settingsModal.display.showBelowChatPresentCharactersNote": "Показывать компактную панель персонажей под чатом.",
"template.settingsModal.display.thoughtBasedExpressions": "Выражения на основе мыслей",
"template.settingsModal.display.thoughtBasedExpressionsNote": "Использовать Character Expressions в SillyTavern для классификации мыслей каждого присутствующего персонажа в панели под чатом. Расход токенов может увеличиться в зависимости от выбранного API классификации.",
"template.settingsModal.display.hideDefaultExpressionDisplay": "Скрыть отображение выражений по умолчанию",
"template.settingsModal.display.hideDefaultExpressionDisplayNote": "Скрыть встроенное отображение выражений персонажей SillyTavern.",
"template.settingsModal.display.narratorMode": "Режим расказчика",
"template.settingsModal.display.narratorModeNote": "Использовать карточку персонажа в качестве расказчика. Персонажи берутся из контекста вместо фиксированных отсылок.",
"template.settingsModal.display.showInventory": "Показывать инвентарь",
"template.settingsModal.display.showInventoryNote": "Отслеживайте переносимые предметы, одежду, хранимые вещи и активы.",
"template.settingsModal.display.showQuests": "Показывать задания",
"template.settingsModal.display.showQuestsNote": "Управляйте основными и дополнительными заданиями с целями.",
"template.settingsModal.display.showLockIcons": "Показывать значки блокировки/разблокировки трекеров",
"template.settingsModal.display.showLockIconsNote": "Отображать значки блокировки/разблокировки на элементах трекера, чтобы предотвратить их изменение ИИ.",
"template.settingsModal.display.showThoughtsInChat": "Показывать мысли",
"template.settingsModal.display.showThoughtsInChatNote": "Отображать мысли персонажей в виде всплывающих пузырьков рядом с их сообщениями.",
"template.settingsModal.display.showInlineThoughts": "Показывать мысли под текстом сообщения",
"template.settingsModal.display.showInlineThoughtsNote": "Переключает между стандартными угловыми пузырями мыслей и карточками мыслей под текстом сообщения.",
"template.settingsModal.display.alwaysShowThoughtBubble": "Всегда показывать пузырь мыслей",
"template.settingsModal.display.alwaysShowThoughtBubbleNote": "Автоматически раскрывать пузырь мыслей без предварительного нажатия на значок",
"template.settingsModal.display.enableAnimations": "Включить анимации",
"template.settingsModal.display.enableAnimationsNote": "Плавные переходы для характеристик, обновления контента и бросков кубиков.",
"template.settingsModal.display.showImmersiveHtmlToggle": "Показывать переключатель Immersive HTML",
"template.settingsModal.display.showImmersiveHtmlToggleNote": "Отображать кнопку переключения для включения/отключения HTML-форматирования в сообщениях.",
"template.settingsModal.display.showDialogueColoringToggle": "Показывать переключатель цветных диалогов",
"template.settingsModal.display.showDialogueColoringToggleNote": "Отображать кнопку переключения для включения/отключения цветного форматирования диалогов.",
"template.settingsModal.display.showSpotifyMusicToggle": "Показывать переключатель музыки Spotify",
"template.settingsModal.display.showSpotifyMusicToggleNote": "Отображать музыкальный проигрыватель Spotify с предложенными ИИ треками, подходящими для сцены.",
"template.settingsModal.display.showSnowflakesToggle": "Показывать переключатель погодных эффектов",
"template.settingsModal.display.showDynamicWeatherToggle": "Показывать переключатель динамических погодных эффектов",
"template.settingsModal.display.showDynamicWeatherToggleNote": "Отображать кнопку переключения для включения/отключения анимированных погодных эффектов.",
"template.settingsModal.display.showNarratorMode": "Показывать переключатель режима рассказчика",
"template.settingsModal.display.showNarratorModeNote": "Отображать кнопку переключения для включения/отключения режима рассказчика (персонажи определяются из контекста).",
"template.settingsModal.display.showAutoAvatars": "Показывать переключатель автоматической генерации аватаров",
"template.settingsModal.display.showAutoAvatarsNote": "Отображать кнопку переключения для автоматической генерации аватаров для персонажей без изображений.",
"template.settingsModal.display.showRandomizedPlot": "Показывать переключатель случайного развития сюжета",
"template.settingsModal.display.showRandomizedPlotNote": "Отображать кнопку для генерации ИИ случайных подсказок для развития сюжета.",
"template.settingsModal.display.showNaturalPlot": "Показывать переключатель естественного развития сюжета",
"template.settingsModal.display.showNaturalPlotNote": "Отображать кнопку для контекстно-зависимых подсказок продолжения повествования.",
"template.settingsModal.display.showStartEncounter": "Показывать переключатель начала встречи",
"template.settingsModal.display.showStartEncounterNote": "Отображать кнопку для начала интерактивных боевых столкновений.",
"template.settingsModal.display.showDiceDisplay": "Показывать отображение броска кубиков",
"template.settingsModal.display.showDiceDisplayNote": "Отображать индикатор \"Последний бросок\" на панели.",
"template.mainPanel.autoAvatars": "Авто-аватары",
"template.settingsModal.advancedTitle": "Дополнительно",
"template.settingsModal.advanced.encounterHistoryDepth": "Глубина истории чата для боя:",
"template.settingsModal.advanced.encounterHistoryDepthNote": "Количество последних сообщений, включаемых при инициализации боя.",
"template.settingsModal.advanced.autoSaveCombatLogs": "Автосохранение журналов боя",
"template.settingsModal.advanced.autoSaveCombatLogsNote": "Сохранять подробные журналы боя в файл для будущего использования и анализа.",
"template.settingsModal.advanced.clearCacheNote": "Очищает сохраненные и отображаемые данные трекеров для текущего активного чата.",
"template.settingsModal.advanced.generationMode": "Режим генерации:",
"template.settingsModal.advanced.generationModeOptions.together": "Вместе с основной генерацией",
"template.settingsModal.advanced.generationModeOptions.separate": "Отдельная генерация",
"template.settingsModal.advanced.generationModeNote": "Вместе: добавляет RPG-трекинг к основному ответу. Отдельно: генерирует RPG-данные отдельно (вручную или автоматически). Внешний: подключается напрямую к OpenAI-совместимому эндпоинту.",
"template.settingsModal.advanced.generationModeOptions.external": "Внешний API",
"template.settingsModal.advanced.externalApi.title": "Настройки внешнего API",
"template.settingsModal.advanced.externalApi.baseUrl": "Базовый URL API",
"template.settingsModal.advanced.externalApi.baseUrlNote": "OpenAI-совместимый эндпоинт (например, OpenAI, OpenRouter, локальный сервер LLM).",
"template.settingsModal.advanced.externalApi.apiKey": "API-ключ",
"template.settingsModal.advanced.externalApi.apiKeyNote": "Ваш API-ключ для внешнего сервиса.",
"template.settingsModal.advanced.externalApi.model": "Модель",
"template.settingsModal.advanced.externalApi.modelNote": "Идентификатор модели (например, gpt-4o-mini, claude-3-haiku, mistral-7b).",
"template.settingsModal.advanced.externalApi.maxTokens": "Максимальное количество токенов",
"template.settingsModal.advanced.externalApi.temperature": "Температура",
"template.settingsModal.advanced.externalApi.testConnection": "Тестировать соединение",
"template.settingsModal.advanced.contextMessages": "Контекстные сообщения:",
"template.settingsModal.advanced.contextMessagesNote": "Количество последних сообщений, включаемых в контекст.",
"template.settingsModal.advanced.useSeparatePreset": "Использовать модель, подключенную к пресету RPG Companion Trackers",
"template.settingsModal.advanced.useSeparatePresetNote": "При включении генерация трекеров будет использовать модель из пресета \"RPG Companion Trackers\" вместо основной модели API. Пресет будет автоматически переключаться во время генерации и восстанавливаться после нее. Выберите желаемую модель в этом пресете и убедитесь, что переключатель \"Bind presets to API connections\" включен (рядом с кнопками импорта/экспорта пресетов).",
"template.settingsModal.advanced.skipInjections": "Пропускать инъекции во время управляемых генераций:",
"template.settingsModal.advanced.skipInjectionsOptions.none": "Никогда не пропускать",
"template.settingsModal.advanced.skipInjectionsOptions.impersonation": "Только при запросах олицетворения",
"template.settingsModal.advanced.skipInjectionsOptions.guided": "Всегда для управляемых или тихих подсказок",
"template.settingsModal.advanced.skipInjectionsNote": "При установке расширение не будет внедрять подсказки трекеров, примеры или HTML-инструкции в соответствии с выбранным режимом при обнаружении управляемой генерации (через `instruct` или `quiet_prompt`). Полезно при использовании GuidedGenerations или аналогичных расширений.",
"template.settingsModal.advanced.customHtmlPromptTitle": "Пользовательская HTML-подсказка:",
"template.settingsModal.advanced.restoreDefaultHtmlPrompt": "Восстановить по умолчанию",
"template.settingsModal.advanced.customHtmlPromptNote": "Настройте HTML-подсказку, которая внедряется при включенной опции \"Enable Immersive HTML\". Подсказка по умолчанию показана выше - вы можете редактировать ее напрямую или полностью заменить. Нажмите \"Восстановить по умолчанию\" для сброса. Это влияет на все режимы генерации (together, separate и plot progression).",
"template.settingsModal.advanced.clearCache": "Очистить кэш расширения",
"template.settingsModal.advanced.resetFabPositions": "Сбросить позиции кнопок",
"template.settingsModal.advanced.resetFabPositionsNote": "Сбрасывает все плавающие кнопки действий (переключение, обновление, отладка) в позиции по умолчанию (сверху слева). Полезно, если кнопки находятся за пределами экрана.",
"template.trackerEditorModal.title": "Редактировать трекеры",
"template.trackerEditorModal.tabs.userStats": "Характеристики пользователя",
"template.trackerEditorModal.tabs.infoBox": "Инфо-панель",
"template.trackerEditorModal.tabs.presentCharacters": "Присутствующие персонажи",
"template.trackerEditorModal.buttons.reset": "Сбросить",
"template.trackerEditorModal.buttons.cancel": "Отмена",
"template.trackerEditorModal.buttons.save": "Сохранить и применить",
"template.trackerEditorModal.buttons.export": "Экспорт",
"template.trackerEditorModal.buttons.import": "Импорт",
"template.trackerEditorModal.messages.exportSuccess": "Шаблон трекеров успешно экспортирован!",
"template.trackerEditorModal.messages.exportError": "Не удалось экспортировать шаблон трекеров. Проверьте консоль для получения подробностей.",
"template.trackerEditorModal.messages.importSuccess": "Шаблон трекеров успешно импортирован!",
"template.trackerEditorModal.messages.importError": "Не удалось импортировать шаблон трекеров",
"template.trackerEditorModal.messages.importConfirm": "Это заменит текущую конфигурацию трекеров. Продолжить?",
"template.trackerEditorModal.userStatsTab.customStatsTitle": "Пользовательские характеристики",
"template.trackerEditorModal.userStatsTab.addCustomStatButton": "Добавить пользовательскую характеристику",
"template.trackerEditorModal.userStatsTab.rpgAttributesTitle": "RPG-атрибуты",
"template.trackerEditorModal.userStatsTab.enableRpgAttributes": "Включить раздел RPG-атрибутов",
"template.trackerEditorModal.userStatsTab.alwaysIncludeAttributes": "Всегда включать атрибуты в подсказку",
"template.trackerEditorModal.userStatsTab.alwaysIncludeAttributesNote": "Если отключено, атрибуты отправляются только при активном броске кубиков.",
"template.trackerEditorModal.userStatsTab.addAttributeButton": "Добавить атрибут",
"template.trackerEditorModal.userStatsTab.statusSectionTitle": "Раздел статуса",
"template.trackerEditorModal.userStatsTab.enableStatusSection": "Включить раздел статуса",
"template.trackerEditorModal.userStatsTab.showMoodEmoji": "Показывать эмодзи настроения",
"template.trackerEditorModal.userStatsTab.statusFieldsLabel": "Поля статуса (через запятую):",
"template.trackerEditorModal.userStatsTab.skillsSectionTitle": "Раздел навыков",
"template.trackerEditorModal.userStatsTab.enableSkillsSection": "Включить раздел навыков",
"template.trackerEditorModal.userStatsTab.skillsLabelLabel": "Метка навыков:",
"template.trackerEditorModal.userStatsTab.skillsListLabel": "Список навыков (через запятую):",
"template.trackerEditorModal.infoBoxTab.widgetsTitle": "Виджеты",
"template.trackerEditorModal.infoBoxTab.dateWidget": "Дата",
"template.trackerEditorModal.infoBoxTab.weatherWidget": "Погода",
"template.trackerEditorModal.infoBoxTab.temperatureWidget": "Температура",
"template.trackerEditorModal.infoBoxTab.timeWidget": "Время",
"template.trackerEditorModal.infoBoxTab.locationWidget": "Местоположение",
"template.trackerEditorModal.infoBoxTab.recentEventsWidget": "Недавние события",
"template.trackerEditorModal.presentCharactersTab.relationshipStatusTitle": "Поля статуса отношений",
"template.trackerEditorModal.presentCharactersTab.enableRelationshipStatus": "Включить поля статуса отношений",
"template.trackerEditorModal.presentCharactersTab.relationshipStatusHint": "Определите типы отношений с соответствующими эмодзи, отображаемыми на портретах персонажей.",
"template.trackerEditorModal.presentCharactersTab.newRelationshipButton": "Новое отношение",
"template.trackerEditorModal.presentCharactersTab.appearanceDemeanorTitle": "Поля внешности/поведения",
"template.trackerEditorModal.presentCharactersTab.appearanceDemeanorHint": "Поля, отображаемые под именем персонажа.",
"template.trackerEditorModal.presentCharactersTab.addCustomFieldButton": "Добавить пользовательское поле",
"template.trackerEditorModal.presentCharactersTab.thoughtsConfigTitle": "Настройки мыслей",
"template.trackerEditorModal.presentCharactersTab.enableCharacterThoughts": "Включить мысли персонажей",
"template.trackerEditorModal.presentCharactersTab.thoughtsLabelLabel": "Метка мыслей:",
"template.trackerEditorModal.presentCharactersTab.aiInstructionLabel": "Инструкция для ИИ:",
"template.trackerEditorModal.presentCharactersTab.characterStatsTitle": "Характеристики персонажей",
"template.trackerEditorModal.presentCharactersTab.trackCharacterStats": "Отслеживать характеристики персонажей",
"template.trackerEditorModal.presentCharactersTab.characterStatsHint": "Создавайте характеристики для отслеживания для каждого персонажа (отображаются в виде цветных полос).",
"template.trackerEditorModal.presentCharactersTab.addCharacterStatButton": "Добавить характеристику персонажа",
"template.mainPanel.title": "RPG Companion",
"template.mainPanel.lastRoll": "Последний бросок:",
"template.mainPanel.clearLastRoll": "Очистить последний бросок",
"template.mainPanel.immersiveHtml": "Immersive HTML",
"template.mainPanel.coloredDialogues": "Цветные диалоги",
"template.mainPanel.spotifyMusic": "Музыка Spotify",
"template.mainPanel.snowflakesEffect": "Эффект снежинок",
"template.mainPanel.dynamicWeatherEffects": "Динамическая погода",
"template.mainPanel.narratorMode": "Режим рассказчика",
"template.mainPanel.refreshRpgInfo": "Обновить RPG-информацию",
"template.mainPanel.updating": "Обновление...",
"template.mainPanel.editTrackersButton": "Редактировать трекеры",
"template.mainPanel.settingsButton": "Настройки",
"global.none": "Нет",
"global.add": "Добавить",
"global.cancel": "Отмена",
"global.listView": "Вид списка",
"global.gridView": "Вид сетки",
"global.save": "Сохранить",
"global.status": "Статус",
"global.inventory": "Инвентарь",
"global.quests": "Задания",
"global.info": "Информация",
"infobox.noData.title": "Данных пока нет",
"infobox.noData.instruction": "Сгенерируйте новый ответ в ролевой игре или переключитесь на \"Отдельную генерацию\" в Настройках, чтобы получить доступ и нажать кнопку \"Обновить RPG-информацию\"",
"infobox.recentEvents.title": "Недавние события",
"infobox.recentEvents.addEventPlaceholder": "Добавить событие...",
"inventory.section.onPerson": "При себе",
"inventory.section.clothing": "Одежда",
"inventory.section.stored": "Хранимое",
"inventory.section.assets": "Активы",
"inventory.onPerson.empty": "Нет переносимых предметов",
"inventory.onPerson.title": "Предметы, которые сейчас в инвентаре",
"inventory.onPerson.addItemButton": "Добавить предмет",
"inventory.onPerson.addItemPlaceholder": "Введите название предмета...",
"inventory.clothing.empty": "Ничего не надето",
"inventory.clothing.title": "Одежда и броня",
"inventory.clothing.addItemButton": "Добавить одежду",
"inventory.clothing.addItemPlaceholder": "Введите элемент одежды...",
"inventory.stored.title": "Места хранения",
"inventory.stored.addLocationButton": "Добавить место",
"inventory.stored.addLocationPlaceholder": "Введите название места...",
"inventory.stored.saveButton": "Сохранить",
"inventory.stored.empty": "Пока нет мест хранения. Нажмите \"Добавить место\", чтобы создать.",
"inventory.stored.noItems": "Здесь нет хранимых предметов",
"inventory.stored.addItemToLocationPlaceholder": "Введите название предмета...",
"inventory.stored.addItemButton": "Добавить предмет",
"inventory.stored.confirmRemoveLocationMessage": "Удалить \"${location}\"? Это удалит все предметы, хранящиеся там.",
"inventory.stored.confirmRemoveLocationConfirmButton": "Подтвердить",
"inventory.assets.empty": "Нет активов",
"inventory.assets.title": "Транспорт, недвижимость и крупные владения",
"inventory.assets.addAssetModalTitle": "Добавить актив",
"inventory.assets.addAssetButton": "Добавить актив",
"inventory.assets.addAssetPlaceholder": "Введите название актива...",
"inventory.assets.description": "Активы включают транспортные средства (автомобили, мотоциклы), недвижимость (дома, квартиры) и крупное оборудование (инструменты для мастерской, специальные предметы).",
"quests.section.main": "Основное задание",
"quests.section.optional": "Дополнительные задания",
"quests.main.title": "Основные задания",
"quests.main.addQuestButton": "Добавить задание",
"quests.main.addQuestPlaceholder": "Введите название основного задания...",
"quests.main.empty": "Нет активных основных заданий",
"quests.main.hint": "Основное задание представляет вашу главную цель в истории.",
"quests.optional.title": "Дополнительные задания",
"quests.optional.addQuestButton": "Добавить задание",
"quests.optional.addQuestPlaceholder": "Введите название дополнительного задания...",
"quests.optional.empty": "Нет активных дополнительных заданий",
"quests.optional.hint": "Дополнительные задания - это побочные цели, которые дополняют основную историю.",
"checkpoint.setChapterStart": "Установить начало главы",
"checkpoint.clearChapterStart": "Очистить начало главы",
"checkpoint.indicator": "Начало главы",
"checkpoint.tooltip": "Сообщения до этой точки исключаются из контекста",
"musicPlayer.title": "Музыка сцены",
"musicPlayer.noMusic": "ИИ будет предлагать музыку, когда это уместно для сцены",
"errors.parsingError": "Ошибка парсинга RPG Companion Trackers! Модель вернула неправильный формат. Если проблема сохраняется, рассмотрите возможность смены модели для генераций.",
"settings.recommendedModels.title": "Рекомендуемые модели",
"settings.recommendedModels.description": "Для правильной работы расширения **не рекомендуется использовать модели с базой обчучения ниже 20B, особенно если они старые.** Оно лучше всего работает с современными моделями, такими как Deepseek, Claude, GPT или Gemini."
}
+264
View File
@@ -0,0 +1,264 @@
const fs = require('fs-extra');
const path = require('path');
const chokidar = require('chokidar');
const glob = require('glob');
const COMPILED_DIR = __dirname // path.join(__dirname, 'compiled');
function findUnlocalizedText() {
const srcArg = process.argv.find(arg => arg.startsWith('--src='));
const srcDir = srcArg ? srcArg.split('=')[1] : '.';
console.log(`\n🔎 Scanning for unlocalized text in ${srcDir}...`);
const files = glob.sync(`${srcDir}/**/*.{html,js,jsx}`, {
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**']
});
if (files.length === 0) {
console.log('⚠️ No .html/.js/.jsx files found');
return;
}
let totalFound = 0;
for (const file of files) {
const content = fs.readFileSync(file, 'utf8');
const lines = content.split('\n');
const relPath = path.relative(process.cwd(), file);
// Searching for string number
lines.forEach((line, index) => {
let match;
const localPattern = /<([a-zA-Z][a-zA-Z0-9]*)(?:\s(?:[^>](?!data-i18n-key))*)?>([\p{L}\p{N}\s\-.,!?:'"()]+)<\/\1>/gu;
while ((match = localPattern.exec(line)) !== null) {
const text = match[2].trim();
if (!text) continue;
// Passing JSX expressions like {someVar}
if (text.includes('{') || text.includes('}')) continue;
// Passing if tag has data-i18n-key
if (match[0].includes('data-i18n-key')) continue;
console.log(` - ${relPath}:${index + 1} — <${match[1]}> "${text}"`);
totalFound++;
}
});
}
if (totalFound === 0) {
console.log('✅ No unlocalized text found!');
} else {
console.log(`\n📋 Found ${totalFound} potentially unlocalized text node(s)`);
}
}
// Function to validate translations
function validateTranslations() {
console.log('🔍 Validating translation files...');
// Parse --locales=en,fr argument
const localesArg = process.argv.find(arg => arg.startsWith('--locales='));
const selectedLocales = localesArg
? localesArg.split('=')[1].split(',').map(l => l.trim())
: null;
const files = fs.readdirSync(COMPILED_DIR)
.filter(file => file.endsWith('.json'))
.filter(file => {
const locale = path.basename(file, '.json');
return !selectedLocales || selectedLocales.includes(locale);
});
if (files.length === 0) {
console.log('⚠️ No compiled translation files found');
return;
}
// Load all translation data
const translations = {};
for (const file of files) {
const locale = path.basename(file, '.json');
const filePath = path.join(COMPILED_DIR, file);
translations[locale] = fs.readJsonSync(filePath);
}
// Get all locales
const locales = Object.keys(translations);
console.log(`📁 Found ${locales.length} locales: ${locales.join(', ')}`);
if (locales.length < 2) {
console.log('⚠️ Need at least 2 locales to compare');
return;
}
// Choose the first locale as reference
const referenceLocale = locales[0];
console.log(`🔑 Using ${referenceLocale} as reference locale`);
// Get all keys from reference locale
const referenceKeys = Object.keys(translations[referenceLocale]);
console.log(`🔢 Reference locale has ${referenceKeys.size} unique keys`);
// Track statistics
const stats = {
missingKeys: {},
extraKeys: {},
typeErrors: {}
};
// Initialize stats for each locale
for (const locale of locales) {
if (locale !== referenceLocale) {
stats.missingKeys[locale] = [];
stats.extraKeys[locale] = [];
stats.typeErrors[locale] = [];
}
}
// Check each locale against the reference
for (const locale of locales) {
if (locale === referenceLocale) continue;
const localeKeys = Object.keys(translations[locale]);
// Check for missing keys
for (const key of referenceKeys) {
if (!key in translations[locale]) {
stats.missingKeys[locale].push(key);
} else {
// Check for type mismatches
const refValue = translations[referenceLocale][key];
const localeValue = translations[locale][key];
if (typeof refValue !== typeof localeValue) {
stats.typeErrors[locale].push({
key,
refType: typeof refValue,
localeType: typeof localeValue
});
}
}
}
// Check for extra keys
for (const key of localeKeys) {
if (!key in translations[referenceLocale]) {
stats.extraKeys[locale].push(key);
}
}
}
// Print results
let hasIssues = false;
// Print missing keys
for (const locale in stats.missingKeys) {
const missing = stats.missingKeys[locale];
if (missing.length > 0) {
hasIssues = true;
console.log(`${locale} is missing ${missing.length} keys:`);
missing.forEach(key => {
console.log(` - ${key}`);
});
}
}
// Print extra keys
for (const locale in stats.extraKeys) {
const extra = stats.extraKeys[locale];
if (extra.length > 0) {
hasIssues = true;
console.log(`⚠️ ${locale} has ${extra.length} extra keys:`);
extra.forEach(key => {
console.log(` - ${key}`);
});
}
}
// Print type errors
for (const locale in stats.typeErrors) {
const typeErrors = stats.typeErrors[locale];
if (typeErrors.length > 0) {
hasIssues = true;
console.log(`⚠️ ${locale} has ${typeErrors.length} type mismatches:`);
typeErrors.forEach(err => {
console.log(` - ${err.key}: expected ${err.refType}, got ${err.localeType}`);
});
}
}
// Print empty values check if needed
console.log('\n📊 Checking for empty values...');
for (const locale of locales) {
checkEmptyValues(translations[locale], locale);
}
if (!hasIssues) {
console.log('✅ All locales have consistent structure!');
}
return hasIssues;
}
// Function to check for empty values
function checkEmptyValues(obj, locale, prefix = '') {
for (const key in obj) {
const fullKey = prefix ? `${prefix}.${key}` : key;
const value = obj[key];
if (value === '') {
console.log(`⚠️ ${locale} has empty string at ${fullKey}`);
} else if (value === null) {
console.log(`⚠️ ${locale} has null value at ${fullKey}`);
} else if (typeof value === 'object' && !Array.isArray(value)) {
checkEmptyValues(value, locale, fullKey);
}
}
}
// Main function
function main() {
// Create compiled directory if it doesn't exist
fs.ensureDirSync(COMPILED_DIR);
// Run validation
validateTranslations();
// Find unlocalized text
findUnlocalizedText();
}
// Watch mode
if (process.argv.includes('--watch')) {
console.log('👀 Watching for changes...');
let debounceTimer;
const debounceDelay = 100;
// Initial validation
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
main();
}, debounceDelay);
// Watch for changes in the compiled directory
chokidar.watch(COMPILED_DIR, {
ignoreInitial: true,
ignored: /.*~$/, // Игнорировать скрытые файлы
}).on('all', (event, path) => {
if (event === 'change' || event === 'add' || event === 'unlink') {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
console.log(`🔁 Detected changes in ${path} (${event}), revalidating...`);
main();
}, debounceDelay);
}
});
} else {
// Run once
main();
}
+493
View File
@@ -0,0 +1,493 @@
{
"settings.language.label": "语言",
"settings.language.option.en": "English",
"settings.language.option.zh-cn": "简体中文",
"settings.language.option.zh-tw": "繁體中文",
"settings.language.option.ru": "Русский",
"settings.language.option.fr": "Français",
"settings.extensionEnabled": "启用 RPG Companion",
"settings.note": "切换以启用/禁用 RPG Companion 扩展。其他设置可在面板内配置。",
"template.settingsTitle": "RPG Companion 设置",
"template.settingsModal.themeTitle": "主题",
"template.settingsModal.themeLabel": "视觉主题:",
"template.settingsModal.themeOptions.default": "默认",
"template.settingsModal.themeOptions.sciFi": "科幻 (合成波)",
"template.settingsModal.themeOptions.fantasy": "奇幻 (古朴羊皮纸)",
"template.settingsModal.themeOptions.cyberpunk": "赛博朋克 (霓虹网格)",
"template.settingsModal.themeOptions.custom": "自定义",
"template.settingsModal.themeOptions.custom.background": "背景:",
"template.settingsModal.themeOptions.custom.accent": "强调色:",
"template.settingsModal.themeOptions.custom.text": "文字:",
"template.settingsModal.themeOptions.custom.highlight": "高亮:",
"template.settingsModal.theme.statBarLow": "状态条颜色 (低)",
"template.settingsModal.theme.statBarLowNote": "数值为 0% 时的颜色。",
"template.settingsModal.theme.statBarHigh": "状态条颜色 (高)",
"template.settingsModal.theme.statBarHighNote": "数值为 100% 时的颜色。",
"template.settingsModal.displayTitle": "显示选项",
"template.settingsModal.displayNote": "您可以在 SillyTavern 的扩展标签页中启用/禁用整个 RPG Companion 扩展。",
"template.settingsModal.display.panelPosition": "面板位置:",
"template.settingsModal.display.panelPositionOptions.right": "右侧边栏",
"template.settingsModal.display.panelPositionOptions.left": "左侧边栏",
"template.settingsModal.display.toggleAutoUpdate": "消息后自动更新",
"template.settingsModal.display.toggleAutoUpdateNote": "每条消息后自动刷新 RPG 信息。",
"template.settingsModal.display.showUserStats": "显示用户数值",
"template.settingsModal.display.showUserStatsNote": "启用用户数值,跟踪您角色的数值、心情、属性、技能等。",
"template.settingsModal.display.showInfoBox": "显示信息框",
"template.settingsModal.display.showInfoBoxNote": "显示位置、时间、天气和最近事件。",
"template.settingsModal.display.showPresentCharacters": "显示在场角色",
"template.settingsModal.display.showPresentCharactersNote": "显示角色肖像及其当前想法和状态。",
"template.settingsModal.display.showBelowChatPresentCharacters": "显示聊天下方的在场角色",
"template.settingsModal.display.showBelowChatPresentCharactersNote": "在聊天下方显示紧凑的在场角色面板。",
"template.settingsModal.display.thoughtBasedExpressions": "基于想法的表情",
"template.settingsModal.display.thoughtBasedExpressionsNote": "使用 SillyTavern Character Expressions 对聊天下方面板中每个在场角色的想法进行分类。Token 用量可能会因所选的分类 API 而增加。",
"template.settingsModal.display.hideDefaultExpressionDisplay": "隐藏默认表情显示",
"template.settingsModal.display.hideDefaultExpressionDisplayNote": "隐藏 SillyTavern 内置的角色表情显示。",
"template.settingsModal.display.narratorMode": "旁白模式",
"template.settingsModal.display.narratorModeNote": "使用角色卡作为旁白。根据上下文推断角色,而非使用固定的角色引用。",
"template.settingsModal.display.showInventory": "显示物品栏",
"template.settingsModal.display.showInventoryNote": "跟踪携带的物品、穿戴的衣物、存储的物品和资产。",
"template.settingsModal.display.showQuests": "显示任务",
"template.settingsModal.display.showQuestsNote": "管理带有目标的主要和可选任务。",
"template.settingsModal.display.showLockIcons": "显示锁定/解锁跟踪器",
"template.settingsModal.display.showLockIconsNote": "在跟踪器项目上显示锁定/解锁图标,以防止 AI 修改它们。",
"template.settingsModal.display.showThoughtsInChat": "显示想法",
"template.settingsModal.display.showThoughtsInChatNote": "将角色想法显示为其消息旁边的气泡。",
"template.settingsModal.display.showInlineThoughts": "在消息文本下方显示想法",
"template.settingsModal.display.showInlineThoughtsNote": "在默认角落想法气泡和显示在消息文本下方的想法卡片之间切换。",
"template.settingsModal.display.alwaysShowThoughtBubble": "始终显示想法气泡",
"template.settingsModal.display.alwaysShowThoughtBubbleNote": "自动展开想法气泡,无需先点击图标",
"template.settingsModal.display.enableAnimations": "启用动画",
"template.settingsModal.display.enableAnimationsNote": "数值、内容更新和掷骰的平滑过渡。",
"template.settingsModal.display.showImmersiveHtmlToggle": "显示沉浸式 HTML",
"template.settingsModal.display.showImmersiveHtmlToggleNote": "显示一个切换按钮以启用/禁用消息中的 HTML 格式。",
"template.settingsModal.display.showDialogueColoringToggle": "显示彩色对话",
"template.settingsModal.display.showDialogueColoringToggleNote": "显示一个切换按钮以启用/禁用彩色对话格式。",
"template.settingsModal.display.showDeceptionToggle": "显示欺骗系统",
"template.settingsModal.display.showDeceptionToggleNote": "显示一个切换按钮以启用/禁用用于标记谎言和欺骗的欺骗系统。",
"template.settingsModal.display.showOmniscienceToggle": "显示全知过滤器",
"template.settingsModal.display.showOmniscienceToggleNote": "显示一个切换按钮以启用/禁用用于过滤隐藏事件的全知过滤器。",
"template.settingsModal.display.showSpotifyMusicToggle": "显示 Spotify 音乐",
"template.settingsModal.display.showSpotifyMusicToggleNote": "显示 Spotify 音乐播放器,带有 AI 推荐的适合场景的曲目。",
"template.settingsModal.display.showSnowflakesToggle": "显示雪花效果",
"template.settingsModal.display.showDynamicWeatherToggle": "显示动态天气效果",
"template.settingsModal.display.showDynamicWeatherToggleNote": "显示一个切换按钮以启用/禁用动画天气效果。",
"template.settingsModal.display.showNarratorMode": "显示旁白模式",
"template.settingsModal.display.showNarratorModeNote": "显示一个切换按钮以启用/禁用旁白模式(根据上下文推断角色)。",
"template.settingsModal.display.showAutoAvatars": "显示自动生成头像",
"template.settingsModal.display.showAutoAvatarsNote": "显示一个切换按钮以自动为没有图片的角色生成头像。",
"template.settingsModal.display.showRandomizedPlot": "显示随机化剧情推进",
"template.settingsModal.display.showRandomizedPlotNote": "显示用于 AI 生成的随机剧情推进提示的按钮。",
"template.settingsModal.display.showNaturalPlot": "显示自然剧情推进",
"template.settingsModal.display.showNaturalPlotNote": "显示用于上下文感知的叙事延续提示的按钮。",
"template.settingsModal.display.showStartEncounter": "显示开始遭遇",
"template.settingsModal.display.showStartEncounterNote": "显示按钮以启动交互式战斗遭遇。",
"template.settingsModal.display.showDiceDisplay": "显示掷骰显示",
"template.settingsModal.display.showDiceDisplayNote": "在面板中显示“上次掷骰”指示器。",
"template.settingsModal.display.showCYOAToggle": "显示选择冒险",
"template.settingsModal.display.showCYOAToggleNote": "显示一个切换按钮,用于启用/禁用“选择你自己的冒险”格式指令,该指令使模型在输出结束时生成五个可能的行动/对话供你选择。",
"template.settingsModal.display.weatherPosition.background": "在背景中显示",
"template.settingsModal.display.weatherPosition.backgroundNote": "在聊天背景中显示天气效果(标准行为)。",
"template.settingsModal.display.weatherPosition.foreground": "在前景中显示",
"template.settingsModal.display.weatherPosition.foregroundNote": "在聊天前景中显示天气效果(实验性)。",
"template.mainPanel.autoAvatars": "自动头像",
"template.settingsModal.advancedTitle": "高级",
"template.settingsModal.advanced.encounterHistoryDepth": "遭遇战的聊天历史深度:",
"template.settingsModal.advanced.encounterHistoryDepthNote": "包含在战斗初始化中的最近消息数量。",
"template.settingsModal.advanced.autoSaveCombatLogs": "自动保存战斗日志",
"template.settingsModal.advanced.autoSaveCombatLogsNote": "将详细战斗日志保存到文件以供将来参考和分析。",
"template.settingsModal.advanced.clearCacheNote": "清除当前活动聊天中已提交和显示的跟踪器数据。",
"template.settingsModal.advanced.generationMode": "生成模式:",
"template.settingsModal.advanced.generationModeOptions.together": "集成生成",
"template.settingsModal.advanced.generationModeOptions.separate": "单独生成",
"template.settingsModal.advanced.generationModeNote": "集成:将 RPG 跟踪添加到主要角色扮演中。单独:单独生成 RPG 数据(手动或自动)。外部:直接连接到 OpenAI 兼容端点。",
"template.settingsModal.advanced.generationModeOptions.external": "外部 API",
"template.settingsModal.advanced.externalApi.title": "外部 API 设置",
"template.settingsModal.advanced.externalApi.baseUrl": "API 基础 URL",
"template.settingsModal.advanced.externalApi.baseUrlNote": "OpenAI 兼容端点(例如 OpenAI、OpenRouter、本地 LLM 服务器)。",
"template.settingsModal.advanced.externalApi.apiKey": "API 密钥",
"template.settingsModal.advanced.externalApi.apiKeyNote": "您的外部服务 API 密钥。",
"template.settingsModal.advanced.externalApi.model": "模型",
"template.settingsModal.advanced.externalApi.modelNote": "模型标识符(例如 gpt-4o-mini、claude-3-haiku、mistral-7b)。",
"template.settingsModal.advanced.externalApi.maxTokens": "最大token数",
"template.settingsModal.advanced.externalApi.temperature": "温度",
"template.settingsModal.advanced.externalApi.testConnection": "测试连接",
"template.settingsModal.advanced.contextMessages": "上下文消息:",
"template.settingsModal.advanced.contextMessagesNote": "包含的最近消息数量。",
"template.settingsModal.advanced.useSeparatePreset": "使用连接到 RPG Companion Trackers 预设的模型",
"template.settingsModal.advanced.useSeparatePresetNote": "启用后,跟踪器生成将使用“RPG Companion Trackers”预设中的模型,而不是您的主 API 模型。预设将在生成期间自动切换并在之后恢复。在该预设中选择所需模型,并确保“将预设绑定到 API 连接”切换已打开(位于导入/导出预设按钮旁边)。",
"template.settingsModal.advanced.skipInjections": "在引导生成期间跳过注入:",
"template.settingsModal.advanced.skipInjectionsOptions.none": "从不跳过",
"template.settingsModal.advanced.skipInjectionsOptions.impersonation": "仅在模拟请求时",
"template.settingsModal.advanced.skipInjectionsOptions.guided": "始终针对引导或静默提示",
"template.settingsModal.advanced.skipInjectionsNote": "设置后,当检测到引导生成(通过 `instruct` 或 `quiet_prompt`)时,扩展将不会根据所选模式注入跟踪器提示、示例或 HTML 指令。在使用 GuidedGenerations 或类似扩展时很有用。",
"template.settingsModal.advanced.customHtmlPromptTitle": "自定义 HTML 提示:",
"template.settingsModal.advanced.restoreDefaultHtmlPrompt": "恢复默认",
"template.settingsModal.advanced.customHtmlPromptNote": "自定义启用“启用沉浸式 HTML”时注入的 HTML 提示。默认提示显示在上面 - 您可以直接编辑或完全替换。点击“恢复默认”以重置。这会影响所有生成模式(同时、单独和剧情推进)。",
"template.settingsModal.advanced.clearCache": "清除扩展缓存",
"template.settingsModal.advanced.resetFabPositions": "重置按钮位置",
"template.settingsModal.advanced.resetFabPositionsNote": "将所有浮动操作按钮(切换、刷新、调试)重置为默认的左上角位置。如果按钮在屏幕外,这很有用。",
"template.trackerEditorModal.title": "编辑跟踪器",
"template.trackerEditorModal.tabs.userStats": "用户数值",
"template.trackerEditorModal.tabs.infoBox": "信息框",
"template.trackerEditorModal.tabs.presentCharacters": "在场角色",
"template.trackerEditorModal.buttons.reset": "重置",
"template.trackerEditorModal.buttons.cancel": "取消",
"template.trackerEditorModal.buttons.save": "保存并应用",
"template.trackerEditorModal.buttons.export": "导出",
"template.trackerEditorModal.buttons.import": "导入",
"template.trackerEditorModal.messages.exportSuccess": "跟踪器预设导出成功!",
"template.trackerEditorModal.messages.exportError": "跟踪器预设导出失败。请检查控制台以获取详细信息。",
"template.trackerEditorModal.messages.importSuccess": "跟踪器预设导入成功!",
"template.trackerEditorModal.messages.importError": "跟踪器预设导入失败",
"template.trackerEditorModal.messages.importConfirm": "这将替换您当前的跟踪器配置。继续吗?",
"template.trackerEditorModal.userStatsTab.customStatsTitle": "自定义数值",
"template.trackerEditorModal.userStatsTab.addCustomStatButton": "添加自定义数值",
"template.trackerEditorModal.userStatsTab.rpgAttributesTitle": "RPG 属性",
"template.trackerEditorModal.userStatsTab.enableRpgAttributes": "启用 RPG 属性部分",
"template.trackerEditorModal.userStatsTab.alwaysIncludeAttributes": "始终在提示中包含属性",
"template.trackerEditorModal.userStatsTab.alwaysIncludeAttributesNote": "如果禁用,属性仅在掷骰活动时发送。",
"template.trackerEditorModal.userStatsTab.addAttributeButton": "添加属性",
"template.trackerEditorModal.userStatsTab.statusSectionTitle": "状态部分",
"template.trackerEditorModal.userStatsTab.enableStatusSection": "启用状态部分",
"template.trackerEditorModal.userStatsTab.showMoodEmoji": "显示心情表情",
"template.trackerEditorModal.userStatsTab.statusFieldsLabel": "状态字段(逗号分隔):",
"template.trackerEditorModal.userStatsTab.skillsSectionTitle": "技能部分",
"template.trackerEditorModal.userStatsTab.enableSkillsSection": "启用技能部分",
"template.trackerEditorModal.userStatsTab.skillsLabelLabel": "技能标签:",
"template.trackerEditorModal.userStatsTab.skillsListLabel": "技能列表(逗号分隔):",
"template.trackerEditorModal.infoBoxTab.widgetsTitle": "小部件",
"template.trackerEditorModal.infoBoxTab.dateWidget": "日期",
"template.trackerEditorModal.infoBoxTab.weatherWidget": "天气",
"template.trackerEditorModal.infoBoxTab.temperatureWidget": "温度",
"template.trackerEditorModal.infoBoxTab.timeWidget": "时间",
"template.trackerEditorModal.infoBoxTab.locationWidget": "位置",
"template.trackerEditorModal.infoBoxTab.recentEventsWidget": "最近事件",
"template.trackerEditorModal.presentCharactersTab.relationshipStatusTitle": "关系状态字段",
"template.trackerEditorModal.presentCharactersTab.enableRelationshipStatus": "启用关系状态字段",
"template.trackerEditorModal.presentCharactersTab.relationshipStatusHint": "定义关系类型,并在角色肖像上显示相应的表情符号。",
"template.trackerEditorModal.presentCharactersTab.newRelationshipButton": "新关系",
"template.trackerEditorModal.presentCharactersTab.appearanceDemeanorTitle": "外观/举止字段",
"template.trackerEditorModal.presentCharactersTab.appearanceDemeanorHint": "显示在角色名字下方的字段。",
"template.trackerEditorModal.presentCharactersTab.addCustomFieldButton": "添加自定义字段",
"template.trackerEditorModal.presentCharactersTab.thoughtsConfigTitle": "想法配置",
"template.trackerEditorModal.presentCharactersTab.enableCharacterThoughts": "启用角色想法",
"template.trackerEditorModal.presentCharactersTab.thoughtsLabelLabel": "想法标签:",
"template.trackerEditorModal.presentCharactersTab.aiInstructionLabel": "AI 指令:",
"template.trackerEditorModal.presentCharactersTab.characterStatsTitle": "角色数值",
"template.trackerEditorModal.presentCharactersTab.trackCharacterStats": "跟踪角色数值",
"template.trackerEditorModal.presentCharactersTab.characterStatsHint": "为每个角色创建要跟踪的数值(显示为彩色数字)。",
"template.trackerEditorModal.presentCharactersTab.addCharacterStatButton": "添加角色数值",
"template.mainPanel.title": "RPG Companion",
"template.mainPanel.lastRoll": "上次掷骰:",
"template.mainPanel.clearLastRoll": "清除上次掷骰",
"template.mainPanel.immersiveHtml": "沉浸式 HTML",
"template.mainPanel.coloredDialogues": "彩色对话",
"template.mainPanel.deceptionSystem": "欺骗系统",
"template.mainPanel.omniscienceFilter": "全知过滤器",
"template.mainPanel.cyoa": "选择冒险",
"template.mainPanel.spotifyMusic": "Spotify 音乐",
"template.mainPanel.snowflakesEffect": "雪花效果",
"template.mainPanel.dynamicWeatherEffects": "动态天气",
"template.mainPanel.narratorMode": "旁白模式",
"template.mainPanel.refreshRpgInfo": "刷新 RPG 信息",
"template.mainPanel.updating": "更新中...",
"template.mainPanel.editTrackersButton": "编辑跟踪器",
"template.mainPanel.settingsButton": "设置",
"global.none": "无",
"global.add": "添加",
"global.cancel": "取消",
"global.listView": "列表视图",
"global.gridView": "网格视图",
"global.save": "保存",
"global.status": "状态",
"global.inventory": "物品栏",
"global.quests": "任务",
"global.info": "信息",
"global.removeItem": "移除物品",
"global.clickToEdit": "点击编辑",
"global.collapseExpandPanel": "折叠/展开面板",
"global.refreshRpgInfo": "刷新RPG信息",
"global.showHideApiKey": "显示/隐藏API密钥",
"global.closeDialog": "关闭对话框",
"infobox.noData.title": "尚无数据",
"infobox.noData.instruction": "在角色扮演中生成新响应,或在设置中切换到“单独生成”以访问并点击“刷新 RPG 信息”按钮",
"infobox.recentEvents.title": "最近事件",
"infobox.recentEvents.addEventPlaceholder": "添加事件...",
"inventory.section.onPerson": "随身携带",
"inventory.section.clothing": "衣物",
"inventory.section.stored": "存储",
"inventory.section.assets": "资产",
"inventory.onPerson.empty": "未携带任何物品",
"inventory.onPerson.title": "当前携带的物品",
"inventory.onPerson.addItemButton": "添加物品",
"inventory.onPerson.addItemPlaceholder": "输入物品名称...",
"inventory.clothing.empty": "未穿戴任何衣物",
"inventory.clothing.title": "衣物和护甲",
"inventory.clothing.addItemButton": "添加衣物",
"inventory.clothing.addItemPlaceholder": "输入衣物物品...",
"inventory.stored.title": "存储位置",
"inventory.stored.addLocationButton": "添加位置",
"inventory.stored.addLocationPlaceholder": "输入位置名称...",
"inventory.stored.saveButton": "保存",
"inventory.stored.empty": "尚无存储位置。点击“添加位置”以创建一个。",
"inventory.stored.noItems": "此处未存储任何物品",
"inventory.stored.addItemToLocationPlaceholder": "输入物品名称...",
"inventory.stored.addItemButton": "添加物品",
"inventory.stored.confirmRemoveLocationMessage": "删除“${location}”?这将删除存储在该处的所有物品。",
"inventory.stored.confirmRemoveLocationConfirmButton": "确认",
"inventory.assets.empty": "未拥有任何资产",
"inventory.assets.title": "车辆、财产和主要所有物",
"inventory.assets.addAssetModalTitle": "添加资产",
"inventory.assets.addAssetButton": "添加资产",
"inventory.assets.addAssetPlaceholder": "输入资产名称...",
"inventory.assets.description": "资产包括车辆(汽车、摩托车)、财产(房屋、公寓)和主要设备(车间工具、特殊物品)。",
"inventory.onPerson.addItemTitle": "添加新物品",
"inventory.clothing.addItemTitle": "添加新衣物",
"inventory.stored.addLocationTitle": "添加新存储位置",
"inventory.stored.addItemToLocationTitle": "添加物品到此位置",
"inventory.stored.removeLocationTitle": "移除此存储位置",
"inventory.assets.addItemTitle": "添加新资产",
"inventory.assets.removeAssetTitle": "移除资产",
"quests.section.main": "主要任务",
"quests.section.optional": "可选任务",
"quests.main.title": "主要任务",
"quests.main.addQuestButton": "添加任务",
"quests.main.addQuestPlaceholder": "输入主要任务标题...",
"quests.main.empty": "无活跃的主要任务",
"quests.main.hint": "主要任务代表您在故事中的主要目标。",
"quests.optional.title": "可选任务",
"quests.optional.addQuestButton": "添加任务",
"quests.optional.addQuestPlaceholder": "输入可选任务标题...",
"quests.optional.empty": "无活跃的可选任务",
"quests.optional.hint": "可选任务是补充您主要故事的次要目标。",
"quests.editQuestTitle": "编辑任务",
"quests.removeQuestTitle": "完成/移除任务",
"checkpoint.setChapterStart": "设置章节开始",
"checkpoint.clearChapterStart": "清除章节开始",
"checkpoint.indicator": "章节开始",
"checkpoint.tooltip": "此点之前的消息从上下文中排除",
"musicPlayer.title": "场景音乐",
"musicPlayer.noMusic": "AI 将在适合场景时推荐音乐",
"errors.parsingError": "RPG Companion Trackers 解析错误!模型返回了错误的格式。如果问题持续存在,请考虑更换生成模型。",
"settings.recommendedModels.title": "推荐模型",
"settings.recommendedModels.description": "为使扩展正常工作,**不建议使用任何低于 20B 的模型,尤其是旧模型。** 它与 SOTA 模型(如 Deepseek、Claude、GPT 或 Gemini)配合最佳。",
"thoughts.addCharacter": "添加角色",
"thoughts.locked": "已锁定",
"thoughts.unlocked": "已解锁",
"thoughts.clickToEdit": "点击编辑",
"thoughts.clickToUpload": "点击上传头像",
"thoughts.removeCharacter": "移除角色",
"thoughts.empty": "尚无角色数据生成",
"userStats.level": "LVL",
"userStats.clickToEditLevel": "点击编辑等级",
"userStats.statsLocked": "已锁定 - AI 无法更改数值",
"userStats.statsUnlocked": "已解锁 - AI 可以更改数值",
"userStats.clickToEditStatName": "点击编辑数值名称",
"userStats.clickToEditStatValue": "点击编辑",
"userStats.moodLocked": "已锁定 - AI 无法更改心情",
"userStats.moodUnlocked": "已解锁 - AI 可以更改心情",
"userStats.clickToEditEmoji": "点击编辑表情",
"userStats.skillsLocked": "已锁定 - AI 无法更改技能",
"userStats.skillsUnlocked": "已解锁 - AI 可以更改技能",
"userStats.clickToEditSkills": "点击编辑技能",
"userStats.empty": "尚无数值生成",
"infoBox.clickToEdit": "点击编辑",
"infoBox.locked": "已锁定 - AI 无法更改此项",
"infoBox.unlocked": "已解锁 - AI 可以更改此项",
"infoBox.weatherFallback": "天气",
"infoBox.locationFallback": "位置",
"stats.health": "Health",
"stats.satiety": "Satiety",
"stats.energy": "Energy",
"stats.hygiene": "Hygiene",
"stats.arousal": "Arousal",
"stats.str": "STR",
"stats.dex": "DEX",
"stats.con": "CON",
"stats.int": "INT",
"stats.wis": "WIS",
"stats.cha": "CHA",
"stats.displayMode": "显示模式:",
"stats.displayMode.percentage": "百分比",
"stats.displayMode.number": "数值",
"dice.title": "掷骰子",
"dice.numberOfDice": "骰子数量:",
"dice.diceType": "骰子类型:",
"dice.rolling": "掷骰中...",
"dice.result": "结果:",
"dice.saveRoll": "保存掷骰",
"preset.createNewPresetTitle": "创建新预设",
"preset.deleteCurrentPresetTitle": "删除当前预设",
"preset.setDefaultPresetTitle": "设为默认预设",
"preset.defaultPresetDescription": "这是默认预设",
"preset.label": "预设:",
"preset.useThisPresetFor": "将此预设用于:",
"stats.showLevel": "显示等级",
"dateFormat.weekdayMonthYear": "星期,月份,年份",
"dateFormat.dayNumericalMonthYear": "日(数字),月份,年份",
"historyPersistence.tabTitle": "历史持久性",
"historyPersistence.settingsTitle": "历史持久性设置",
"historyPersistence.enable": "启用历史持久性",
"template.trackerEditorModal.tabs.historyPersistence": "历史持久性",
"historyPersistence.hint": "将选定的跟踪器数据注入历史消息中,帮助AI保持时间敏感事件、天气变化和位置跟踪的连续性。",
"historyPersistence.sendAllEnabledStats": "刷新时发送所有启用的数值",
"historyPersistence.sendAllEnabledStatsHint": "启用后,刷新RPG信息将在历史上下文中包含预设中的所有启用数值,忽略下面的单个选择。",
"historyPersistence.numberOfMessages": "包含的消息数量(0 = 所有可用):",
"historyPersistence.injectionPosition": "注入位置:",
"historyPersistence.injectionPosition.userMessageEnd": "用户消息末尾",
"historyPersistence.injectionPosition.assistantMessageEnd": "助手消息末尾",
"historyPersistence.customContextPreamble": "自定义上下文前言:",
"historyPersistence.customContextPreamblePlaceholder": "该时刻的上下文:",
"historyPersistence.userStatsSection": "用户数值",
"historyPersistence.userStatsHint": "选择哪些数值应包含在历史消息中。",
"historyPersistence.statusSection": "状态(心情/状况)",
"historyPersistence.inventory": "物品栏",
"historyPersistence.quests": "任务",
"historyPersistence.infoBoxSection": "信息框",
"historyPersistence.infoBoxHint": "选择哪些信息框字段应包含在历史消息中。这些字段推荐用于时间跟踪。",
"historyPersistence.presentCharactersSection": "当前角色",
"historyPersistence.presentCharactersHint": "选择哪些角色字段应包含在历史消息中。",
"historyPersistence.widget.date": "日期",
"historyPersistence.widget.weather": "天气",
"historyPersistence.widget.temperature": "温度",
"historyPersistence.widget.time": "时间",
"historyPersistence.widget.location": "位置",
"historyPersistence.widget.recentEvents": "近期事件",
"historyPersistence.thoughts": "想法",
"historyPersistence.skills": "技能",
"template.promptsEditor.button": "自定义提示",
"template.promptsEditor.buttonNote": "编辑用于生成、剧情推进和战斗遭遇的所有AI提示。",
"template.promptsEditor.title": "自定义提示",
"template.promptsEditor.description": "自定义整个扩展中使用的AI提示。留空字段以使用默认值。",
"template.promptsEditor.restoreDefault": "恢复默认",
"template.promptsEditor.htmlPrompt.title": "HTML提示",
"template.promptsEditor.htmlPrompt.note": "当“启用沉浸式HTML”启用时注入。影响所有生成模式。",
"template.promptsEditor.dialogueColoringPrompt.title": "对话着色提示",
"template.promptsEditor.dialogueColoringPrompt.note": "当“启用彩色对话”启用时注入。影响所有生成模式。",
"template.promptsEditor.deceptionPrompt.title": "欺骗系统提示",
"template.promptsEditor.deceptionPrompt.note": "当“启用欺骗系统”启用时注入。指示AI用隐藏标签标记谎言和欺骗行为。",
"template.promptsEditor.omnisciencePrompt.title": "全知过滤器提示",
"template.promptsEditor.omnisciencePrompt.note": "当“启用全知过滤器”启用时注入。指示AI将玩家角色无法感知的信息分离到隐藏的过滤器标签中。",
"template.promptsEditor.cyoaPrompt.title": "选择冒险提示",
"template.promptsEditor.cyoaPrompt.note": "当“启用选择冒险”启用时注入。指示AI在回复结尾提供带编号的动作选项。使用非常高的优先级(深度102)确保它是最后一条指令。",
"template.promptsEditor.spotifyPrompt.title": "Spotify音乐提示",
"template.promptsEditor.spotifyPrompt.note": "当“启用Spotify音乐”启用时注入。要求AI为场景推荐合适的音乐。",
"template.promptsEditor.narratorPrompt.title": "旁白模式提示",
"template.promptsEditor.narratorPrompt.note": "当“旁白模式”启用时注入。指示AI从上下文中推断角色信息。",
"template.promptsEditor.contextPrompt.title": "上下文指令提示",
"template.promptsEditor.contextPrompt.note": "在Separate/External模式中,上下文摘要后注入。告诉AI如何使用上下文。",
"template.promptsEditor.randomPlotPrompt.title": "随机剧情推进提示",
"template.promptsEditor.randomPlotPrompt.note": "当点击“随机剧情”按钮时注入。为故事引入随机元素。",
"template.promptsEditor.naturalPlotPrompt.title": "自然剧情推进提示",
"template.promptsEditor.naturalPlotPrompt.note": "当点击“自然剧情”按钮时注入。自然地推进故事发展。",
"template.promptsEditor.avatarPrompt.title": "头像生成指令",
"template.promptsEditor.avatarPrompt.note": "生成头像图像提示时给LLM的指令。用于“自动生成缺失头像”功能。",
"template.promptsEditor.trackerPrompt.title": "跟踪器指令",
"template.promptsEditor.trackerPrompt.note": "仅指令部分(格式规范已硬编码)。{userName}将被替换为用户名称。",
"template.promptsEditor.trackerContinuationPrompt.title": "跟踪器延续指令",
"template.promptsEditor.trackerContinuationPrompt.note": "在跟踪器格式规范后添加的指令,告诉AI如何继续叙事。",
"template.promptsEditor.combatPrompt.title": "战斗叙事风格指令",
"template.promptsEditor.combatPrompt.note": "战斗遭遇的写作风格指令。包括散文质量指南和防重复规则。{userName}将被替换为用户名称。",
"template.settingsModal.mobileFabTitle": "移动按钮小部件",
"template.settingsModal.mobileFabNote": "在移动设备上显示围绕浮动按钮的紧凑信息小部件。小部件自动定位。",
"template.settingsModal.mobileFab.enabled": "启用浮动移动小部件",
"template.settingsModal.mobileFab.enabledNote": "主开关,用于在移动浮动按钮周围显示信息小部件。",
"template.settingsModal.mobileFab.weatherIcon": "天气图标",
"template.settingsModal.mobileFab.weatherDesc": "天气描述",
"template.settingsModal.mobileFab.clock": "时间/时钟",
"template.settingsModal.mobileFab.date": "日期",
"template.settingsModal.mobileFab.location": "位置",
"template.settingsModal.mobileFab.stats": "数值(生命值、能量等)",
"template.settingsModal.mobileFab.attributes": "RPG属性(力量、敏捷等)",
"template.settingsModal.desktopStripTitle": "桌面折叠面板条小部件",
"template.settingsModal.desktopStripNote": "在桌面上的折叠面板条中显示紧凑信息小部件。垂直显示数值,无需展开面板。",
"template.settingsModal.desktopStrip.enabled": "启用面板条小部件",
"template.settingsModal.desktopStrip.enabledNote": "在折叠面板条中显示小部件,以便快速访问数值。",
"template.settingsModal.desktopStrip.weatherIcon": "天气图标",
"template.settingsModal.desktopStrip.clock": "时间/时钟",
"template.settingsModal.desktopStrip.date": "日期",
"template.settingsModal.desktopStrip.location": "位置",
"template.settingsModal.desktopStrip.stats": "数值(生命值、能量等)",
"template.settingsModal.desktopStrip.attributes": "RPG属性(力量、敏捷等)",
"plotProgression.buttons.randomizedPlot": "随机化剧情",
"plotProgression.buttons.naturalPlot": "自然剧情",
"plotProgression.buttons.enterEncounter": "进入遭遇战",
"plotProgression.tooltips.randomizedPlot": "生成随机剧情转折或事件",
"plotProgression.tooltips.naturalPlot": "无转折地自然延续故事",
"plotProgression.tooltips.enterEncounter": "进入战斗遭遇",
"encounter.configModal.title": "配置战斗叙事",
"encounter.configModal.combatNarrativeStyle": "战斗叙事风格",
"encounter.configModal.combatSummaryStyle": "战斗总结风格",
"encounter.configModal.labels.tense": "时态:",
"encounter.configModal.labels.person": "人称:",
"encounter.configModal.labels.narration": "叙述:",
"encounter.configModal.labels.pointOfView": "视角:",
"encounter.configModal.options.present": "现在时",
"encounter.configModal.options.past": "过去时",
"encounter.configModal.options.firstPerson": "第一人称",
"encounter.configModal.options.secondPerson": "第二人称",
"encounter.configModal.options.thirdPerson": "第三人称",
"encounter.configModal.options.omniscient": "全知视角",
"encounter.configModal.options.limited": "有限视角",
"encounter.configModal.placeholders.narrator": "叙述者",
"encounter.configModal.rememberSettings": "记住这些设置以供未来遭遇使用",
"encounter.configModal.buttons.proceed": "继续",
"encounter.ui.concludeEncounterTitle": "提前结束遭遇",
"encounter.ui.closeTitle": "关闭(结束战斗)",
"encounter.ui.initializingCombat": "正在初始化战斗...",
"encounter.ui.initializingCombatEncounter": "正在初始化战斗遭遇...",
"encounter.ui.combatBegins": "战斗开始!",
"encounter.ui.allEnemies": "所有敌人",
"encounter.ui.areaOfEffect": "范围效果",
"encounter.ui.youHaveBeenDefeated": "你已被击败...",
"encounter.ui.attacks": "攻击",
"encounter.ui.items": "物品",
"encounter.ui.customAction": "自定义动作",
"encounter.ui.customActionPlaceholder": "描述你想要做什么...",
"encounter.ui.generatingCombatSummary": "正在生成战斗总结...",
"encounter.ui.pleaseWait": "请稍候...",
"encounter.ui.failedToCreateSummary": "无法创建总结。你可以关闭此窗口。",
"encounter.ui.wrongFormatDetected": "检测到错误格式",
"encounter.ui.concludeEncounterButton": "结束遭遇",
"encounter.ui.combatEncounterTitle": "战斗遭遇",
"encounter.ui.errorGeneratingCombatSummary": "生成战斗总结时出错。",
"encounter.ui.closeCombatWindow": "关闭战斗窗口",
"encounter.ui.combatLog": "战斗日志",
"encounter.ui.selectTarget": "选择目标",
"encounter.ui.submit": "提交",
"encounter.ui.regenerate": "重新生成",
"encounter.ui.or": "或",
"encounter.ui.result.victory": "胜利",
"encounter.ui.result.defeat": "失败",
"encounter.ui.result.fled": "逃跑",
"encounter.ui.result.interrupted": "中断",
"encounter.ui.error.noResponse": "未收到AI响应。模型可能不可用。",
"encounter.ui.error.invalidJsonFormat": "检测到无效的JSON格式。AI返回了格式错误的数据。请确保最大响应长度至少设置为2048个token,否则模型可能会用完token并产生不完整的结构。",
"encounter.ui.error.failedToInitialize": "初始化战斗失败:",
"encounter.ui.error.errorProcessingAction": "处理动作时出错:",
"encounter.ui.combatSummaryAddedBy": "战斗总结已由{speakerName}添加到聊天。",
"encounter.ui.combatSummaryAdded": "战斗总结已添加到聊天。",
"encounter.ui.environment.default": "战斗竞技场",
"encounter.ui.enemiesTitle": "敌人",
"encounter.ui.partyTitle": "队伍",
"encounter.ui.hpSuffix": " HP",
"encounter.ui.playerSuffix": "(你)",
"encounter.ui.confirmConcludeEarly": "提前结束遭遇战并生成总结?",
"encounter.ui.confirmEndCombat": "确定要结束这场战斗遭遇战吗?",
"encounter.ui.enemyDefaultEmoji": "👹",
"encounter.ui.yourActions": "你的行动",
"encounter.ui.attackType.aoe": "范围效果",
"encounter.ui.attackType.both": "单体或范围",
"encounter.ui.attackType.single": "单体目标",
"encounter.ui.targetingAllEnemies": " targeting all enemies!",
"encounter.ui.on": " on ",
"encounter.ui.youPrefix": "你: ",
"global.locked": "已锁定",
"global.unlocked": "已解锁",
"global.confirm": "确认",
"inventory.addItemPlaceholder": "输入物品名称...",
"inventory.stored.removeLocationConfirm": "删除\"{location}\"?这将删除该位置存储的所有物品。",
"userStats.clickToEdit": "点击编辑",
"quests.main.addQuestTitle": "添加主线任务",
"quests.optional.addQuestTitle": "添加可选任务"
}
+211
View File
@@ -0,0 +1,211 @@
{
"settings.language.label": "語言",
"settings.language.option.en": "English",
"settings.language.option.zh-cn": "简体中文",
"settings.language.option.zh-tw": "繁體中文",
"settings.language.option.ru": "Русский",
"settings.language.option.fr": "Français",
"settings.extensionEnabled": "啟用 RPG Companion",
"settings.note": "切換開關以啟用/停用 RPG Companion。其他設定可在面板內配置。",
"template.settingsTitle": "RPG Companion 設定",
"template.settingsModal.themeTitle": "主題",
"template.settingsModal.themeLabel": "可選主題:",
"template.settingsModal.themeOptions.default": "預設",
"template.settingsModal.themeOptions.sciFi": "科幻 (合成波)",
"template.settingsModal.themeOptions.fantasy": "奇幻 (古樸羊皮紙)",
"template.settingsModal.themeOptions.cyberpunk": "賽博朋克 (霓虹網格)",
"template.settingsModal.themeOptions.custom": "自訂",
"template.settingsModal.themeOptions.custom.background": "背景:",
"template.settingsModal.themeOptions.custom.accent": "強調色:",
"template.settingsModal.themeOptions.custom.text": "文字:",
"template.settingsModal.themeOptions.custom.highlight": "高亮:",
"template.settingsModal.theme.statBarLow": "屬性條顏色 (低)",
"template.settingsModal.theme.statBarLowNote": "屬性在 0% 時的顏色",
"template.settingsModal.theme.statBarHigh": "屬性條顏色 (高)",
"template.settingsModal.theme.statBarHighNote": "屬性在 100% 時的顏色",
"template.settingsModal.displayTitle": "顯示設定",
"template.settingsModal.displayNote": "使用擴充功能標籤來啟用/停用 RPG Companion 擴充功能。",
"template.settingsModal.display.panelPosition": "面板位置:",
"template.settingsModal.display.panelPositionOptions.right": "右側邊欄",
"template.settingsModal.display.panelPositionOptions.left": "左側邊欄",
"template.settingsModal.display.toggleAutoUpdate": "訊息後自動更新",
"template.settingsModal.display.showUserStats": "顯示 user 屬性",
"template.settingsModal.display.showInfoBox": "顯示資訊框",
"template.settingsModal.display.showPresentCharacters": "顯示在場角色",
"template.settingsModal.display.showBelowChatPresentCharacters": "顯示聊天下方的在場角色",
"template.settingsModal.display.showBelowChatPresentCharactersNote": "在聊天下方顯示精簡的在場角色面板。",
"template.settingsModal.display.thoughtBasedExpressions": "基於想法的表情",
"template.settingsModal.display.thoughtBasedExpressionsNote": "使用 SillyTavern Character Expressions 對聊天下方面板中每個在場角色的想法進行分類。Token 用量可能會依所選的分類 API 而增加。",
"template.settingsModal.display.hideDefaultExpressionDisplay": "隱藏預設表情顯示",
"template.settingsModal.display.hideDefaultExpressionDisplayNote": "隱藏 SillyTavern 內建的角色表情顯示。",
"template.settingsModal.display.showInventory": "顯示物品欄",
"template.settingsModal.display.showLockIcons": "顯示鎖定/解鎖追蹤器",
"template.settingsModal.display.showLockIconsNote": "在追蹤器項目上顯示鎖定/解鎖圖示,以防止 AI 修改它們。",
"template.settingsModal.display.showThoughtsInChat": "在聊天中顯示想法",
"template.settingsModal.display.showThoughtsInChatNote": "將角色想法顯示為其訊息旁的泡泡",
"template.settingsModal.display.showInlineThoughts": "在訊息文字下方顯示想法",
"template.settingsModal.display.showInlineThoughtsNote": "在預設角落想法泡泡與顯示在訊息文字下方的想法卡片之間切換。",
"template.settingsModal.display.alwaysShowThoughtBubble": "始終顯示想法泡泡",
"template.settingsModal.display.alwaysShowThoughtBubbleNote": "自動展開想法泡泡",
"template.settingsModal.display.enableAnimations": "啟用動畫",
"template.settingsModal.display.enableAnimationsNote": "屬性、內容更新和擲骰的動畫效果",
"template.settingsModal.display.showImmersiveHtmlToggle": "顯示沉浸式 HTML",
"template.settingsModal.display.showDialogueColoringToggle": "顯示彩色對話",
"template.settingsModal.display.showDialogueColoringToggleNote": "顯示一個切換按鈕以啟用/停用彩色對話格式。",
"template.settingsModal.display.showSpotifyMusicToggle": "顯示 Spotify 音樂",
"template.settingsModal.display.showSnowflakesToggle": "顯示雪花效果", "template.settingsModal.display.showDynamicWeatherToggle": "顯示動態天氣效果", "template.settingsModal.display.showNarratorMode": "顯示旁白模式", "template.settingsModal.display.showNarratorModeNote": "顯示切換按鈕以啟用/停用旁白模式", "template.settingsModal.display.showAutoAvatars": "顯示自動生成頭像", "template.settingsModal.display.showAutoAvatarsNote": "顯示切換按鈕以自動為沒有圖片的角色生成頭像", "template.settingsModal.display.showPlotProgressionButtons": "顯示劇情推進按鈕(QR",
"template.settingsModal.display.showPlotProgressionButtonsNote": "在聊天輸入框上方顯示劇情推進提示按鈕(QR)",
"template.settingsModal.advancedTitle": "進階",
"template.settingsModal.advanced.generationMode": "生成模式:",
"template.settingsModal.advanced.generationModeOptions.together": "同時生成",
"template.settingsModal.advanced.generationModeOptions.separate": "單獨生成",
"template.settingsModal.advanced.generationModeNote": "同時生成:將 RPG 追蹤添加到主要提示詞中一同生成。單獨生成:分開生成 RPG 數據。(就是手動或自動的差別)。外部 API:直接連接 OpenAI 兼容端點生成數據。",
"template.settingsModal.advanced.generationModeOptions.external": "外部 API",
"template.settingsModal.advanced.externalApi.title": "外部 API 設定",
"template.settingsModal.advanced.externalApi.baseUrl": "API 基礎 URL",
"template.settingsModal.advanced.externalApi.baseUrlNote": "OpenAI 兼容端點(例如 OpenAI、OpenRouter、本地 LLM 伺服器)",
"template.settingsModal.advanced.externalApi.apiKey": "API 金鑰",
"template.settingsModal.advanced.externalApi.apiKeyNote": "外部服務的 API 金鑰",
"template.settingsModal.advanced.externalApi.model": "模型",
"template.settingsModal.advanced.externalApi.modelNote": "模型識別碼(例如 gpt-4o-mini、claude-3-haiku、mistral-7b",
"template.settingsModal.advanced.externalApi.maxTokens": "最大 Token",
"template.settingsModal.advanced.externalApi.temperature": "溫度 (Temperature)",
"template.settingsModal.advanced.externalApi.testConnection": "測試連接",
"template.settingsModal.advanced.contextMessages": "上下文訊息:",
"template.settingsModal.advanced.contextMessagesNote": "包含的最近訊息數量(僅限單獨生成模式)",
"template.settingsModal.advanced.memoryBatchSize": "記憶批次大小:",
"template.settingsModal.advanced.memoryBatchSizeNote": "在記憶回憶中每批處理的訊息數量",
"template.settingsModal.advanced.useSeparatePreset": "使用 RPG Companion 追蹤預設模型(設置次要模型)",
"template.settingsModal.advanced.useSeparatePresetNote": "僅限單獨生成模式。啟用後將使用“RPG Companion Trackers”預設中綁定的模型,而不是您的主要 API 模型。生成期間會自動切換預設,之後會恢復原使用預設。請在“RPG Companion Trackers”預設中選擇次要模型,並確保“將預設綁定到 API 連接”切換已開啟(在導入/導出預設按鈕旁邊)。",
"template.settingsModal.advanced.skipInjections": "在引導生成期間跳過注入:",
"template.settingsModal.advanced.skipInjectionsOptions.none": "從不跳過",
"template.settingsModal.advanced.skipInjectionsOptions.impersonation": "僅在模擬請求時跳過",
"template.settingsModal.advanced.skipInjectionsOptions.guided": "始終跳過引導",
"template.settingsModal.advanced.skipInjectionsNote": "當設置後,擴充功能在檢測到引導生成(通過 `instruct` 或 `quiet_prompt`)時,將根據所選模式不注入追蹤提示詞、範例或 HTML 指令。當與 GuidedGenerations 或類似擴充功能一起使用時非常有用。",
"template.settingsModal.advanced.customHtmlPromptTitle": "自訂 HTML 提示詞:",
"template.settingsModal.advanced.restoreDefaultHtmlPrompt": "恢復預設",
"template.settingsModal.advanced.customHtmlPromptNote": "自訂啟用“啟用沉浸式 HTML”時注入的 HTML 提示詞。上方顯示預設提示詞 - 您可以直接編輯或完全替換它。點擊“恢復預設”以重置。這會影響所有生成模式(同時、單獨和劇情推進)。",
"template.settingsModal.advanced.clearCache": "清除擴充功能快取",
"template.settingsModal.advanced.resetFabPositions": "重置按鈕位置",
"template.settingsModal.advanced.resetFabPositionsNote": "將所有浮動操作按鈕(切換、刷新、調試)重置為預設的左上位置。如果按鈕在螢幕外,這會很有用。",
"template.trackerEditorModal.title": "追蹤器編輯",
"template.trackerEditorModal.tabs.userStats": "User 屬性",
"template.trackerEditorModal.tabs.infoBox": "資訊框",
"template.trackerEditorModal.tabs.presentCharacters": "在場角色",
"template.trackerEditorModal.buttons.reset": "重置",
"template.trackerEditorModal.buttons.cancel": "取消",
"template.trackerEditorModal.buttons.save": "保存並應用",
"template.trackerEditorModal.buttons.export": "匯出",
"template.trackerEditorModal.buttons.import": "匯入",
"template.trackerEditorModal.messages.exportSuccess": "追蹤器預設匯出成功!",
"template.trackerEditorModal.messages.exportError": "匯出追蹤器預設失敗。請檢查控制台以獲取詳細資訊。",
"template.trackerEditorModal.messages.importSuccess": "追蹤器預設匯入成功!",
"template.trackerEditorModal.messages.importError": "匯入追蹤器預設失敗",
"template.trackerEditorModal.messages.importConfirm": "這將替換您當前的追蹤器配置。繼續?",
"template.trackerEditorModal.userStatsTab.customStatsTitle": "自訂屬性",
"template.trackerEditorModal.userStatsTab.addCustomStatButton": "添加自訂屬性",
"template.trackerEditorModal.userStatsTab.rpgAttributesTitle": "RPG 屬性",
"template.trackerEditorModal.userStatsTab.enableRpgAttributes": "啟用 RPG 屬性",
"template.trackerEditorModal.userStatsTab.alwaysIncludeAttributes": "始終發送屬性(prompt",
"template.trackerEditorModal.userStatsTab.alwaysIncludeAttributesNote": "將 RPG 屬性始終包含在提示詞中,即使它們未顯示在面板上也一樣。",
"template.trackerEditorModal.userStatsTab.addAttributeButton": "添加屬性",
"template.trackerEditorModal.userStatsTab.statusSectionTitle": "狀態欄",
"template.trackerEditorModal.userStatsTab.enableStatusSection": "啟用狀態欄",
"template.trackerEditorModal.userStatsTab.showMoodEmoji": "顯示心情emoji",
"template.trackerEditorModal.userStatsTab.statusFieldsLabel": "狀態欄欄位(以逗號分隔):",
"template.trackerEditorModal.userStatsTab.skillsSectionTitle": "技能欄",
"template.trackerEditorModal.userStatsTab.enableSkillsSection": "啟用技能欄",
"template.trackerEditorModal.userStatsTab.skillsLabelLabel": "技能欄標籤:",
"template.trackerEditorModal.userStatsTab.skillsListLabel": " 技能列表(以逗號分隔):",
"template.trackerEditorModal.infoBoxTab.widgetsTitle": "小工具",
"template.trackerEditorModal.infoBoxTab.dateWidget": "日期",
"template.trackerEditorModal.infoBoxTab.weatherWidget": "天氣",
"template.trackerEditorModal.infoBoxTab.temperatureWidget": "溫度",
"template.trackerEditorModal.infoBoxTab.timeWidget": "時間",
"template.trackerEditorModal.infoBoxTab.locationWidget": "位置",
"template.trackerEditorModal.infoBoxTab.recentEventsWidget": "近期事件",
"template.trackerEditorModal.presentCharactersTab.relationshipStatusTitle": "關係狀態",
"template.trackerEditorModal.presentCharactersTab.enableRelationshipStatus": "啟用關係狀態欄位",
"template.trackerEditorModal.presentCharactersTab.relationshipStatusHint": "定義關係類型,並在角色頭像上顯示對應的表情符號",
"template.trackerEditorModal.presentCharactersTab.newRelationshipButton": "新增關係類型",
"template.trackerEditorModal.presentCharactersTab.appearanceDemeanorTitle": "外觀與當前行為舉止",
"template.trackerEditorModal.presentCharactersTab.appearanceDemeanorHint": "角色名稱下方顯示的字段,以 | 分隔。",
"template.trackerEditorModal.presentCharactersTab.addCustomFieldButton": "添加自訂字段",
"template.trackerEditorModal.presentCharactersTab.thoughtsConfigTitle": "內心話配置",
"template.trackerEditorModal.presentCharactersTab.enableCharacterThoughts": "啟用角色內心話",
"template.trackerEditorModal.presentCharactersTab.thoughtsLabelLabel": "內心話標籤:",
"template.trackerEditorModal.presentCharactersTab.aiInstructionLabel": "內心話提示詞:",
"template.trackerEditorModal.presentCharactersTab.characterStatsTitle": "角色屬性",
"template.trackerEditorModal.presentCharactersTab.trackCharacterStats": "啟用角色屬性",
"template.trackerEditorModal.presentCharactersTab.characterStatsHint": "建立統計資料以追蹤每個角色(以彩色長條圖顯示)",
"template.trackerEditorModal.presentCharactersTab.addCharacterStatButton": "添加角色屬性",
"template.mainPanel.title": "RPG Companion",
"template.mainPanel.lastRoll": "上次擲骰:",
"template.mainPanel.clearLastRoll": "清除上次擲骰",
"template.mainPanel.immersiveHtml": "沉浸式 HTML",
"template.mainPanel.coloredDialogues": "彩色對話",
"template.mainPanel.spotifyMusic": "Spotify 音樂",
"template.mainPanel.snowflakesEffect": "雪花效果", "template.mainPanel.dynamicWeatherEffects": "動態天氣", "template.mainPanel.narratorMode": "旁白模式", "template.mainPanel.autoAvatars": "自動頭像", "template.mainPanel.refreshRpgInfo": "刷新資訊",
"template.mainPanel.updating": "更新中...",
"template.mainPanel.editTrackersButton": "追蹤器編輯",
"template.mainPanel.settingsButton": "設定",
"global.none": "None",
"global.add": "添加",
"global.cancel": "取消",
"global.save": "保存",
"global.listView": "清單檢視",
"global.gridView": "格子檢視",
"global.status": "狀態欄",
"global.inventory": "物品欄",
"global.quests": "任務",
"global.info":"資訊",
"infobox.noData.title": "無資訊可顯示",
"infobox.noData.instruction": "在RP中產生新的回复,或在設定中切換到“單獨生成”,然後點擊“刷新資訊”按鈕。",
"infobox.recentEvents.title": "近期事件",
"infobox.recentEvents.addEventPlaceholder": "添加事件...",
"inventory.section.onPerson": "隨身物品",
"inventory.section.clothing": "服裝",
"inventory.section.stored": "倉庫物品",
"inventory.section.assets": "資產",
"inventory.onPerson.empty": "這裡什麼都沒有 (⚲□⚲)",
"inventory.onPerson.title": "攜帶的物品",
"inventory.onPerson.addItemButton": "添加物品",
"inventory.onPerson.addItemPlaceholder": "輸入物品名稱...",
"inventory.clothing.empty": "未穿著任何服裝 (⚲□⚲)",
"inventory.clothing.title": "服裝與護甲",
"inventory.clothing.addItemButton": "添加服裝",
"inventory.clothing.addItemPlaceholder": "輸入服裝物品...",
"inventory.stored.title": "倉庫位置",
"inventory.stored.addLocationButton": "添加倉庫",
"inventory.stored.addLocationPlaceholder": "輸入倉庫名稱...",
"inventory.stored.saveButton": "保存",
"inventory.stored.empty": "沒有倉庫 (⚲□⚲), 點擊\"添加倉庫\"來新增一個倉庫",
"inventory.stored.noItems": "這個倉庫是空的 (⚲□⚲)",
"inventory.stored.addItemToLocationPlaceholder": "輸入物品名稱...",
"inventory.stored.addItemButton": "添加物品",
"inventory.stored.confirmRemoveLocationMessage": "確定要刪除這個倉庫嗎?這將移除所有其中的物品。",
"inventory.stored.confirmRemoveLocationConfirmButton": "刪除",
"inventory.assets.empty": "沒有資產 (⚲□⚲) 好窮",
"inventory.assets.title": "車輛、房產及主要財產",
"inventory.assets.addAssetModalTitle": "添加資產",
"inventory.assets.addAssetButton": "添加資產",
"inventory.assets.addAssetPlaceholder": "輸入資產名稱...",
"inventory.assets.description": "資產包括車輛(汽車、摩托車)、房產(房屋、公寓)和主要設備(車間工具、特殊物品)。",
"quests.section.main": "主線任務",
"quests.section.optional": "支線任務",
"quests.main.title": "主線任務",
"quests.main.addQuestButton": "添加主要任務",
"quests.main.addQuestPlaceholder": "輸入主線任務名稱...",
"quests.main.empty": "當前無主要任務 (ฅ˙Ⱉ˙ฅ)",
"quests.main.hint": "主線任務代表你在故事中的主要目標。",
"quests.optional.title": "支線任務",
"quests.optional.addQuestButton": "添加支線任務",
"quests.optional.addQuestPlaceholder": "輸入支線任務名稱...",
"quests.optional.empty": "當前無支線任務 (ʘ̆ʚʘ̆)",
"quests.optional.hint": "支線任務是補充主線劇情的支線目標。",
"musicPlayer.title": "場景音樂",
"musicPlayer.noMusic": "AI 會在適當時為場景建議音樂",
"errors.parsingError": "RPG Companion 追蹤器解析錯誤!模型返回了不正確的格式。如果問題持續存在,請考慮更換生成模型。",
"settings.recommendedModels.title": "推薦模型",
"settings.recommendedModels.description": "為了讓擴充功能正常運作,**不建議使用任何小於 20B 的模型,尤其是舊模型。**它最適合使用 SOTA 模型,例如 Deepseek、Claude、GPT 或 Gemini。"
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Equipment Constants
* Shared definitions for the equipment system
*/
/**
* Equipment category definitions: maps type to allowed slots, max equipped, and icon
*/
export const EQUIPMENT_CATEGORIES = {
helmet: { slots: ['helmet'], maxEquipped: 1, icon: 'fa-hat-cowboy-side' },
necklace: { slots: ['necklace'], maxEquipped: 1, icon: 'fa-circle-nodes' },
bodyArmor: { slots: ['bodyArmor'], maxEquipped: 1, icon: 'fa-vest' },
gloves: { slots: ['gloves'], maxEquipped: 1, icon: 'fa-hand' },
pants: { slots: ['pants'], maxEquipped: 1, icon: 'fa-socks' },
shoes: { slots: ['shoes'], maxEquipped: 1, icon: 'fa-shoe-prints' },
ring: { slots: ['ring1', 'ring2', 'ring3', 'ring4', 'ring5', 'ring6', 'ring7', 'ring8', 'ring9', 'ring10'], maxEquipped: 10, icon: 'fa-ring' },
accessory: { slots: ['accessory1', 'accessory2', 'accessory3'], maxEquipped: 3, icon: 'fa-gem' }
};
/**
* Flat list of all slots with their category info
*/
export const SLOTS_LIST = Object.entries(EQUIPMENT_CATEGORIES).flatMap(([type, def]) =>
def.slots.map(slotId => ({
id: slotId,
type,
icon: def.icon
}))
);
/**
* Escapes HTML special characters to prevent XSS
* @param {string} text - Text to escape
* @returns {string} Escaped text
*/
export function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
+403
View File
@@ -0,0 +1,403 @@
/**
* Avatar Generator Module
* Handles automatic and manual avatar generation for NPC characters
*
* Features:
* - Batch generation with awaitable completion
* - Batch prompt generation via LLM
* - Individual image generation via /sd command
* - Manual regeneration support
*/
import { characters, this_chid } from '../../../../../../../script.js';
import { safeGenerateRaw } from '../../utils/responseExtractor.js';
import { executeSlashCommandsOnChatInput } from '../../../../../../../scripts/slash-commands.js';
import { selected_group, getGroupMembers } from '../../../../../../group-chats.js';
import { extensionSettings, sessionAvatarPrompts, setSessionAvatarPrompt } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { generateAvatarPromptGenerationPrompt } from '../generation/promptBuilder.js';
import { getCurrentPresetName, switchToPreset, generateWithExternalAPI } from '../generation/apiClient.js';
// Generation state - tracks characters currently being generated
const pendingGenerations = new Set();
/**
* Checks if a character is pending generation (waiting or actively generating)
* @param {string} characterName - Name of character to check
* @returns {boolean} True if generation is pending
*/
export function isGenerating(characterName) {
return pendingGenerations.has(characterName);
}
/**
* Checks if any avatars are currently being generated
* @returns {boolean} True if any generation is in progress
*/
export function isAnyGenerating() {
return pendingGenerations.size > 0;
}
/**
* Gets all characters currently pending generation
* @returns {string[]} Array of character names
*/
export function getPendingGenerations() {
return [...pendingGenerations];
}
/**
* Helper to check if two character names match (case-insensitive, handles partial matches)
* @param {string} cardName - Name from character card
* @param {string} aiName - Name from AI response
* @returns {boolean} True if names match
*/
function namesMatch(cardName, aiName) {
if (!cardName || !aiName) return false;
const cardLower = cardName.toLowerCase().trim();
const aiLower = aiName.toLowerCase().trim();
if (cardLower === aiLower) return true;
const cardCore = cardLower.split(/[\s,'"]+/)[0];
const aiCore = aiLower.split(/[\s,'"]+/)[0];
if (cardCore === aiCore) return true;
const escapedCardCore = cardCore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const wordBoundary = new RegExp(`\\b${escapedCardCore}\\b`);
return wordBoundary.test(aiCore);
}
/**
* Checks if a character already has an avatar (custom NPC avatar or from character card)
* @param {string} characterName - Name of character to check
* @returns {boolean} True if character has an avatar
*/
export function hasExistingAvatar(characterName) {
// Check for custom NPC avatar first
if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[characterName]) {
const avatar = extensionSettings.npcAvatars[characterName];
if (typeof avatar === 'string' && avatar) {
return true;
}
}
// Check group members for avatar
if (selected_group) {
try {
const groupMembers = getGroupMembers(selected_group);
if (groupMembers && groupMembers.length > 0) {
const matchingMember = groupMembers.find(member =>
member && member.name && namesMatch(member.name, characterName)
);
if (matchingMember && matchingMember.avatar && matchingMember.avatar !== 'none') {
return true;
}
}
} catch (e) {
// Ignore errors
}
}
// Check all characters for avatar
if (characters && characters.length > 0) {
const matchingCharacter = characters.find(c =>
c && c.name && namesMatch(c.name, characterName)
);
if (matchingCharacter && matchingCharacter.avatar && matchingCharacter.avatar !== 'none') {
return true;
}
}
// Check current character in 1-on-1 chat
if (this_chid !== undefined && characters[this_chid] &&
characters[this_chid].name && namesMatch(characters[this_chid].name, characterName)) {
if (characters[this_chid].avatar && characters[this_chid].avatar !== 'none') {
return true;
}
}
return false;
}
/**
* Generates avatars for multiple characters and waits for all to complete.
* This is the main entry point for auto-generation within a workflow.
*
* @param {string[]} characterNames - Array of character names to generate avatars for
* @param {Function} onStarted - Optional callback when generation starts (to update UI)
* @returns {Promise<void>} Resolves when all generations complete
*/
export async function generateAvatarsForCharacters(characterNames, onStarted = null) {
if (!extensionSettings.autoGenerateAvatars) {
return;
}
// Filter to characters that need avatars
const needsGeneration = characterNames.filter(name => {
// Skip if already pending
if (pendingGenerations.has(name)) {
return false;
}
// Skip if has avatar
if (hasExistingAvatar(name)) {
return false;
}
return true;
});
if (needsGeneration.length === 0) {
return;
}
// console.log('[RPG Avatar] Starting batch generation for:', needsGeneration);
// Mark all as pending IMMEDIATELY (before any async work)
for (const name of needsGeneration) {
pendingGenerations.add(name);
}
// Trigger UI update to show loading spinners
if (onStarted) {
try {
onStarted([...needsGeneration]);
} catch (e) {
console.error('[RPG Avatar] Error in onStarted callback:', e);
}
}
try {
// Generate images one at a time, generating prompt on demand
for (const characterName of needsGeneration) {
// Skip if somehow already has avatar now
if (hasExistingAvatar(characterName)) {
pendingGenerations.delete(characterName);
continue;
}
// Generate LLM prompt for this character
const prompt = await generateAvatarPrompt(characterName);
// Generate the image using the prompt
await generateSingleAvatar(characterName, prompt);
pendingGenerations.delete(characterName);
// Small delay between generations to avoid overwhelming the API
if (needsGeneration.indexOf(characterName) < needsGeneration.length - 1) {
await new Promise(resolve => setTimeout(resolve, 500));
}
}
} finally {
// Ensure all are removed from pending even if there's an error
for (const name of needsGeneration) {
pendingGenerations.delete(name);
}
}
// console.log('[RPG Avatar] Batch generation complete');
}
/**
* Regenerates avatar for a specific character
* Clears existing avatar and prompt, then generates new ones
* Handles preset switching if useSeparatePreset is enabled
*
* @param {string} characterName - Name of character to regenerate
* @returns {Promise<string|null>} New avatar URL or null if failed
*/
export async function regenerateAvatar(characterName) {
// console.log('[RPG Avatar] Regenerating avatar for:', characterName);
// Mark as pending immediately
pendingGenerations.add(characterName);
// Clear existing avatar
if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[characterName]) {
delete extensionSettings.npcAvatars[characterName];
saveSettings();
}
// Clear existing prompt cache
if (sessionAvatarPrompts[characterName]) {
delete sessionAvatarPrompts[characterName];
}
try {
// Generate new LLM prompt
const prompt = await generateAvatarPrompt(characterName);
// Generate the avatar
return await generateSingleAvatar(characterName, prompt);
} finally {
// Remove from pending when done
pendingGenerations.delete(characterName);
}
}
/**
* Generates an LLM prompt for a single character
*
* @param {string} characterName - Name of character
* @returns {Promise<string|null>} Generated prompt or null if failed
*/
async function generateAvatarPrompt(characterName) {
// Check cache first if not forcing regeneration
if (sessionAvatarPrompts[characterName]) {
return sessionAvatarPrompts[characterName];
}
try {
// console.log('[RPG Avatar] Generating LLM prompt for:', characterName);
const promptMessages = await generateAvatarPromptGenerationPrompt(characterName);
let response;
if (extensionSettings.generationMode === 'external') {
// console.log('[RPG Avatar] Using external API for avatar prompt generation');
response = await generateWithExternalAPI(promptMessages);
} else {
response = await safeGenerateRaw({
prompt: promptMessages,
quietToLoud: false
});
}
if (response) {
const prompt = response.trim();
// console.log(`[RPG Avatar] Generated prompt for ${characterName}:`, prompt);
// Store prompt in session storage
setSessionAvatarPrompt(characterName, prompt);
return prompt;
}
} catch (error) {
console.error(`[RPG Avatar] Failed to generate LLM prompt for ${characterName}:`, error);
}
return null;
}
/**
* Builds a fallback prompt when LLM prompt generation fails or isn't available
* Uses information embedded in the character name if present (e.g., from malformed tracker output)
*
* @param {string} characterName - Character name (may contain additional details)
* @returns {string} A basic prompt for image generation
*/
function buildFallbackPrompt(characterName) {
// Check if the name contains embedded details (malformed format from weaker models)
// e.g., "Eris Details: 🌟 | beautiful girl with white hair | kind expression"
if (characterName.includes('Details:') || characterName.includes('|')) {
// Extract useful description parts
const parts = characterName.split(/Details:|[|]/).map(p => p.trim()).filter(p => p && !p.match(/^[\p{Emoji}]+$/u));
if (parts.length > 1) {
// First part is likely the name, rest are descriptions
const name = parts[0];
const descriptions = parts.slice(1).join(', ');
return `portrait of ${name}, ${descriptions}, fantasy art style, detailed`;
}
}
// Simple fallback - just use the name
return `portrait of ${characterName}, character portrait, fantasy art style, detailed face, high quality`;
}
/**
* Generates a single avatar using the /sd command
*
* @param {string} characterName - Name of character to generate avatar for
* @param {string|null} prompt - The prompt to use (optional, will fallback if null)
* @returns {Promise<string|null>} Avatar URL or null if failed
*/
async function generateSingleAvatar(characterName, prompt = null) {
// Use provided prompt, or check cache, or build fallback
if (!prompt) {
prompt = sessionAvatarPrompts[characterName];
}
if (!prompt) {
// console.log(`[RPG Avatar] No LLM prompt for ${characterName}, using fallback prompt`);
prompt = buildFallbackPrompt(characterName);
}
// console.log(`[RPG Avatar] Starting image generation for: ${characterName}`);
try {
// Execute /sd command with quiet=true to suppress chat output
const result = await executeSlashCommandsOnChatInput(
`/sd quiet=true ${prompt}`,
{ clearChatInput: false }
);
// Extract image URL from result
const imageUrl = extractImageUrl(result);
if (imageUrl) {
// Store the avatar
if (!extensionSettings.npcAvatars) {
extensionSettings.npcAvatars = {};
}
extensionSettings.npcAvatars[characterName] = imageUrl;
saveSettings();
// console.log(`[RPG Avatar] Successfully generated avatar for: ${characterName}`);
return imageUrl;
} else {
console.warn(`[RPG Avatar] Failed to extract image URL for ${characterName}:`, result);
return null;
}
} catch (error) {
console.error(`[RPG Avatar] Generation failed for ${characterName}:`, error);
return null;
}
}
/**
* Extracts image URL from /sd command result
* Handles various result formats
*
* @param {any} result - Result from executeSlashCommandsOnChatInput
* @returns {string|null} Image URL or null
*/
function extractImageUrl(result) {
if (!result) return null;
// Handle string result
if (typeof result === 'string') {
// Validate it looks like a URL or data URI
if (result.startsWith('http') || result.startsWith('data:') || result.startsWith('/')) {
return result;
}
return null;
}
// Handle object result with various possible properties
if (typeof result === 'object') {
// Try common properties
const url = result.pipe || result.output || result.image || result.url || result.result;
if (url && typeof url === 'string') {
if (url.startsWith('http') || url.startsWith('data:') || url.startsWith('/')) {
return url;
}
}
}
return null;
}
/**
* Clears all pending generations and resets state
*/
export function clearPendingGenerations() {
pendingGenerations.clear();
}
/**
* Gets the current generation status for display
* @returns {{pending: number, names: string[]}}
*/
export function getGenerationStatus() {
return {
pending: pendingGenerations.size,
names: [...pendingGenerations]
};
}
+189
View File
@@ -0,0 +1,189 @@
/**
* Chapter Checkpoint Module
* Allows users to mark messages as "chapter start" points to filter context
* Uses SillyTavern's /hide and /unhide commands to exclude messages from context
*/
import { getContext } from '../../../../../../extensions.js';
import { chat_metadata, saveChatDebounced } from '../../../../../../../script.js';
import { executeSlashCommandsOnChatInput } from '../../../../../../../scripts/slash-commands.js';
// Track the message range that is currently hidden
let currentlyHiddenRange = null;
// Debounce restore to prevent loops
let isRestoring = false;
let restoreTimeout = null;
let pendingResolve = null;
/**
* Gets the current chapter checkpoint message ID for the active chat
* @returns {number|null} Message ID of the checkpoint, or null if none set
*/
export function getChapterCheckpoint() {
const context = getContext();
if (!context || !chat_metadata) return null;
return chat_metadata.rpg_companion_chapter_checkpoint || null;
}
/**
* Sets a message as the chapter checkpoint
* Automatically clears any previous checkpoint (only one checkpoint allowed at a time)
* Hides all messages before the checkpoint
* @param {number} messageId - The chat message index to set as checkpoint
* @returns {Promise<boolean>} True if successful
*/
export async function setChapterCheckpoint(messageId) {
const context = getContext();
const chat = context.chat;
if (!chat || messageId < 0 || messageId >= chat.length) {
console.error('[RPG Companion] Invalid message ID for checkpoint:', messageId);
return false;
}
const previousCheckpoint = chat_metadata.rpg_companion_chapter_checkpoint;
// If moving checkpoint, unhide the old range first
if (previousCheckpoint !== null && previousCheckpoint !== undefined && previousCheckpoint !== messageId && currentlyHiddenRange !== null) {
const { start, end } = currentlyHiddenRange;
await executeSlashCommandsOnChatInput(`/unhide ${start}-${end}`, { quiet: true });
// console.log(`[RPG Companion] Unhid previous range: ${start}-${end}`);
}
// Store in chat metadata (this automatically overrides any previous checkpoint)
chat_metadata.rpg_companion_chapter_checkpoint = messageId;
saveChatDebounced();
// Hide all messages before the checkpoint
if (messageId > 0) {
const rangeEnd = messageId - 1;
await executeSlashCommandsOnChatInput(`/hide 0-${rangeEnd}`, { quiet: true });
currentlyHiddenRange = { start: 0, end: rangeEnd };
// console.log(`[RPG Companion] Hidden messages 0-${rangeEnd} (checkpoint at ${messageId})`);
}
if (previousCheckpoint !== null && previousCheckpoint !== undefined && previousCheckpoint !== messageId) {
// console.log(`[RPG Companion] Chapter checkpoint moved from message ${previousCheckpoint} to ${messageId}`);
} else {
// console.log('[RPG Companion] Chapter checkpoint set at message', messageId);
}
// Emit event for UI updates
if (typeof document !== 'undefined') {
const event = new CustomEvent('rpg-companion-checkpoint-changed', {
detail: { messageId, previousCheckpoint }
});
document.dispatchEvent(event);
}
return true;
}
/**
* Clears the chapter checkpoint and unhides all hidden messages
*/
export async function clearChapterCheckpoint() {
if (!chat_metadata) return;
// Unhide any hidden messages
if (currentlyHiddenRange !== null) {
const { start, end } = currentlyHiddenRange;
await executeSlashCommandsOnChatInput(`/unhide ${start}-${end}`, { quiet: true });
// console.log(`[RPG Companion] Unhid messages ${start}-${end}`);
currentlyHiddenRange = null;
}
delete chat_metadata.rpg_companion_chapter_checkpoint;
saveChatDebounced();
// console.log('[RPG Companion] Chapter checkpoint cleared');
// Emit event for UI updates
if (typeof document !== 'undefined') {
const event = new CustomEvent('rpg-companion-checkpoint-changed', {
detail: { messageId: null }
});
document.dispatchEvent(event);
}
}
/**
* Checks if a message is the current checkpoint
* @param {number} messageId - The message index to check
* @returns {boolean} True if this is the checkpoint message
*/
export function isCheckpointMessage(messageId) {
const checkpointId = getChapterCheckpoint();
return checkpointId === messageId;
}
/**
* Restores checkpoint state after page reload or generation events
* Checks if a checkpoint exists and re-applies the /hide command
* Debounced to prevent loops when called from multiple events
*/
export async function restoreCheckpointOnLoad() {
// Prevent concurrent executions
if (isRestoring) {
return;
}
// Clear any pending timeout and resolve the pending promise
if (restoreTimeout) {
clearTimeout(restoreTimeout);
restoreTimeout = null;
}
if (pendingResolve) {
pendingResolve();
pendingResolve = null;
}
// Debounce: wait 100ms before actually restoring
return new Promise((resolve) => {
pendingResolve = resolve;
restoreTimeout = setTimeout(async () => {
isRestoring = true;
try {
const checkpointId = getChapterCheckpoint();
if (checkpointId !== null && checkpointId !== undefined && checkpointId > 0) {
const context = getContext();
const chat = context.chat;
if (chat && checkpointId < chat.length) {
const rangeEnd = checkpointId - 1;
// Check if messages are already hidden
let needsRestore = false;
let hiddenCount = 0;
let visibleCount = 0;
for (let i = 0; i <= rangeEnd; i++) {
if (chat[i]) {
if (chat[i].is_system) {
hiddenCount++;
} else {
visibleCount++;
needsRestore = true;
}
}
}
if (needsRestore) {
await executeSlashCommandsOnChatInput(`/hide 0-${rangeEnd}`, { quiet: true });
currentlyHiddenRange = { start: 0, end: rangeEnd };
// console.log(`[RPG Companion] Restored checkpoint: Hidden messages 0-${rangeEnd}`);
} else {
currentlyHiddenRange = { start: 0, end: rangeEnd };
}
}
}
} finally {
isRestoring = false;
pendingResolve = null;
resolve();
}
}, 100);
});
}
+12 -1
View File
@@ -8,6 +8,7 @@ import {
$userStatsContainer
} from '../../core/state.js';
import { saveSettings, saveChatData } from '../../core/persistence.js';
import { updateFabWidgets } from '../ui/mobile.js';
/**
* Sets up event listeners for classic stat +/- buttons using delegation.
@@ -19,24 +20,34 @@ export function setupClassicStatsButtons() {
// Delegated event listener for increase buttons
$userStatsContainer.on('click', '.rpg-stat-increase', function() {
const stat = $(this).data('stat');
if (extensionSettings.classicStats[stat] < 100) {
// Initialize custom attributes if they don't exist
if (extensionSettings.classicStats[stat] === undefined) {
extensionSettings.classicStats[stat] = 10;
}
if (extensionSettings.classicStats[stat] < 999) {
extensionSettings.classicStats[stat]++;
saveSettings();
saveChatData();
// Update only the specific stat value, not the entire stats panel
$(this).closest('.rpg-classic-stat').find('.rpg-classic-stat-value').text(extensionSettings.classicStats[stat]);
updateFabWidgets();
}
});
// Delegated event listener for decrease buttons
$userStatsContainer.on('click', '.rpg-stat-decrease', function() {
const stat = $(this).data('stat');
// Initialize custom attributes if they don't exist
if (extensionSettings.classicStats[stat] === undefined) {
extensionSettings.classicStats[stat] = 10;
}
if (extensionSettings.classicStats[stat] > 1) {
extensionSettings.classicStats[stat]--;
saveSettings();
saveChatData();
// Update only the specific stat value, not the entire stats panel
$(this).closest('.rpg-classic-stat').find('.rpg-classic-stat-value').text(extensionSettings.classicStats[stat]);
updateFabWidgets();
}
});
}
+18 -4
View File
@@ -9,6 +9,7 @@ import {
setPendingDiceRoll
} from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { i18n } from '../../core/i18n.js';
/**
* Rolls the dice and displays result.
@@ -84,16 +85,29 @@ export async function executeRollCommand(command) {
* Updates the dice display in the sidebar.
*/
export function updateDiceDisplay() {
const lastRoll = extensionSettings.lastDiceRoll;
if (lastRoll) {
$('#rpg-last-roll-text').text(`Last Roll (${lastRoll.formula}): ${lastRoll.total}`);
// Hide the entire dice display if showDiceDisplay is false
const $display = $('#rpg-dice-display');
if (!extensionSettings.showDiceDisplay) {
$display.hide();
return;
} else {
$('#rpg-last-roll-text').text('Last Roll: None');
$display.show();
}
const lastRoll = extensionSettings.lastDiceRoll;
const label = i18n.getTranslation('template.mainPanel.lastRoll') || 'Last Roll: ';
const noneValue = i18n.getTranslation('global.none') || 'None';
if (lastRoll) {
$('#rpg-last-roll-text').text(`${label}(${lastRoll.formula}): ${lastRoll.total}`);
} else {
$('#rpg-last-roll-text').text(label + noneValue);
}
}
/**
* Clears the last dice roll.
* Called when the x button is clicked.
*/
export function clearDiceRoll() {
extensionSettings.lastDiceRoll = null;
+130
View File
@@ -0,0 +1,130 @@
/**
* Encounter State Module
* Manages combat encounter state and history
*/
/**
* Current encounter state
*/
export let currentEncounter = {
active: false,
initialized: false,
combatHistory: [], // Array of {role: 'user'|'assistant'|'system', content: string}
combatStats: null, // Current combat stats (HP, party, enemies, etc.)
preEncounterContext: [], // Messages from before the encounter started
encounterStartMessage: '', // The message that triggered the encounter
encounterLog: [] // Full log of combat actions for final summary
};
/**
* Encounter logs storage (per chat)
*/
export let encounterLogs = {
// chatId: [
// {
// timestamp: Date,
// log: [],
// summary: string,
// result: 'victory'|'defeat'|'fled'
// }
// ]
};
/**
* Sets the current encounter state
* @param {object} encounter - The encounter state object
*/
export function setCurrentEncounter(encounter) {
currentEncounter = encounter;
}
/**
* Updates current encounter state with partial data
* @param {object} updates - Partial encounter state to merge
*/
export function updateCurrentEncounter(updates) {
Object.assign(currentEncounter, updates);
}
/**
* Resets the encounter state
*/
export function resetEncounter() {
currentEncounter = {
active: false,
initialized: false,
combatHistory: [],
combatStats: null,
preEncounterContext: [],
encounterStartMessage: '',
encounterLog: []
};
}
/**
* Adds a message to combat history
* @param {string} role - Message role ('user', 'assistant', or 'system')
* @param {string} content - Message content
*/
export function addCombatMessage(role, content) {
currentEncounter.combatHistory.push({ role, content });
}
/**
* Adds an entry to the encounter log
* @param {string} action - The action taken
* @param {string} result - The result of the action
*/
export function addEncounterLogEntry(action, result) {
currentEncounter.encounterLog.push({
timestamp: Date.now(),
action,
result
});
}
/**
* Saves an encounter log for a specific chat
* @param {string} chatId - The chat identifier
* @param {object} logData - The encounter log data
*/
export function saveEncounterLog(chatId, logData) {
if (!encounterLogs[chatId]) {
encounterLogs[chatId] = [];
}
encounterLogs[chatId].push({
timestamp: new Date(),
log: logData.log || [],
summary: logData.summary || '',
result: logData.result || 'unknown'
});
}
/**
* Gets encounter logs for a specific chat
* @param {string} chatId - The chat identifier
* @returns {Array} Array of encounter logs
*/
export function getEncounterLogs(chatId) {
return encounterLogs[chatId] || [];
}
/**
* Clears all encounter logs for a specific chat
* @param {string} chatId - The chat identifier
*/
export function clearEncounterLogs(chatId) {
if (encounterLogs[chatId]) {
delete encounterLogs[chatId];
}
}
/**
* Exports encounter logs as JSON
* @param {string} chatId - The chat identifier
* @returns {string} JSON string of encounter logs
*/
export function exportEncounterLogs(chatId) {
const logs = getEncounterLogs(chatId);
return JSON.stringify(logs, null, 2);
}
+85 -2
View File
@@ -63,7 +63,7 @@ export async function ensureHtmlCleaningRegex(st_extension_settings, saveSetting
);
if (alreadyExists) {
console.log('[RPG Companion] HTML cleaning regex already exists, skipping import');
// console.log('[RPG Companion] HTML cleaning regex already exists, skipping import');
return;
}
@@ -107,10 +107,93 @@ export async function ensureHtmlCleaningRegex(st_extension_settings, saveSetting
console.warn('[RPG Companion] saveSettingsDebounced is not a function, cannot save HTML regex');
}
console.log('[RPG Companion] ✅ HTML cleaning regex imported successfully');
// console.log('[RPG Companion] ✅ HTML cleaning regex imported successfully');
} catch (error) {
console.error('[RPG Companion] Failed to import HTML cleaning regex:', error);
console.error('[RPG Companion] Error details:', error.message, error.stack);
// Don't throw - this is a nice-to-have feature
}
}
/**
* Automatically imports a regex script to clean tracker JSON from outgoing prompts.
* This is useful when switching from together mode to separate mode mid-roleplay,
* as it prevents old tracker JSON from chat history being sent to the AI.
* @param {Object} st_extension_settings - SillyTavern extension settings object
* @param {Function} saveSettingsDebounced - Function to save settings
*/
export async function ensureTrackerCleaningRegex(st_extension_settings, saveSettingsDebounced) {
try {
// Validate extension settings structure
if (!st_extension_settings || typeof st_extension_settings !== 'object') {
console.warn('[RPG Companion] Invalid extension_settings object, skipping tracker cleaning regex import');
return;
}
// Check if the tracker cleaning regex already exists
const scriptName = 'Clean RPG Trackers (From Outgoing Prompt)';
const existingScripts = st_extension_settings?.regex || [];
// Validate regex array
if (!Array.isArray(existingScripts)) {
console.warn('[RPG Companion] extension_settings.regex is not an array, resetting to empty array');
st_extension_settings.regex = [];
}
const alreadyExists = existingScripts.some(script =>
script && typeof script === 'object' && script.scriptName === scriptName
);
if (alreadyExists) {
// console.log('[RPG Companion] Tracker cleaning regex already exists, skipping import');
return;
}
// Generate a UUID for the script
const uuidv4 = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
// Create the regex script to remove ```json...``` blocks containing tracker data
// This regex matches markdown code blocks with "json" language tag
const regexScript = {
id: uuidv4(),
scriptName: scriptName,
findRegex: '/```json\\s*\\n\\{[\\s\\S]*?(?:\"userStats\"|\"infoBox\"|\"characters\")[\\s\\S]*?\\}\\s*\\n```/gm',
replaceString: '',
trimStrings: [],
placement: [2], // 2 = Input (affects outgoing prompt)
disabled: false,
markdownOnly: false,
promptOnly: true,
runOnEdit: true,
substituteRegex: 0,
minDepth: null,
maxDepth: null
};
// Add to global regex scripts
if (!Array.isArray(st_extension_settings.regex)) {
st_extension_settings.regex = [];
}
st_extension_settings.regex.push(regexScript);
// Save the changes
if (typeof saveSettingsDebounced === 'function') {
saveSettingsDebounced();
} else {
console.warn('[RPG Companion] saveSettingsDebounced is not a function, cannot save tracker cleaning regex');
}
// console.log('[RPG Companion] ✅ Tracker cleaning regex imported successfully');
} catch (error) {
console.error('[RPG Companion] Failed to import tracker cleaning regex:', error);
console.error('[RPG Companion] Error details:', error.message, error.stack);
// Don't throw - this is a nice-to-have feature
}
}
+167
View File
@@ -0,0 +1,167 @@
/**
* JSON Cleaning Module
* Automatically registers a regex script to strip tracker JSON from Together mode output
*/
/**
* Registers an output transformation regex to remove tracker JSON from messages
* This uses SillyTavern's built-in regex system to transform text BEFORE display
* @param {Object} st_extension_settings - SillyTavern extension settings object
* @param {Function} saveSettingsDebounced - Function to save settings
*/
export async function ensureJsonCleaningRegex(st_extension_settings, saveSettingsDebounced) {
try {
// Validate extension settings structure
if (!st_extension_settings || typeof st_extension_settings !== 'object') {
console.warn('[RPG Companion] Invalid extension_settings object, skipping JSON cleaning regex');
return;
}
// Check if the JSON cleaning regex already exists
const scriptName = 'RPG Companion - Remove Tracker JSON (Together Mode)';
const existingScripts = st_extension_settings?.regex || [];
// Validate regex array
if (!Array.isArray(existingScripts)) {
console.warn('[RPG Companion] extension_settings.regex is not an array, resetting to empty array');
st_extension_settings.regex = [];
}
const existingScript = existingScripts.find(script =>
script && script.scriptName && script.scriptName === scriptName
);
if (existingScript) {
// Update existing script with new regex pattern if it's different
const newPattern = '/```(?:json|markdown)?[\\s\\S]*?```/gim';
// Always ensure these properties are set correctly
let needsSave = false;
if (existingScript.findRegex !== newPattern) {
existingScript.findRegex = newPattern;
needsSave = true;
}
if (JSON.stringify(existingScript.placement) !== JSON.stringify([2])) {
existingScript.placement = [2]; // 2 = AI Output
needsSave = true;
}
if (existingScript.disabled !== false) {
existingScript.disabled = false;
needsSave = true;
}
if (existingScript.runOnEdit !== true) {
existingScript.runOnEdit = true;
needsSave = true;
}
if (existingScript.markdownOnly !== true) {
existingScript.markdownOnly = true; // Only process markdown
needsSave = true;
}
if (existingScript.promptOnly !== true) {
existingScript.promptOnly = true; // Enable prompt processing
needsSave = true;
}
if (needsSave && typeof saveSettingsDebounced === 'function') {
// Force immediate save and wait for it
const saveResult = saveSettingsDebounced();
if (saveResult && typeof saveResult.then === 'function') {
await saveResult;
}
// Small delay to ensure save completes
await new Promise(resolve => setTimeout(resolve, 100));
console.log('[RPG Companion] ✅ Updated JSON cleaning regex to v3.2.3 settings.');
} else {
console.log('[RPG Companion] JSON Cleaning Regex is up to date.');
}
return;
}
// Generate a UUID for the script
const uuidv4 = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
// Create the regex script object for cleaning JSON tracker data
// This regex matches ```json...```, ```markdown...```, or plain ```...``` code blocks
// The prompt now explicitly instructs models to use this format
// Updated to handle various whitespace scenarios and ensure it catches all variations
const regexScript = {
id: uuidv4(),
scriptName: scriptName,
// Match ```json...```, ```markdown...```, or ```...``` code blocks (handles spaces, newlines, any content)
// Using a more permissive pattern to catch all variations
findRegex: '/```(?:json|markdown)?[\\s\\S]*?```/gim',
replaceString: '',
trimStrings: [],
placement: [2], // 2 = AI Output
disabled: false,
markdownOnly: true,
promptOnly: true, // Enable prompt processing
runOnEdit: true,
substituteRegex: 0,
minDepth: null,
maxDepth: null
};
// Add to global regex scripts
if (!Array.isArray(st_extension_settings.regex)) {
st_extension_settings.regex = [];
}
st_extension_settings.regex.push(regexScript);
console.log('[RPG Companion] JSON Cleaning Regex created and activated.');
// Save the changes
if (typeof saveSettingsDebounced === 'function') {
saveSettingsDebounced();
} else {
console.warn('[RPG Companion] saveSettingsDebounced is not a function, cannot save JSON cleaning regex');
}
} catch (error) {
console.error('[RPG Companion] JSON Cleaning Regex failed to properly initialize!');
console.error('[RPG Companion] Error details:', error.message, error.stack);
// Don't throw - continue without it
}
}
/**
* Removes the JSON cleaning regex if it exists
* Useful when switching to separate mode or disabling the feature
* @param {Object} st_extension_settings - SillyTavern extension settings object
* @param {Function} saveSettingsDebounced - Function to save settings
*/
export function removeJsonCleaningRegex(st_extension_settings, saveSettingsDebounced) {
try {
if (!st_extension_settings?.regex || !Array.isArray(st_extension_settings.regex)) {
return;
}
const scriptName = 'RPG Companion - Remove Tracker JSON (Together Mode)';
const initialLength = st_extension_settings.regex.length;
st_extension_settings.regex = st_extension_settings.regex.filter(script =>
!script || !script.scriptName || script.scriptName !== scriptName
);
if (st_extension_settings.regex.length < initialLength) {
// console.log('[RPG Companion] Removed JSON cleaning regex');
if (typeof saveSettingsDebounced === 'function') {
saveSettingsDebounced();
}
}
} catch (error) {
console.error('[RPG Companion] Failed to remove JSON cleaning regex:', error);
}
}
-267
View File
@@ -1,267 +0,0 @@
/**
* Lorebook Limiter Module
* Adds maximum activation limit to SillyTavern's World Info system
*/
import { eventSource, event_types } from '../../../../../../../script.js';
let maxActivations = 0; // 0 = unlimited
let settingsInitialized = false;
let activatedEntriesThisGeneration = [];
/**
* Initialize the lorebook limiter
*/
export function initLorebookLimiter() {
console.log('[Lorebook Limiter] Initializing...');
// Load saved setting
const saved = localStorage.getItem('rpg_max_lorebook_activations');
if (saved !== null) {
maxActivations = parseInt(saved, 10);
}
// Wait for World Info settings to be ready
eventSource.on('worldInfoSettings', () => {
setTimeout(() => {
if (!settingsInitialized) {
injectMaxActivationsUI();
settingsInitialized = true;
}
}, 100);
});
// Try when the WI drawer is opened
const tryInjectOnClick = () => {
const wiButton = document.querySelector('#WIDrawerIcon');
if (wiButton) {
wiButton.addEventListener('click', () => {
setTimeout(() => {
if (!settingsInitialized) {
injectMaxActivationsUI();
settingsInitialized = true;
}
}, 300);
});
console.log('[Lorebook Limiter] Attached to WI drawer button');
}
};
// Also try on app ready
eventSource.on('app_ready', () => {
setTimeout(() => {
tryInjectOnClick();
if (!settingsInitialized) {
injectMaxActivationsUI();
settingsInitialized = true;
}
}, 1000);
});
// Patch the world info activation system
patchWorldInfoActivation();
}
/**
* Inject the Maximum Activations UI into World Info settings
*/
function injectMaxActivationsUI() {
console.log('[Lorebook Limiter] Injecting UI...');
// Check if already injected
if (document.querySelector('#rpg-max-lorebook-activations-container')) {
console.log('[Lorebook Limiter] UI already injected');
return;
}
// Find the Memory Recollection button - we'll add our UI right after it
const memoryButton = document.querySelector('.rpg-memory-recollection-btn');
if (!memoryButton) {
console.log('[Lorebook Limiter] Memory Recollection button not found yet');
return;
}
const container = memoryButton.parentElement;
if (!container) {
console.log('[Lorebook Limiter] Could not find button container');
return;
}
console.log('[Lorebook Limiter] Found Memory Recollection button, injecting slider after it');
// Create the UI - styled to match the extension's theme
const settingHTML = `
<div id="rpg-max-lorebook-activations-container" class="rpg-lorebook-limiter-container">
<label class="rpg-lorebook-limiter-label">
<span class="rpg-lorebook-limiter-title">Max Lorebook Activations</span>
<input type="number"
id="rpg-max-activations-input"
class="rpg-lorebook-limiter-input"
min="0"
max="9999"
step="1"
value="${maxActivations}"
placeholder="0 = unlimited" />
</label>
<small class="rpg-lorebook-limiter-hint">Limit entries per generation (0 = unlimited)</small>
</div>
`;
// Insert after the Memory Recollection button
memoryButton.insertAdjacentHTML('afterend', settingHTML);
// Add event listener
const input = document.querySelector('#rpg-max-activations-input');
if (input) {
input.addEventListener('input', (e) => {
let value = parseInt(e.target.value, 10);
if (isNaN(value) || value < 0) value = 0;
if (value > 9999) value = 9999;
maxActivations = value;
e.target.value = value;
localStorage.setItem('rpg_max_lorebook_activations', value.toString());
console.log(`[Lorebook Limiter] Max activations set to: ${value}`);
});
console.log('[Lorebook Limiter] ✅ UI injected successfully');
}
}
/**
* Patch the world info activation system to enforce the limit
*/
function patchWorldInfoActivation() {
console.log('[Lorebook Limiter] Setting up activation limiter...');
// We need to intercept at the module level
// Use a Proxy on the module loader
const originalDefine = window.define;
const originalRequire = window.require;
// Try multiple approaches to hook into the WI system
const attemptPatch = () => {
// Approach 1: Direct window access
if (window.getWorldInfoPrompt) {
const original = window.getWorldInfoPrompt;
window.getWorldInfoPrompt = async function(...args) {
const result = await original.apply(this, args);
if (maxActivations > 0 && result) {
// Count entries in the worldInfoString
const lines = (result.worldInfoBefore + result.worldInfoAfter).split('\n').filter(l => l.trim());
if (lines.length > maxActivations) {
console.log(`[Lorebook Limiter] Limiting ${lines.length} WI lines to ${maxActivations}`);
// Trim the strings
const limitedLines = lines.slice(0, maxActivations);
result.worldInfoBefore = limitedLines.join('\n');
result.worldInfoAfter = '';
result.worldInfoString = result.worldInfoBefore;
console.log(`[Lorebook Limiter] ✅ Limited from ${lines.length} to ${limitedLines.length} entries`);
}
}
return result;
};
console.log('[Lorebook Limiter] ✅ Patched window.getWorldInfoPrompt');
return true;
}
// Approach 2: Through SillyTavern context
if (window.SillyTavern?.getContext) {
const ctx = window.SillyTavern.getContext();
if (ctx.getWorldInfoPrompt) {
const original = ctx.getWorldInfoPrompt;
ctx.getWorldInfoPrompt = async function(...args) {
const result = await original.apply(this, args);
if (maxActivations > 0 && result) {
const lines = (result.worldInfoBefore + result.worldInfoAfter).split('\n').filter(l => l.trim());
if (lines.length > maxActivations) {
console.log(`[Lorebook Limiter] Limiting ${lines.length} WI entries to ${maxActivations}`);
const limitedLines = lines.slice(0, maxActivations);
result.worldInfoBefore = limitedLines.join('\n');
result.worldInfoAfter = '';
result.worldInfoString = result.worldInfoBefore;
}
}
return result;
};
console.log('[Lorebook Limiter] ✅ Patched SillyTavern.getContext().getWorldInfoPrompt');
return true;
}
// Try checkWorldInfo instead
if (ctx.checkWorldInfo) {
const original = ctx.checkWorldInfo;
ctx.checkWorldInfo = async function(...args) {
const result = await original.apply(this, args);
if (maxActivations > 0 && result?.allActivatedEntries?.size > maxActivations) {
console.log(`[Lorebook Limiter] Limiting ${result.allActivatedEntries.size} entries to ${maxActivations}`);
// Keep only first N entries
const entries = Array.from(result.allActivatedEntries.entries());
result.allActivatedEntries = new Map(entries.slice(0, maxActivations));
// Also limit the string output
const lines = (result.worldInfoBefore + result.worldInfoAfter).split('\n').filter(l => l.trim());
if (lines.length > maxActivations) {
const limitedLines = lines.slice(0, maxActivations);
result.worldInfoBefore = limitedLines.join('\n');
result.worldInfoAfter = '';
}
console.log(`[Lorebook Limiter] ✅ Limited to ${result.allActivatedEntries.size} entries`);
}
return result;
};
console.log('[Lorebook Limiter] ✅ Patched SillyTavern.getContext().checkWorldInfo');
return true;
}
}
return false;
};
// Try immediately
if (!attemptPatch()) {
// Retry after delays
setTimeout(() => attemptPatch() || setTimeout(() => attemptPatch(), 2000), 1000);
}
}
/**
* Update the maximum activations limit
*/
export function setMaxActivations(value) {
maxActivations = parseInt(value, 10);
localStorage.setItem('rpg_max_lorebook_activations', value.toString());
// Update UI if it exists
const valueDisplay = document.querySelector('#rpg-max-activations-value');
const slider = document.querySelector('#rpg-max-activations-slider');
if (valueDisplay) {
valueDisplay.textContent = value;
}
if (slider) {
slider.value = value;
}
}
/**
* Get current maximum activations limit
*/
export function getMaxActivations() {
return maxActivations;
}
-843
View File
@@ -1,843 +0,0 @@
/**
* Memory Recollection Module
* Handles generation of lorebook entries from chat history
*/
import { chat, characters, this_chid, generateRaw, substituteParams, eventSource, event_types } from '../../../../../../../script.js';
import { selected_group } from '../../../../../../group-chats.js';
import { extensionSettings, addDebugLog } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { checkWorldInfo, createNewWorldInfo, openWorldInfoEditor, saveWorldInfo, setWorldInfoSettings } from '../../../../../../world-info.js';
/**
* Helper to log to both console and debug logs array
*/
function debugLog(message, data = null) {
if (data !== null && data !== undefined) {
console.log(message, data);
} else {
console.log(message);
}
if (extensionSettings.debugMode) {
addDebugLog(message, data);
}
}
/**
* Get or create the Memory Recollection lorebook
* @returns {Promise<string>} The UID of the Memory Recollection lorebook
*/
async function getOrCreateMemoryLorebook() {
const lorebookName = 'Memory Recollection';
try {
debugLog('[Memory Recollection] Checking for existing lorebook...');
// Use checkWorldInfo to see if it exists
const exists = await checkWorldInfo(lorebookName);
if (exists) {
debugLog('[Memory Recollection] Found existing lorebook:', lorebookName);
return lorebookName;
}
// Create new lorebook using SillyTavern's imported function
debugLog('[Memory Recollection] Creating new Memory Recollection lorebook');
// Call the imported createNewWorldInfo function
await createNewWorldInfo(lorebookName, true);
debugLog('[Memory Recollection] Created lorebook:', lorebookName);
// Wait for the file system to settle
await new Promise(resolve => setTimeout(resolve, 500));
return lorebookName;
} catch (error) {
console.error('[Memory Recollection] Error in getOrCreateMemoryLorebook:', error);
throw error;
}
}
/**
* Create the constant "Relevant Memories:" header entry
* @param {string} lorebookUid - The UID of the lorebook
* @returns {Object} The header entry object
*/
function createConstantHeaderEntry() {
const entry = {
uid: 1, // Fixed UID so it's always first
key: [],
keysecondary: [],
comment: 'Relevant Memories Header',
content: 'Relevant Memories:',
constant: true, // Always inserted
vectorized: false,
selective: false,
selectiveLogic: 0,
addMemo: false,
order: 99, // First in order
position: 4, // at Depth
disable: false,
ignoreBudget: false,
excludeRecursion: false,
preventRecursion: false,
matchPersonaDescription: false,
matchCharacterDescription: false,
matchCharacterPersonality: false,
matchCharacterDepthPrompt: false,
matchScenario: false,
matchCreatorNotes: false,
delayUntilRecursion: false,
probability: 100,
useProbability: true,
depth: 1, // Insertion depth
outletName: '',
group: '',
groupOverride: false,
groupWeight: 100,
scanDepth: null,
caseSensitive: null,
matchWholeWords: null,
useGroupScoring: null,
automationId: '',
role: 0, // System role
sticky: 0,
cooldown: 0,
delay: 0,
triggers: [],
displayIndex: 0,
characterFilter: {
isExclude: false,
names: [],
tags: []
}
};
debugLog('[Memory Recollection] Created constant header entry');
return entry;
}
/**
* Save a world info entry to a lorebook
* @param {string} lorebookUid - The filename/UID of the lorebook
* @param {Object} entry - The entry data
*/
async function saveWorldInfoEntry(lorebookUid, entry) {
try {
debugLog('[Memory Recollection] Saving entry to lorebook:', lorebookUid);
// Open the world info editor for this lorebook to load its data
await openWorldInfoEditor(lorebookUid);
// Wait for it to load
await new Promise(resolve => setTimeout(resolve, 500));
// Now access the loaded world info data
const worldInfo = window.world_info;
debugLog('[Memory Recollection] World info after opening:', {
type: typeof worldInfo,
isArray: Array.isArray(worldInfo),
hasEntries: worldInfo?.entries !== undefined,
keys: worldInfo ? Object.keys(worldInfo).slice(0, 10) : null
});
// Try different structures - it might be an array or might have different properties
let entries;
if (worldInfo && typeof worldInfo === 'object') {
if (worldInfo.entries) {
entries = worldInfo.entries;
} else if (Array.isArray(worldInfo)) {
// If it's an array, convert to entries object
entries = {};
worldInfo.forEach((e, i) => {
if (e && e.uid) {
entries[e.uid] = e;
}
});
}
}
if (!entries) {
entries = {};
}
// Add the entry
entries[entry.uid] = entry;
debugLog('[Memory Recollection] Entry added, saving world info...');
// Save using the imported saveWorldInfo function
// Pass the entries as the data structure
await saveWorldInfo(lorebookUid, { entries });
debugLog('[Memory Recollection] Entry saved successfully');
return { success: true };
} catch (error) {
console.error('[Memory Recollection] Error saving entry:', error);
throw error;
}
}
/**
* Save multiple world info entries to a lorebook at once
* @param {string} lorebookUid - The filename/UID of the lorebook
* @param {Array} newEntries - Array of entry objects to add
*/
async function saveWorldInfoEntries(lorebookUid, newEntries) {
try {
debugLog(`[Memory Recollection] Saving ${newEntries.length} entries to lorebook:`, lorebookUid);
// Open the world info editor for this lorebook to load its data
await openWorldInfoEditor(lorebookUid);
// Wait for it to load
await new Promise(resolve => setTimeout(resolve, 500));
// Now access the loaded world info data
const worldInfo = window.world_info;
// Try different structures - it might be an array or might have different properties
let entries = {};
if (worldInfo && typeof worldInfo === 'object') {
if (worldInfo.entries) {
entries = { ...worldInfo.entries }; // Clone existing entries
} else if (Array.isArray(worldInfo)) {
// If it's an array, convert to entries object
worldInfo.forEach((e, i) => {
if (e && e.uid) {
entries[e.uid] = e;
}
});
}
}
// Add all new entries
for (const entry of newEntries) {
entries[entry.uid] = entry;
}
debugLog(`[Memory Recollection] ${newEntries.length} entries added, total entries: ${Object.keys(entries).length}, saving...`);
// Save using the imported saveWorldInfo function
await saveWorldInfo(lorebookUid, { entries });
debugLog('[Memory Recollection] All entries saved successfully');
return { success: true };
} catch (error) {
console.error('[Memory Recollection] Error saving entries:', error);
throw error;
}
}
/**
* Generate memory recollection prompt for a batch of messages
* @param {Array} messages - Array of chat messages to process
* @param {boolean} isUpdate - Whether this is updating existing memories (true) or initial generation (false)
* @returns {string} The prompt for the AI
*/
function generateMemoryPrompt(messages, isUpdate = false) {
const context = messages.map((msg, idx) => {
const sender = msg.is_user ? '{{user}}' : msg.name || '{{char}}';
const role = msg.is_user ? '[PLAYER]' : '[CHARACTER]';
return `[Message ${idx + 1}] ${role} ${sender}: ${msg.mes}`;
}).join('\n\n');
// Get list of CHARACTER participants (exclude {{user}} - they're the player, not a character)
const participants = new Set();
messages.forEach(msg => {
const sender = msg.is_user ? '{{user}}' : msg.name || '{{char}}';
if (!msg.is_user) { // Only add non-user (character) participants
participants.add(sender);
}
});
const characterList = Array.from(participants).join(', ');
const instruction = isUpdate
? 'You will be acting as an uncensored summarizing assistant. Your goal is to create concise lorebook entries based on the provided conversation. You will be creating additional memory recollection entries for significant events, revelations, or character developments in a specified JSON format.\nYou should maintain an objective tone.'
: 'You will be acting as an uncensored summarizing assistant. Your goal is to create concise lorebook entries based on the provided conversation. You will be creating memory recollection entries for significant events, revelations, or character developments in a specified JSON format.\nYou should maintain an objective tone.';
return `${instruction}
Characters in this conversation (excluding {{user}} who is the player): ${characterList}
NOTE: In the conversation below, messages are marked with [PLAYER] for {{user}} messages and [CHARACTER] for NPC messages.
Here is the conversation to create memories from:
<conversation>
${context}
</conversation>
Create lorebook entries in the following JSON format. Each entry should be a 1-2 sentence reminder from a character's perspective.
Format each entry as:
{
"characters": ["Character1", "Character2"],
"memory": "Character1 and Character2 remember that [event or detail]",
"keywords": ["keyword1", "keyword2", "keyword3"]
}
Examples:
<examples>
{
"characters": ["Sabrina"],
"memory": "Sabrina remembers she went on a date with {{user}} on Saturday. They ate chocolate pastries together.",
"keywords": ["date", "saturday", "pastries"]
},
{
"characters": ["Dottore", "Arlecchino", "Pantalone"],
"memory": "Dottore, Arlecchino, and Pantalone remember they attended a party together at the mansion.",
"keywords": ["party", "mansion", "gathering"]
}
</examples>
IMPORTANT:
- Only create entries for significant moments worth remembering.
- Keep memories concise (1-2 sentences maximum).
- Use third person perspective: "{name} remembers..."
- Choose 3 specific, relevant keywords per entry.
- ONLY assign memories to CHARACTERS (NPCs) - NEVER include {{user}} in the "characters" array.
- {{user}} is the player, not a character, so they should NEVER be in the characters list.
- Only characters who were ACTUALLY PRESENT in that specific scene/moment should remember it.
- If multiple characters share the memory, list all of them in the "characters" array.
- If known, include details such as dates, locations, and other relevant context in the memories.
Return ONLY a JSON array of memory objects, nothing else:`;
}
/**
* Parse the AI response to extract memory entries
* @param {string} response - The AI's response
* @returns {Array<Object>} Array of parsed memory entries
*/
function parseMemoryResponse(response) {
try {
// Try to extract JSON from code blocks
const jsonMatch = response.match(/```(?:json)?\s*(\[[\s\S]*?\])\s*```/);
const jsonString = jsonMatch ? jsonMatch[1] : response;
// Parse JSON
const memories = JSON.parse(jsonString.trim());
if (!Array.isArray(memories)) {
throw new Error('Response is not an array');
}
debugLog('[Memory Recollection] Parsed memories:', memories);
return memories;
} catch (error) {
debugLog('[Memory Recollection] Failed to parse response:', error);
console.error('[Memory Recollection] Parse error:', error);
console.error('[Memory Recollection] Raw response:', response);
return [];
}
}
/**
* Create a world info entry from a memory object
* @param {string} lorebookUid - The UID of the lorebook
* @param {Object} memory - The memory object
* @param {number} index - The index for ordering
*/
async function createMemoryEntry(lorebookUid, memory, index) {
const { characters: characterList, memory: content, keywords } = memory;
// Handle character filter - just use the character names directly
let characterNames = [];
if (Array.isArray(characterList) && characterList.length > 0) {
// New format: array of character names
characterNames = characterList.map(name => name.trim());
debugLog(`[Memory Recollection] Character names for filter:`, characterNames);
} else if (typeof characterList === 'string' && characterList.trim() !== '') {
// Legacy string format or comma-separated - parse it
characterNames = characterList.split(',').map(n => n.trim()).filter(n => n !== '');
debugLog(`[Memory Recollection] Character names for filter:`, characterNames);
}
const entry = {
uid: Date.now() + index, // Simple UID generation
key: keywords || [],
keysecondary: [],
comment: `Memory: ${characterNames.join(', ')}`,
content: content,
constant: false,
vectorized: false,
selective: true,
selectiveLogic: 0,
addMemo: false,
order: 100,
position: 4, // at Depth
disable: false,
ignoreBudget: false,
excludeRecursion: false,
preventRecursion: false,
matchPersonaDescription: false,
matchCharacterDescription: false,
matchCharacterPersonality: false,
matchCharacterDepthPrompt: false,
matchScenario: false,
matchCreatorNotes: false,
delayUntilRecursion: false,
probability: 100,
useProbability: true,
depth: 1, // Insertion depth
outletName: '',
group: '',
groupOverride: false,
groupWeight: 100,
scanDepth: null,
caseSensitive: null,
matchWholeWords: null,
useGroupScoring: null,
automationId: '',
role: 0, // 0 = System role (matching the example)
sticky: 0,
cooldown: 0,
delay: 0,
triggers: [],
displayIndex: index + 1,
characterFilter: {
isExclude: false,
names: characterNames, // Array of character names
tags: []
},
extensions: {
position: 4, // at Depth
depth: 1,
role: 1
}
};
debugLog(`[Memory Recollection] Created entry for ${characterNames.join(', ')} with character filter:`, characterNames);
return entry; // Return instead of saving
}
/**
* Process a batch of messages and generate memory entries
* @param {Array} messages - Array of messages to process
* @param {string} lorebookUid - The UID of the lorebook
* @param {boolean} isUpdate - Whether this is an update (true) or initial generation (false)
* @param {number} startIndex - Starting index for entry ordering
* @returns {Promise<Array>} Array of created entries
*/
async function processBatch(messages, lorebookUid, isUpdate, startIndex) {
debugLog(`[Memory Recollection] Processing batch of ${messages.length} messages (isUpdate: ${isUpdate})`);
const prompt = generateMemoryPrompt(messages, isUpdate);
// Generate using SillyTavern's generateRaw
const response = await generateRaw(prompt, '', false, false);
if (!response) {
throw new Error('No response from AI');
}
// Parse the response
const memories = parseMemoryResponse(response);
if (memories.length === 0) {
debugLog('[Memory Recollection] No memories extracted from this batch');
// Return -1 to signal parse failure (vs 0 for valid but empty response)
throw new Error('Failed to parse memories from AI response. The response may be invalid or the service may be unavailable.');
}
// Create entries for each memory (but don't save yet)
const entries = [];
for (let i = 0; i < memories.length; i++) {
const entry = await createMemoryEntry(lorebookUid, memories[i], startIndex + i);
entries.push(entry);
}
debugLog(`[Memory Recollection] Created ${entries.length} entries from batch`);
return entries;
}
/**
* Main function to start memory recollection process
* @param {Function} onProgress - Callback for progress updates (current, total)
* @param {Function} onComplete - Callback when complete
* @param {Function} onError - Callback for errors
*/
export async function startMemoryRecollection(onProgress, onComplete, onError) {
try {
debugLog('[Memory Recollection] Starting memory recollection process');
// Get or create the lorebook
const lorebookUid = await getOrCreateMemoryLorebook();
// Get messages to process count from settings
const messagesToProcess = extensionSettings.memoryMessagesToProcess || 16;
// Check if this is an update (lorebook already exists with entries)
const world_info = window.world_info;
const lorebook = world_info.globalSelect?.find(book => book.uid === lorebookUid);
const existingEntryCount = lorebook?.entries ? Object.keys(lorebook.entries).length : 0;
const isUpdate = existingEntryCount > 1; // More than just the header
let messagesToProcessArray;
if (isUpdate) {
// Process only the last batch
const totalMessages = chat.length;
const startIdx = Math.max(0, totalMessages - messagesToProcess);
messagesToProcessArray = chat.slice(startIdx);
debugLog(`[Memory Recollection] Update mode: Processing last ${messagesToProcess} messages`);
} else {
// Process entire chat in batches
messagesToProcessArray = chat;
debugLog(`[Memory Recollection] Initial mode: Processing all ${chat.length} messages`);
}
const totalBatches = Math.ceil(messagesToProcessArray.length / messagesToProcess);
let entryIndex = existingEntryCount;
const allEntries = []; // Accumulate all entries here
for (let i = 0; i < totalBatches; i++) {
const batchStart = i * messagesToProcess;
const batchEnd = Math.min(batchStart + messagesToProcess, messagesToProcessArray.length);
const batch = messagesToProcessArray.slice(batchStart, batchEnd);
onProgress(i + 1, totalBatches);
try {
const batchEntries = await processBatch(batch, lorebookUid, isUpdate && i === 0, entryIndex);
allEntries.push(...batchEntries); // Add to accumulator
entryIndex += batchEntries.length;
} catch (error) {
// Batch failed - ask user if they want to retry
debugLog('[Memory Recollection] Batch failed:', error.message);
const retry = await new Promise(resolve => {
const retryModal = document.createElement('div');
retryModal.className = 'rpg-memory-modal-overlay';
retryModal.innerHTML = `
<div class="rpg-memory-modal">
<div class="rpg-memory-modal-header">
<h3>⚠️ Generation Failed</h3>
</div>
<div class="rpg-memory-modal-body">
<p><strong>Error:</strong> ${error.message}</p>
<p>Batch ${i + 1} of ${totalBatches} failed to process.</p>
<p>Would you like to retry this batch?</p>
</div>
<div class="rpg-memory-modal-footer">
<button class="rpg-memory-modal-btn rpg-memory-cancel">Skip Batch</button>
<button class="rpg-memory-modal-btn rpg-memory-proceed">Retry</button>
</div>
</div>
`;
document.body.appendChild(retryModal);
retryModal.querySelector('.rpg-memory-cancel').addEventListener('click', () => {
document.body.removeChild(retryModal);
resolve(false);
});
retryModal.querySelector('.rpg-memory-proceed').addEventListener('click', () => {
document.body.removeChild(retryModal);
resolve(true);
});
});
if (retry) {
// Retry the same batch
i--;
continue;
}
// Otherwise skip this batch and continue
}
// Small delay between batches to avoid rate limiting
if (i < totalBatches - 1) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
// Add the constant header entry at the end
const headerEntry = createConstantHeaderEntry();
allEntries.push(headerEntry); // Add to end of array
// Save all entries at once
if (allEntries.length > 0) {
debugLog(`[Memory Recollection] Saving ${allEntries.length} total entries (including header) to lorebook...`);
await saveWorldInfoEntries(lorebookUid, allEntries);
// Trigger world info refresh by simulating the WI button click to reload the list
// This ensures the newly created lorebook appears in the dropdown
const wiButton = document.querySelector('#WIDrawerIcon');
if (wiButton) {
// Close and reopen to force refresh
wiButton.click();
await new Promise(resolve => setTimeout(resolve, 100));
wiButton.click();
debugLog('[Memory Recollection] Triggered WI panel refresh');
}
// Also emit the update event
eventSource.emit(event_types.WORLDINFO_SETTINGS_UPDATED);
}
debugLog('[Memory Recollection] Process complete');
// Open the World Info editor with the Memory Recollection lorebook
try {
await openWorldInfoEditor(lorebookUid);
debugLog('[Memory Recollection] Opened World Info editor with Memory Recollection lorebook');
} catch (err) {
debugLog('[Memory Recollection] Could not open World Info editor:', err);
}
onComplete(allEntries.length);
} catch (error) {
debugLog('[Memory Recollection] Error:', error);
onError(error);
}
}
/**
* Show memory recollection confirmation modal
*/
export function showMemoryRecollectionModal() {
const modal = document.createElement('div');
modal.className = 'rpg-memory-modal-overlay';
modal.innerHTML = `
<div class="rpg-memory-modal">
<div class="rpg-memory-modal-header">
<h3>⚠️ Memory Recollection</h3>
</div>
<div class="rpg-memory-modal-body">
<p><strong>Warning!</strong> This process will trigger multiple generation requests and will take time.</p>
<p>Ensure your currently selected model is the one you want to use for this task.</p>
<p class="rpg-memory-modal-info">
Messages per batch: <strong>${extensionSettings.memoryMessagesToProcess || 16}</strong>
<br>
<span class="rpg-memory-modal-hint">(You can change this in the extension settings)</span>
</p>
</div>
<div class="rpg-memory-modal-footer">
<button class="rpg-memory-modal-btn rpg-memory-cancel">Cancel</button>
<button class="rpg-memory-modal-btn rpg-memory-proceed">Proceed</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Event listeners
modal.querySelector('.rpg-memory-cancel').addEventListener('click', () => {
document.body.removeChild(modal);
});
modal.querySelector('.rpg-memory-proceed').addEventListener('click', () => {
document.body.removeChild(modal);
showMemoryProgressModal();
});
// Click outside to close
modal.addEventListener('click', (e) => {
if (e.target === modal) {
document.body.removeChild(modal);
}
});
}
/**
* Show progress modal during memory recollection
*/
function showMemoryProgressModal() {
const modal = document.createElement('div');
modal.className = 'rpg-memory-modal-overlay';
modal.innerHTML = `
<div class="rpg-memory-modal">
<div class="rpg-memory-modal-header">
<h3>🧠 Processing Memories...</h3>
</div>
<div class="rpg-memory-modal-body">
<p class="rpg-memory-progress-text">Processing batch <span class="rpg-memory-current">0</span> of <span class="rpg-memory-total">0</span></p>
<div class="rpg-memory-progress-bar">
<div class="rpg-memory-progress-fill"></div>
</div>
<p class="rpg-memory-status">Initializing...</p>
</div>
</div>
`;
document.body.appendChild(modal);
const currentSpan = modal.querySelector('.rpg-memory-current');
const totalSpan = modal.querySelector('.rpg-memory-total');
const progressFill = modal.querySelector('.rpg-memory-progress-fill');
const statusText = modal.querySelector('.rpg-memory-status');
// Start the process
startMemoryRecollection(
(current, total) => {
currentSpan.textContent = current;
totalSpan.textContent = total;
const percentage = (current / total) * 100;
progressFill.style.width = `${percentage}%`;
statusText.textContent = `Processing memories from batch ${current}...`;
},
(entriesCreated) => {
statusText.innerHTML = `
<strong>✅ Complete!</strong> Created ${entriesCreated} memory entries.<br>
<small>The "Memory Recollection" lorebook has been created.</small><br>
<strong style="color: #ffa500; margin-top: 10px; display: block;">⚠️ Please refresh SillyTavern to see the lorebook in the World Info dropdown.</strong>
`;
progressFill.style.width = '100%';
// Add close button
const closeBtn = document.createElement('button');
closeBtn.className = 'rpg-memory-modal-btn rpg-memory-close';
closeBtn.textContent = 'Close';
closeBtn.style.marginTop = '15px';
closeBtn.addEventListener('click', () => {
document.body.removeChild(modal);
});
modal.querySelector('.rpg-memory-modal-body').appendChild(closeBtn);
},
(error) => {
statusText.textContent = `Error: ${error.message}`;
statusText.style.color = '#e94560';
// Close after 5 seconds
setTimeout(() => {
document.body.removeChild(modal);
}, 5000);
}
);
}
/**
* Setup the memory recollection button in World Info section
*/
export function setupMemoryRecollectionButton() {
console.log('[Memory Recollection] Setting up button via event listener');
// Use SillyTavern's built-in event to know when WI is ready
// This fires after the worldInfoSettings are loaded
eventSource.on('worldInfoSettings', () => {
console.log('[Memory Recollection] worldInfoSettings event fired');
setTimeout(updateButton, 100);
});
// Also try on app ready
eventSource.on('app_ready', () => {
console.log('[Memory Recollection] app_ready event fired');
setTimeout(updateButton, 500);
});
// Try immediately as well
setTimeout(updateButton, 2000);
function updateButton() {
const existingButton = document.querySelector('.rpg-memory-recollection-btn');
// If extension is disabled, remove button if it exists
if (!extensionSettings.enabled) {
if (existingButton) {
console.log('[Memory Recollection] Extension disabled, removing button');
existingButton.remove();
}
return;
}
// Extension is enabled, add button if it doesn't exist
addButton();
}
function addButton() {
// Check if button already exists
if (document.querySelector('.rpg-memory-recollection-btn')) {
console.log('[Memory Recollection] Button already exists');
return;
}
console.log('[Memory Recollection] Attempting to add button...');
// World Info button bar is inside the world editor
// Look for the specific button container
const selectors = [
'#world_editor_buttons',
'#world_popup .world_button_bar',
'#WorldInfo .world_button_bar',
'.world_button_bar',
'#world_popup .justifyLeft',
'#WorldInfo .justifyLeft',
'#world_popup',
'#WorldInfo'
];
let container = null;
for (const selector of selectors) {
container = document.querySelector(selector);
if (container) {
console.log(`[Memory Recollection] Found container with selector: ${selector}`, container);
break;
}
}
if (!container) {
console.log('[Memory Recollection] No suitable container found yet');
return;
}
// Create the button
const button = document.createElement('button');
button.id = 'rpg-memory-recollection-button';
button.className = 'rpg-memory-recollection-btn menu_button';
button.innerHTML = '<i class="fa-solid fa-brain"></i> Memory Recollection';
button.title = 'Generate memory recollection entries from chat history';
button.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
showMemoryRecollectionModal();
});
// Insert the button - prepend to put it first
if (container.classList.contains('world_button_bar') || container.classList.contains('justifyLeft')) {
container.insertBefore(button, container.firstChild);
} else {
// Find or create a button container
let buttonContainer = container.querySelector('.world_button_bar') ||
container.querySelector('.justifyLeft');
if (!buttonContainer) {
buttonContainer = document.createElement('div');
buttonContainer.className = 'world_button_bar justifyLeft';
container.insertBefore(buttonContainer, container.firstChild);
}
buttonContainer.insertBefore(button, buttonContainer.firstChild);
}
console.log('[Memory Recollection] ✅ Button added successfully!');
}
}
/**
* Update button visibility based on extension enabled state
* Call this when the extension is toggled on/off
*/
export function updateMemoryRecollectionButton() {
const existingButton = document.querySelector('.rpg-memory-recollection-btn');
if (!extensionSettings.enabled) {
// Extension disabled - remove button if it exists
if (existingButton) {
console.log('[Memory Recollection] Extension disabled, removing button');
existingButton.remove();
}
} else {
// Extension enabled - ensure button exists
if (!existingButton) {
console.log('[Memory Recollection] Extension enabled, adding button');
setTimeout(() => {
setupMemoryRecollectionButton();
}, 100);
}
}
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Music Player Module
* Handles parsing and storing Spotify URLs from AI responses
*/
import { extensionSettings, committedTrackerData } from '../../core/state.js';
/**
* Extracts song suggestion from AI response in <spotify:Song - Artist/> format
* @param {string} responseText - The raw AI response text
* @returns {Object|null} Object with {song, artist, searchQuery} or null if not found
*/
export function extractSpotifyUrl(responseText) {
if (!responseText || !extensionSettings.enableSpotifyMusic) return null;
// Match <spotify:Song Title - Artist Name/> format
const songMatch = responseText.match(/<spotify:([^<>-]+)\s*-\s*([^<>\/]+)\/>/i);
if (songMatch) {
const song = songMatch[1].trim();
const artist = songMatch[2].trim();
const searchQuery = `${song} ${artist}`;
return {
song,
artist,
searchQuery,
displayText: `${song} - ${artist}`
};
}
return null;
}
/**
* Converts song data to Spotify app protocol URL
* @param {Object} songData - Object with {song, artist, searchQuery}
* @returns {string} Spotify app protocol URL
*/
export function convertToEmbedUrl(songData) {
if (!songData || !songData.searchQuery) return '';
// Use Spotify app protocol for direct app opening
const encodedQuery = encodeURIComponent(songData.searchQuery);
return `spotify:search:${encodedQuery}`;
}
/**
* Parses AI response for song suggestion and stores it
* @param {string} responseText - The raw AI response text
* @returns {boolean} True if song was found and stored
*/
export function parseAndStoreSpotifyUrl(responseText) {
if (!extensionSettings.enableSpotifyMusic) return false;
const songData = extractSpotifyUrl(responseText);
// console.log('[RPG Companion] Spotify Parser: Found song:', songData);
if (songData) {
// Store in committed tracker data
committedTrackerData.spotifyUrl = songData;
// console.log('[RPG Companion] Spotify Parser: Stored song in committedTrackerData:', committedTrackerData.spotifyUrl);
return true;
}
return false;
}
/**
* Gets the current song data from committed tracker data
* @returns {Object|null} Current song data or null
*/
export function getCurrentSpotifyUrl() {
return committedTrackerData.spotifyUrl || null;
}
/**
* Clears the current song data
*/
export function clearSpotifyUrl() {
committedTrackerData.spotifyUrl = null;
}
+51 -17
View File
@@ -5,13 +5,16 @@
import { togglePlotButtons } from '../ui/layout.js';
import { extensionSettings, setIsPlotProgression } from '../../core/state.js';
import { DEFAULT_HTML_PROMPT, DEFAULT_DIALOGUE_COLORING_PROMPT, DEFAULT_DECEPTION_PROMPT, DEFAULT_CYOA_PROMPT } from '../generation/promptBuilder.js';
import { Generate } from '../../../../../../../script.js';
import { i18n } from '../../core/i18n.js';
/**
* Sets up the plot progression buttons inside the send form area.
* @param {Function} handlePlotClick - Callback function to handle plot button clicks
* @param {Function} handleEncounterClick - Callback function to handle encounter button click
*/
export function setupPlotButtons(handlePlotClick) {
export function setupPlotButtons(handlePlotClick, handleEncounterClick) {
// Remove existing buttons if any
$('#rpg-plot-buttons').remove();
@@ -31,10 +34,9 @@ export function setupPlotButtons(handlePlotClick) {
border-radius: 4px;
font-size: 13px;
cursor: pointer;
margin: 0 4px;
display: inline-block;
" tabindex="0" role="button">
<i class="fa-solid fa-dice"></i> Randomized Plot
margin: 0 2px;
" tabindex="0" role="button" title="${i18n.getTranslation('plotProgression.tooltips.randomizedPlot') || 'Generate a random plot twist or event'}">
<i class="fa-solid fa-dice"></i>&nbsp;<span class="rpg-btn-text">${i18n.getTranslation('plotProgression.buttons.randomizedPlot') || 'Randomized Plot'}</span>
</button>
<button id="rpg-plot-natural" class="menu_button interactable" style="
background-color: #4a90e2;
@@ -44,10 +46,21 @@ export function setupPlotButtons(handlePlotClick) {
border-radius: 4px;
font-size: 13px;
cursor: pointer;
margin: 0 4px;
display: inline-block;
" tabindex="0" role="button">
<i class="fa-solid fa-forward"></i> Natural Plot
margin: 0 2px;
" tabindex="0" role="button" title="${i18n.getTranslation('plotProgression.tooltips.naturalPlot') || 'Continue the story naturally without twists'}">
<i class="fa-solid fa-forward"></i>&nbsp;<span class="rpg-btn-text">${i18n.getTranslation('plotProgression.buttons.naturalPlot') || 'Natural Plot'}</span>
</button>
<button id="rpg-encounter-button" class="menu_button interactable" style="
background-color: #cc3333;
color: white;
border: none;
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
margin: 0 2px;
" tabindex="0" role="button" title="${i18n.getTranslation('plotProgression.tooltips.enterEncounter') || 'Enter combat encounter'}">
<i class="fa-solid fa-fire"></i>&nbsp;<span class="rpg-btn-text">${i18n.getTranslation('plotProgression.buttons.enterEncounter') || 'Enter Encounter'}</span>
</button>
</span>
`;
@@ -58,6 +71,7 @@ export function setupPlotButtons(handlePlotClick) {
// Add event handlers for buttons
$('#rpg-plot-random').on('click', () => handlePlotClick('random'));
$('#rpg-plot-natural').on('click', () => handlePlotClick('natural'));
$('#rpg-encounter-button').on('click', () => handleEncounterClick());
// Show/hide based on setting
togglePlotButtons();
@@ -87,19 +101,39 @@ export async function sendPlotProgression(type) {
// Build the prompt based on type
let prompt = '';
if (type === 'random') {
prompt = 'Actually, the scene is getting stale. Introduce {{random::stakes::a plot twist::a new character::a cataclysm::a fourth-wall-breaking joke::a sudden atmospheric phenomenon::a plot hook::a running gag::an ecchi scenario::Death from Discworld::a new stake::a drama::a conflict::an angered entity::a god::a vision::a prophetic dream::Il Dottore from Genshin Impact::a new development::a civilian in need::an emotional bit::a threat::a villain::an important memory recollection::a marriage proposal::a date idea::an angry horde of villagers with pitchforks::a talking animal::an enemy::a cliffhanger::a short omniscient POV shift to a completely different character::a quest::an unexpected revelation::a scandal::an evil clone::death of an important character::harm to an important character::a romantic setup::a gossip::a messenger::a plot point from the past::a plot hole::a tragedy::a ghost::an otherworldly occurrence::a plot device::a curse::a magic device::a rival::an unexpected pregnancy::a brothel::a prostitute::a new location::a past lover::a completely random thing::a what-if scenario::a significant choice::war::love::a monster::lewd undertones::Professor Mari::a travelling troupe::a secret::a fortune-teller::something completely different::a killer::a murder mystery::a mystery::a skill check::a deus ex machina::three raccoons in a trench coat::a pet::a slave::an orphan::a psycho::tentacles::"there is only one bed" trope::accidental marriage::a fun twist::a boss battle::sexy corn::an eldritch horror::a character getting hungry, thirsty, or exhausted::horniness::a need for a bathroom break need::someone fainting::an assassination attempt::a meta narration of this all being an out of hand DND session::a dungeon::a friend in need::an old friend::a small time skip::a scene shift::Aurora Borealis, at this time of year, at this time of day, at this part of the country::a grand ball::a surprise party::zombies::foreshadowing::a Spanish Inquisition (nobody expects it)::a natural plot progression}} to make things more interesting! Be creative, but stay grounded in the setting.';
// Use custom prompt if set, otherwise use default
prompt = extensionSettings.customPlotRandomPrompt || 'Actually, the scene is getting stale. Introduce {{random::stakes::a plot twist::a new character::a cataclysm::a fourth-wall-breaking joke::a sudden atmospheric phenomenon::a plot hook::a running gag::an ecchi scenario::Death from Discworld::a new stake::a drama::a conflict::an angered entity::a god::a vision::a prophetic dream::Il Dottore from Genshin Impact::a new development::a civilian in need::an emotional bit::a threat::a villain::an important memory recollection::a marriage proposal::a date idea::an angry horde of villagers with pitchforks::a talking animal::an enemy::a cliffhanger::a short omniscient POV shift to a completely different character::a quest::an unexpected revelation::a scandal::an evil clone::death of an important character::harm to an important character::a romantic setup::a gossip::a messenger::a plot point from the past::a plot hole::a tragedy::a ghost::an otherworldly occurrence::a plot device::a curse::a magic device::a rival::an unexpected pregnancy::a brothel::a prostitute::a new location::a past lover::a completely random thing::a what-if scenario::a significant choice::war::love::a monster::lewd undertones::Professor Mari::a travelling troupe::a secret::a fortune-teller::something completely different::a killer::a murder mystery::a mystery::a skill check::a deus ex machina::three raccoons in a trench coat::a pet::a slave::an orphan::a psycho::tentacles::"there is only one bed" trope::accidental marriage::a fun twist::a boss battle::sexy corn::an eldritch horror::a character getting hungry, thirsty, or exhausted::horniness::a need for a bathroom break need::someone fainting::an assassination attempt::a meta narration of this all being an out of hand DND session::a dungeon::a friend in need::an old friend::a small time skip::a scene shift::Aurora Borealis, at this time of year, at this time of day, at this part of the country::a grand ball::a surprise party::zombies::foreshadowing::a Spanish Inquisition (nobody expects it)::a natural plot progression}} to make things more interesting! Be creative, but stay grounded in the setting.';
} else {
prompt = 'Actually, the scene is getting stale. Progress it, to make things more interesting! Reintroduce an unresolved plot point from the past, or push the story further towards the current main goal. Be creative, but stay grounded in the setting.';
// Use custom prompt if set, otherwise use default
prompt = extensionSettings.customPlotNaturalPrompt || 'Actually, the scene is getting stale. Progress it, to make things more interesting! Reintroduce an unresolved plot point from the past, or push the story further towards the current main goal. Be creative, but stay grounded in the setting.';
}
// Add HTML prompt if enabled
if (extensionSettings.enableHtmlPrompt) {
prompt += '\n\n' + `If appropriate, include inline HTML, CSS, and JS elements for creative, visual storytelling throughout your response:
- Use them liberally to depict any in-world content that can be visualized (screens, posters, books, signs, letters, logos, crests, seals, medallions, labels, etc.), with creative license for animations, 3D effects, pop-ups, dropdowns, websites, and so on.
- Style them thematically to match the theme (e.g., sleek for sci-fi, rustic for fantasy), ensuring text is visible.
- Embed all resources directly (e.g., inline SVGs) so nothing relies on external fonts or libraries.
- Place elements naturally in the narrative where characters would see or use them, with no limits on format or application.
- These HTML/CSS/JS elements must be rendered directly without enclosing them in code fences.`;
// Use custom HTML prompt if set, otherwise use default
const htmlPromptText = extensionSettings.customHtmlPrompt || DEFAULT_HTML_PROMPT;
prompt += '\n\n' + htmlPromptText;
}
// Add Dialogue Coloring prompt if enabled
if (extensionSettings.enableDialogueColoring) {
// Use custom Dialogue Coloring prompt if set, otherwise use default
const dialogueColoringPromptText = extensionSettings.customDialogueColoringPrompt || DEFAULT_DIALOGUE_COLORING_PROMPT;
prompt += '\n\n' + dialogueColoringPromptText;
}
// Add Deception System prompt if enabled
if (extensionSettings.enableDeceptionSystem) {
// Use custom Deception prompt if set, otherwise use default
const deceptionPromptText = extensionSettings.customDeceptionPrompt || DEFAULT_DECEPTION_PROMPT;
prompt += '\n\n' + deceptionPromptText;
}
// Add CYOA prompt if enabled
if (extensionSettings.enableCYOA) {
// Use custom CYOA prompt if set, otherwise use default
const cyoaPromptText = extensionSettings.customCYOAPrompt || DEFAULT_CYOA_PROMPT;
prompt += '\n\n' + cyoaPromptText;
}
// Set flag to indicate we're doing plot progression
+290 -72
View File
@@ -3,8 +3,13 @@
* Handles API calls for RPG tracker generation
*/
import { generateRaw, chat } from '../../../../../../../script.js';
import { chat, eventSource } from '../../../../../../../script.js';
import { executeSlashCommandsOnChatInput } from '../../../../../../../scripts/slash-commands.js';
import { safeGenerateRaw, extractTextFromResponse } from '../../utils/responseExtractor.js';
// Custom event name for when RPG Companion finishes updating tracker data
// Other extensions can listen for this event to know when RPG Companion is done
export const RPG_COMPANION_UPDATE_COMPLETE = 'rpg_companion_update_complete';
import {
extensionSettings,
lastGeneratedData,
@@ -12,25 +17,154 @@ import {
isGenerating,
lastActionWasSwipe,
setIsGenerating,
setLastActionWasSwipe
setLastActionWasSwipe,
$musicPlayerContainer,
getSeparateGenerationId
} from '../../core/state.js';
import { saveChatData } from '../../core/persistence.js';
import { generateSeparateUpdatePrompt } from './promptBuilder.js';
import { saveChatData, setMessageSwipeTrackerData } from '../../core/persistence.js';
import {
generateSeparateUpdatePrompt
} from './promptBuilder.js';
import { parseResponse, parseUserStats } from './parser.js';
import { parseAndStoreSpotifyUrl } from '../features/musicPlayer.js';
import { renderUserStats } from '../rendering/userStats.js';
import { renderInfoBox } from '../rendering/infoBox.js';
import { removeLocks } from './lockManager.js';
import { renderThoughts } from '../rendering/thoughts.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 { i18n } from '../../core/i18n.js';
import { generateAvatarsForCharacters } from '../features/avatarGenerator.js';
import { setFabLoadingState, updateFabWidgets } from '../ui/mobile.js';
import { updateStripWidgets } from '../ui/desktop.js';
// Store the original preset name to restore after tracker generation
let originalPresetName = null;
/**
* Generates tracker data using an external OpenAI-compatible API.
* Used when generationMode is 'external'.
*
* @param {Array<{role: string, content: string}>} messages - Array of message objects for the API
* @returns {Promise<string>} The generated response content
* @throws {Error} If the API call fails or configuration is invalid
*/
export async function generateWithExternalAPI(messages) {
const { baseUrl, model, maxTokens, temperature } = extensionSettings.externalApiSettings || {};
// Retrieve API key from secure storage (not shared extension settings)
const apiKey = localStorage.getItem('rpg_companion_external_api_key');
// Validate required settings
if (!baseUrl || !baseUrl.trim()) {
throw new Error('External API base URL is not configured');
}
if (!model || !model.trim()) {
throw new Error('External API model is not configured');
}
// Normalize base URL (remove trailing slash if present)
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, '');
const endpoint = `${normalizedBaseUrl}/chat/completions`;
// console.log(`[RPG Companion] Calling external API: ${normalizedBaseUrl} with model: ${model}`);
// Prepare headers - only include Authorization if API key is provided
const headers = {
'Content-Type': 'application/json'
};
if (apiKey && apiKey.trim()) {
headers['Authorization'] = `Bearer ${apiKey.trim()}`;
}
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: headers,
body: JSON.stringify({
model: model.trim(),
messages: messages,
max_tokens: maxTokens || 2048,
temperature: temperature ?? 0.7
})
});
if (!response.ok) {
const errorText = await response.text();
let errorMessage = `External API error: ${response.status} ${response.statusText}`;
try {
const errorJson = JSON.parse(errorText);
if (errorJson.error?.message) {
errorMessage = `External API error: ${errorJson.error.message}`;
}
} catch (e) {
// If parsing fails, use the raw text if it's short enough
if (errorText.length < 200) {
errorMessage = `External API error: ${errorText}`;
}
}
throw new Error(errorMessage);
}
const data = await response.json();
const content = extractTextFromResponse(data);
if (!content || !content.trim()) {
throw new Error('Invalid response format from external API — no text content found');
}
// console.log('[RPG Companion] External API response received successfully');
return content;
} catch (error) {
if (error.name === 'TypeError' && (error.message.includes('fetch') || error.message.includes('Failed to fetch') || error.message.includes('NetworkError'))) {
throw new Error(`CORS Access Blocked: This API endpoint (${normalizedBaseUrl}) does not allow direct access from a browser. This is a browser security restriction (CORS), not a bug in the extension. Please use an endpoint that supports CORS (like OpenRouter or a local proxy) or use SillyTavern's internal API system (Separate Mode).`);
}
throw error;
}
}
/**
* Tests the external API connection with a simple request.
* @returns {Promise<{success: boolean, message: string, model?: string}>}
*/
export async function testExternalAPIConnection() {
const { baseUrl, model } = extensionSettings.externalApiSettings || {};
const apiKey = localStorage.getItem('rpg_companion_external_api_key');
if (!baseUrl || !model) {
return {
success: false,
message: 'Please fill in all required fields (Base URL and Model). API Key is optional for local servers.'
};
}
try {
const testMessages = [
{ role: 'user', content: 'Respond with exactly: "Connection successful"' }
];
const response = await generateWithExternalAPI(testMessages);
return {
success: true,
message: `Connection successful! Model: ${model}`,
model: model
};
} catch (error) {
return {
success: false,
message: error.message || 'Connection failed'
};
}
}
/**
* Gets the current preset name using the /preset command
* @returns {Promise<string|null>} Current preset name or null if unavailable
*/
async function getCurrentPresetName() {
export async function getCurrentPresetName() {
try {
// Use /preset without arguments to get the current preset name
const result = await executeSlashCommandsOnChatInput('/preset', { quiet: true });
@@ -54,12 +188,14 @@ async function getCurrentPresetName() {
console.error('[RPG Companion] Error getting current preset:', error);
return null;
}
}/**
}
/**
* Switches to a specific preset by name using the /preset slash command
* @param {string} presetName - Name of the preset to switch to
* @returns {Promise<boolean>} True if switching succeeded, false otherwise
*/
async function switchToPreset(presetName) {
export async function switchToPreset(presetName) {
try {
// Use the /preset slash command to switch presets
// This is the proper way to change presets in SillyTavern
@@ -84,7 +220,7 @@ async function switchToPreset(presetName) {
* @param {Function} renderThoughts - UI function to render character thoughts
* @param {Function} renderInventory - UI function to render inventory
*/
export async function updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory) {
export async function updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory, generationId = null) {
if (isGenerating) {
// console.log('[RPG Companion] Already generating, skipping...');
return;
@@ -94,45 +230,70 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
return;
}
if (extensionSettings.generationMode !== 'separate') {
// console.log('[RPG Companion] Not in separate mode, skipping manual update');
if (extensionSettings.generationMode !== 'separate' && extensionSettings.generationMode !== 'external') {
// console.log('[RPG Companion] Not in separate or external mode, skipping manual update');
return;
}
const isExternalMode = extensionSettings.generationMode === 'external';
try {
setIsGenerating(true);
setFabLoadingState(true); // Show spinning FAB on mobile
// Update button to show "Updating..." state
const $updateBtn = $('#rpg-manual-update');
const originalHtml = $updateBtn.html();
$updateBtn.html('<i class="fa-solid fa-spinner fa-spin"></i> Updating...').prop('disabled', true);
const $stripRefreshBtn = $('#rpg-strip-refresh');
const updatingText = i18n.getTranslation('template.mainPanel.updating') || 'Updating...';
$updateBtn.html(`<i class="fa-solid fa-spinner fa-spin"></i> ${updatingText}`).prop('disabled', true);
$stripRefreshBtn.html('<i class="fa-solid fa-spinner fa-spin"></i>').prop('disabled', true);
// Save current preset name before switching (if we're going to switch)
if (extensionSettings.useSeparatePreset) {
originalPresetName = await getCurrentPresetName();
console.log(`[RPG Companion] Saved original preset: "${originalPresetName}"`);
}
const prompt = await generateSeparateUpdatePrompt();
// Switch to separate preset if enabled
if (extensionSettings.useSeparatePreset) {
const switched = await switchToPreset('RPG Companion Trackers');
if (!switched) {
console.warn('[RPG Companion] Failed to switch to RPG Companion Trackers preset. Using current preset.');
originalPresetName = null; // Don't try to restore if we didn't switch
}
}
const prompt = generateSeparateUpdatePrompt();
// Generate using raw prompt (uses current preset, no chat history)
const response = await generateRaw({
// Generate response based on mode
let response;
if (isExternalMode) {
// External mode: Use external OpenAI-compatible API directly
// console.log('[RPG Companion] Using external API for tracker generation');
response = await generateWithExternalAPI(prompt);
} else {
// Separate mode: Use SillyTavern's generateRaw (with extended thinking fallback)
response = await safeGenerateRaw({
prompt: prompt,
quietToLoud: false
});
}
// If a generationId was provided and the counter has since been incremented
// (by a deletion or a newer generation), discard this result entirely.
// The finally block still runs to restore button state.
if (generationId !== null && getSeparateGenerationId() !== generationId) {
// console.log('[RPG Companion] ⚠️ Separate generation result discarded — superseded (genId', generationId, '!= current', getSeparateGenerationId(), ')');
return;
}
if (response) {
// console.log('[RPG Companion] Raw AI response:', response);
const parsedData = parseResponse(response);
// Check if parsing completely failed (no tracker data found)
if (parsedData.parsingFailed) {
toastr.error(i18n.getTranslation('errors.parsingError') || 'RPG Companion Trackers parsing error! The model returned incorrect format. Consider switching generation model if this persists.', '', { timeOut: 5000 });
}
// Remove locks from parsed data (JSON format only, text format is unaffected)
if (parsedData.userStats) {
parsedData.userStats = removeLocks(parsedData.userStats);
}
if (parsedData.infoBox) {
parsedData.infoBox = removeLocks(parsedData.infoBox);
}
if (parsedData.characterThoughts) {
parsedData.characterThoughts = removeLocks(parsedData.characterThoughts);
}
// Parse and store Spotify URL if feature is enabled
parseAndStoreSpotifyUrl(response);
// console.log('[RPG Companion] Parsed data:', parsedData);
// console.log('[RPG Companion] parsedData.userStats:', parsedData.userStats ? parsedData.userStats.substring(0, 100) + '...' : 'null');
@@ -143,24 +304,8 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
// Store RPG data for the last assistant message (separate mode)
const lastMessage = chat && chat.length > 0 ? chat[chat.length - 1] : null;
// console.log('[RPG Companion] Last message is_user:', lastMessage ? lastMessage.is_user : 'no message');
if (lastMessage && !lastMessage.is_user) {
if (!lastMessage.extra) {
lastMessage.extra = {};
}
if (!lastMessage.extra.rpg_companion_swipes) {
lastMessage.extra.rpg_companion_swipes = {};
}
const currentSwipeId = lastMessage.swipe_id || 0;
lastMessage.extra.rpg_companion_swipes[currentSwipeId] = {
userStats: parsedData.userStats,
infoBox: parsedData.infoBox,
characterThoughts: parsedData.characterThoughts
};
// console.log('[RPG Companion] Stored separate mode RPG data for message swipe', currentSwipeId);
// Update lastGeneratedData for display AND future commit
// Update lastGeneratedData for display (regardless of message type)
if (parsedData.userStats) {
lastGeneratedData.userStats = parsedData.userStats;
parseUserStats(parsedData.userStats);
@@ -171,13 +316,27 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
if (parsedData.characterThoughts) {
lastGeneratedData.characterThoughts = parsedData.characterThoughts;
}
// console.log('[RPG Companion] 💾 SEPARATE MODE: Updated lastGeneratedData:', {
// userStats: lastGeneratedData.userStats ? 'exists' : 'null',
// infoBox: lastGeneratedData.infoBox ? 'exists' : 'null',
// characterThoughts: lastGeneratedData.characterThoughts ? 'exists' : 'null'
// });
// Only auto-commit on TRULY first generation (no committed data exists at all)
// Also store on assistant message if present (existing behavior)
if (lastMessage && !lastMessage.is_user) {
if (!lastMessage.extra) {
lastMessage.extra = {};
}
if (!lastMessage.extra.rpg_companion_swipes) {
lastMessage.extra.rpg_companion_swipes = {};
}
const currentSwipeId = lastMessage.swipe_id || 0;
setMessageSwipeTrackerData(lastMessage, currentSwipeId, {
userStats: parsedData.userStats,
infoBox: parsedData.infoBox,
characterThoughts: parsedData.characterThoughts
});
// console.log('[RPG Companion] Stored separate mode RPG data for message swipe', currentSwipeId);
}
// Only commit on TRULY first generation (no committed data exists at all)
// This prevents auto-commit after refresh when we have saved committed data
const hasAnyCommittedContent = (
(committedTrackerData.userStats && committedTrackerData.userStats.trim() !== '') ||
@@ -198,42 +357,101 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
renderInfoBox();
renderThoughts();
renderInventory();
renderEquipment();
renderQuests();
} else {
// No assistant message to attach to - just update display
if (parsedData.userStats) {
parseUserStats(parsedData.userStats);
}
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderQuests();
}
renderMusicPlayer($musicPlayerContainer[0]);
// Save to chat metadata
saveChatData();
// Generate avatars if auto-generate is enabled (runs within this workflow)
// This uses the RPG Companion Trackers preset and keeps the button spinning
if (extensionSettings.autoGenerateAvatars) {
const charactersNeedingAvatars = parseCharactersFromThoughts(parsedData.characterThoughts);
if (charactersNeedingAvatars.length > 0) {
// console.log('[RPG Companion] Generating avatars for:', charactersNeedingAvatars);
// Generate avatars - this awaits completion
await generateAvatarsForCharacters(charactersNeedingAvatars, (names) => {
// Callback when generation starts - re-render to show loading spinners
// console.log('[RPG Companion] Avatar generation started, showing spinners...');
renderThoughts();
});
// Re-render once all avatars are generated
// console.log('[RPG Companion] All avatars generated, re-rendering...');
renderThoughts();
}
}
}
} catch (error) {
console.error('[RPG Companion] Error updating RPG data:', error);
} finally {
// Restore original preset if we switched to a separate one
if (originalPresetName && extensionSettings.useSeparatePreset) {
console.log(`[RPG Companion] Restoring original preset: "${originalPresetName}"`);
await switchToPreset(originalPresetName);
originalPresetName = null; // Clear after restoring
if (isExternalMode) {
toastr.error(error.message, 'RPG Companion External API Error');
}
} finally {
setIsGenerating(false);
setFabLoadingState(false); // Stop spinning FAB on mobile
updateFabWidgets(); // Update FAB widgets with new data
updateStripWidgets(); // Update strip widgets with new data
// Restore button to original state
const $updateBtn = $('#rpg-manual-update');
$updateBtn.html('<i class="fa-solid fa-sync"></i> Refresh RPG Info').prop('disabled', false);
const $stripRefreshBtn = $('#rpg-strip-refresh');
const refreshText = i18n.getTranslation('template.mainPanel.refreshRpgInfo') || 'Refresh RPG Info';
$updateBtn.html(`<i class="fa-solid fa-sync"></i> ${refreshText}`).prop('disabled', false);
$stripRefreshBtn.html('<i class="fa-solid fa-sync"></i>').prop('disabled', false);
// Reset the flag after tracker generation completes
// This ensures the flag persists through both main generation AND tracker generation
// console.log('[RPG Companion] 🔄 Tracker generation complete - resetting lastActionWasSwipe to false');
setLastActionWasSwipe(false);
// Emit event for other extensions to know RPG Companion has finished updating
console.debug('[RPG Companion] Emitting RPG_COMPANION_UPDATE_COMPLETE event');
eventSource.emit(RPG_COMPANION_UPDATE_COMPLETE);
}
}
/**
* Parses character names from Present Characters thoughts data
* @param {string} characterThoughtsData - Raw character thoughts data
* @returns {Array<string>} Array of character names found
*/
function parseCharactersFromThoughts(characterThoughtsData) {
if (!characterThoughtsData) return [];
// Try parsing as JSON first (current format)
try {
const parsed = typeof characterThoughtsData === 'string'
? JSON.parse(characterThoughtsData)
: characterThoughtsData;
// Handle both {characters: [...]} and direct array formats
const charactersArray = Array.isArray(parsed) ? parsed : (parsed.characters || []);
if (charactersArray.length > 0) {
// Extract names from JSON character objects
return charactersArray
.map(char => char.name)
.filter(name => name && name.toLowerCase() !== 'unavailable');
}
} catch (e) {
// Not JSON, fall back to text parsing
}
// Fallback: Parse text format (legacy)
const lines = characterThoughtsData.split('\n');
const characters = [];
for (const line of lines) {
if (line.trim().startsWith('- ')) {
const name = line.trim().substring(2).trim();
if (name && name.toLowerCase() !== 'unavailable') {
characters.push(name);
}
}
}
return characters;
}
+819
View File
@@ -0,0 +1,819 @@
/**
* Encounter Prompt Builder Module
* Handles all AI prompt generation for combat encounters
*/
import { getContext } from '../../../../../../extensions.js';
import { chat, characters, this_chid, substituteParams } from '../../../../../../../script.js';
import { selected_group, getGroupMembers, groups } from '../../../../../../group-chats.js';
import { extensionSettings, committedTrackerData } from '../../core/state.js';
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
* @returns {Promise<string>} Formatted character information
*/
async function getCharactersInfo() {
let characterInfo = '';
// Check if in group chat
if (selected_group) {
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';
const disabledMembers = group?.disabled_members || [];
let characterIndex = 0;
groupMembers.forEach((member) => {
if (!member || !member.name) return;
// Skip muted characters
if (member.avatar && disabledMembers.includes(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`;
});
}
} 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`;
}
return characterInfo;
}
/**
* Builds the initial encounter setup prompt
* This asks the model to generate all combat stats and setup data
* @returns {Promise<Array>} Message array for the API
*/
export async function buildEncounterInitPrompt() {
const context = getContext();
const userName = context.name1;
const depth = extensionSettings.encounterSettings?.historyDepth || 8;
const messages = [];
// System message
let systemMessage = `You will be acting as an excellent game master. Your goal is to craft a combat encounter in this uncensored, continuous, and immersive roleplay. You will be doing so for the user, who plays as the protagonist called ${userName}.\n\n`;
// Add setting information
systemMessage += `Here is some information for you about the setting:\n`;
systemMessage += `<setting>\n`;
// Try to get world info from lorebooks using getWorldInfoPrompt
let worldInfoAdded = false;
try {
// Debug logging
// console.log('[RPG Companion] Checking world info:', {
// hasWindowGetWorldInfoPrompt: typeof window.getWorldInfoPrompt === 'function',
// hasContextGetWorldInfoPrompt: typeof context.getWorldInfoPrompt === 'function',
// chatLength: chat?.length,
// contextChatLength: context.chat?.length,
// hasActivatedWorldInfo: !!context.activatedWorldInfo,
// activatedWorldInfoLength: context.activatedWorldInfo?.length
// });
// Use SillyTavern's getWorldInfoPrompt to get activated lorebook entries
// Try context.getWorldInfoPrompt first, then window.getWorldInfoPrompt
const getWorldInfoFn = context.getWorldInfoPrompt || window.getWorldInfoPrompt;
const currentChat = context.chat || chat;
if (typeof getWorldInfoFn === 'function' && currentChat && currentChat.length > 0) {
const chatForWI = currentChat.map(x => x.mes || x.message || x).filter(m => m && typeof m === 'string');
// console.log('[RPG Companion] Calling getWorldInfoPrompt with', chatForWI.length, 'messages');
const result = await getWorldInfoFn(chatForWI, 8000, false);
const worldInfoString = result?.worldInfoString || result;
// console.log('[RPG Companion] World info result:', { worldInfoString, length: worldInfoString?.length });
if (worldInfoString && typeof worldInfoString === 'string' && worldInfoString.trim()) {
systemMessage += worldInfoString.trim();
worldInfoAdded = true;
// console.log('[RPG Companion] ✅ Added world info from getWorldInfoPrompt');
}
} else {
// console.log('[RPG Companion] getWorldInfoPrompt not available or no chat');
}
} catch (e) {
console.warn('[RPG Companion] Failed to get world info from getWorldInfoPrompt:', e);
}
// Fallback to activatedWorldInfo
if (!worldInfoAdded && context.activatedWorldInfo && Array.isArray(context.activatedWorldInfo) && context.activatedWorldInfo.length > 0) {
// console.log('[RPG Companion] Using fallback activatedWorldInfo:', context.activatedWorldInfo.length, 'entries');
context.activatedWorldInfo.forEach((entry) => {
if (entry && entry.content) {
systemMessage += `${entry.content}\n\n`;
worldInfoAdded = true;
}
});
}
if (!worldInfoAdded) {
console.warn('[RPG Companion] ⚠️ No world information available');
systemMessage += 'No world information available.';
}
systemMessage += `\n</setting>\n\n`;
// Add character information
const charactersInfo = await getCharactersInfo();
if (charactersInfo) {
systemMessage += `Here is the information available to you about the characters participating in the fight:\n`;
systemMessage += `<characters>\n${charactersInfo}</characters>\n\n`;
}
// Add persona information
systemMessage += `Here are details about the user's ${userName}:\n`;
systemMessage += `<persona>\n`;
try {
const personaText = substituteParams('{{persona}}');
if (personaText && personaText !== '{{persona}}') {
systemMessage += personaText;
} else {
systemMessage += 'No persona information available.';
}
} catch (e) {
systemMessage += 'No persona information available.';
}
systemMessage += `\n</persona>\n\n`;
// Add chat history from before the encounter
systemMessage += `Here is the chat history from before the encounter started between the user and the assistant:\n`;
systemMessage += `<history>\n`;
messages.push({
role: 'system',
content: systemMessage
});
// Add recent chat history (last X messages before encounter)
if (chat && chat.length > 0) {
const recentMessages = chat.slice(-depth - 1, -1); // Exclude the last message (encounter trigger)
for (const message of recentMessages) {
const content = message.mes?.trim();
// Skip empty messages
if (content) {
messages.push({
role: message.is_user ? 'user' : 'assistant',
content: content
});
}
}
// Add the encounter trigger message
const lastMessage = chat[chat.length - 1];
if (lastMessage && lastMessage.mes?.trim()) {
currentEncounter.encounterStartMessage = lastMessage.mes;
messages.push({
role: lastMessage.is_user ? 'user' : 'assistant',
content: lastMessage.mes.trim()
});
}
}
// Build user's current stats
let userStatsInfo = '';
// Add HP and other stats from committed tracker data
if (committedTrackerData.userStats) {
userStatsInfo += `${userName}'s Current Stats:\n${committedTrackerData.userStats}\n\n`;
}
// Add skills if available
const skillsSection = extensionSettings.trackerConfig?.userStats?.skillsSection;
if (skillsSection?.enabled && skillsSection.customFields && skillsSection.customFields.length > 0) {
userStatsInfo += `${userName}'s Skills: ${skillsSection.customFields.join(', ')}\n`;
}
// Add inventory
const inventory = extensionSettings.userStats?.inventory;
if (inventory) {
const inventorySummary = buildInventorySummary(inventory);
userStatsInfo += `${userName}'s Inventory:\n${inventorySummary}\n\n`;
}
// Add classic stats/attributes
if (extensionSettings.classicStats) {
const stats = extensionSettings.classicStats;
userStatsInfo += `${userName}'s Attributes: `;
const showLevel = extensionSettings.trackerConfig?.userStats?.showLevel !== false;
const levelStr = showLevel ? `, LVL ${extensionSettings.level}` : '';
userStatsInfo += `STR ${stats.str}, DEX ${stats.dex}, CON ${stats.con}, INT ${stats.int}, WIS ${stats.wis}, CHA ${stats.cha}${levelStr}\n\n`;
}
// Add present characters info for party members
let partyInfo = '';
if (committedTrackerData.characterThoughts) {
partyInfo += `Present Characters (potential party members):\n${committedTrackerData.characterThoughts}\n\n`;
}
// Close history and add combat initialization instruction
let initInstruction = `</history>\n\n`;
// Wrap RPG Companion panel data in context tags
initInstruction += `Here is some additional tracked context for the scene:\n`;
initInstruction += `<context>\n`;
initInstruction += userStatsInfo;
initInstruction += partyInfo;
initInstruction += `</context>\n\n`;
initInstruction += `The combat starts now.\n\n`;
initInstruction += `Based on everything above, generate the initial combat state. Analyze who is in the party fighting alongside ${userName} (if anyone), and who the enemies are. Replace placeholders in [brackets] and X with actual values. Return ONLY a JSON object with the following structure:\n\n`;
initInstruction += `FORMAT:\n`;
initInstruction += `{\n`;
initInstruction += ` "party": [\n`;
initInstruction += ` {\n`;
initInstruction += ` "name": "${userName}",\n`;
initInstruction += ` "hp": X,\n`;
initInstruction += ` "maxHp": X,\n`;
initInstruction += ` "attacks": [\n`;
initInstruction += ` {"name": "Attack", "type": "single-target|AoE|both"},\n`;
initInstruction += ` {"name": "Skill1", "type": "single-target|AoE|both"}\n`;
initInstruction += ` ],\n`;
initInstruction += ` "items": ["Item Name x3", "Another Item x1"],\n`;
initInstruction += ` "statuses": [],\n`;
initInstruction += ` "isPlayer": true\n`;
initInstruction += ` }\n`;
initInstruction += ` // Add other party members here if they exist in the context, changing isPlayer to false for them.\n`;
initInstruction += ` ],\n`;
initInstruction += ` "enemies": [\n`;
initInstruction += ` {\n`;
initInstruction += ` "name": "Enemy Name",\n`;
initInstruction += ` "hp": X,\n`;
initInstruction += ` "maxHp": X,\n`;
initInstruction += ` "attacks": [\n`;
initInstruction += ` {"name": "Attack1", "type": "single-target|AoE|both"},\n`;
initInstruction += ` {"name": "Attack2", "type": "single-target|AoE|both"}\n`;
initInstruction += ` ],\n`;
initInstruction += ` "statuses": [],\n`;
initInstruction += ` "description": "Brief enemy description",\n`;
initInstruction += ` "sprite": "emoji or brief visual description"\n`;
initInstruction += ` }\n`;
initInstruction += ` // Add all enemies participating in this combat\n`;
initInstruction += ` ],\n`;
initInstruction += ` "environment": "Brief description of the combat environment",\n`;
initInstruction += ` "styleNotes": {\n`;
initInstruction += ` "environmentType": "forest|dungeon|desert|cave|city|ruins|snow|water|castle|wasteland|plains|mountains|swamp|volcanic|spaceship|mansion",\n`;
initInstruction += ` "atmosphere": "bright|dark|foggy|stormy|calm|eerie|chaotic|peaceful",\n`;
initInstruction += ` "timeOfDay": "dawn|day|dusk|night|twilight",\n`;
initInstruction += ` "weather": "clear|rainy|snowy|windy|stormy|overcast"\n`;
initInstruction += ` }\n`;
initInstruction += `}\n\n`;
initInstruction += `IMPORTANT NOTES:\n`;
initInstruction += `- For attacks array: Each attack must be an object with "name" and "type" properties\n`;
initInstruction += ` - "single-target": Can only target one character (enemy or ally)\n`;
initInstruction += ` - "AoE": Area of Effect - targets all enemies, but some AoE attacks (like storms, explosions) can also harm allies if the attack is indiscriminate\n`;
initInstruction += ` - "both": Player can choose to target a single enemy OR use as AoE\n`;
initInstruction += `- For items array: Include quantities using format "Item Name xN" (e.g., "Health Potion x3", "Bomb x1")\n`;
initInstruction += ` - If only one item exists, you can use "Item Name x1" or just "Item Name"\n`;
initInstruction += ` - Items will be consumed when used - the quantity will decrease in future turns\n`;
initInstruction += `- Statuses array: May start empty, but don't have to if characters applied them before the combat\n`;
initInstruction += ` - Each status has a format: {"name": "Status Name", "emoji": "💀", "duration": X}\n`;
initInstruction += ` - Examples: Poisoned (🧪), Burning (🔥), Blessed (✨), Stunned (💫), Weakened (⬇️), Strengthened (⬆️)\n\n`;
initInstruction += `The styleNotes object will be used to visually style the combat window - choose ONE value from each category that best fits the environment described in the chat history.\n\n`;
initInstruction += `Use the user's current stats, inventory, and skills to populate the party data. For ${userName}'s attacks array, include their available skills. For items, include usable items from their inventory WITH QUANTITIES (e.g., "Health Potion x3"). Set HP based on their current Health stat if available.\n\n`;
initInstruction += `Ensure all party members and enemies have realistic HP values based on the setting and their descriptions. Return ONLY the JSON object, no other text.`;
// Only add the instruction if it has meaningful content
if (initInstruction.trim()) {
messages.push({
role: 'user',
content: initInstruction.trim()
});
}
// Validate that we have at least one message with content
if (messages.length === 0 || messages.every(m => !m.content || !m.content.trim())) {
throw new Error('Unable to build encounter prompt - no valid content available');
}
return messages;
}
/**
* Builds a combat action prompt
* This is sent when the user takes an action in combat
* @param {string} action - The action taken by the user
* @param {object} combatStats - Current combat statistics
* @returns {Array} Message array for the API
*/
export async function buildCombatActionPrompt(action, combatStats) {
const context = getContext();
const userName = context.name1;
const depth = extensionSettings.encounterSettings?.historyDepth || 8;
// Get narrative style from settings
const narrativeStyle = extensionSettings.encounterSettings?.combatNarrative || {};
const tense = narrativeStyle.tense || 'present';
const person = narrativeStyle.person || 'third';
const narration = narrativeStyle.narration || 'omniscient';
const pov = narrativeStyle.pov || 'narrator';
const messages = [];
// Build system message with setting info
let systemMessage = `You are the game master managing this combat encounter. You must not play as ${userName} - only describe what happens as a result of their actions/dialogues and control NPCs/enemies.\n\n`;
// Add setting information
systemMessage += `Here is some information for you about the setting:\n`;
systemMessage += `<setting>\n`;
// Get world info
let worldInfoAdded = false;
try {
const getWorldInfoFn = context.getWorldInfoPrompt || window.getWorldInfoPrompt;
const currentChat = context.chat || chat;
if (typeof getWorldInfoFn === 'function' && currentChat && currentChat.length > 0) {
const chatForWI = currentChat.map(x => x.mes || x.message || x).filter(m => m && typeof m === 'string');
const result = await getWorldInfoFn(chatForWI, 8000, false);
const worldInfoString = result?.worldInfoString || result;
if (worldInfoString && typeof worldInfoString === 'string' && worldInfoString.trim()) {
systemMessage += worldInfoString.trim();
worldInfoAdded = true;
}
}
} catch (e) {
console.warn('[RPG Companion] Failed to get world info for combat action:', e);
}
if (!worldInfoAdded && context.activatedWorldInfo && Array.isArray(context.activatedWorldInfo) && context.activatedWorldInfo.length > 0) {
context.activatedWorldInfo.forEach((entry) => {
if (entry && entry.content) {
systemMessage += `${entry.content}\n\n`;
worldInfoAdded = true;
}
});
}
if (!worldInfoAdded) {
systemMessage += 'No world information available.';
}
systemMessage += `\n</setting>\n\n`;
// Add character information
const charactersInfo = await getCharactersInfo();
if (charactersInfo) {
systemMessage += `Here is the information available to you about the characters:\n`;
systemMessage += `<characters>\n${charactersInfo}</characters>\n\n`;
}
// Add persona info
if (context.name1) {
systemMessage += `The protagonist is:\n`;
systemMessage += `<persona>\n`;
// Use substituteParams to get {{persona}} like in initial encounter
try {
const personaText = substituteParams('{{persona}}');
if (personaText && personaText !== '{{persona}}') {
systemMessage += personaText;
} else {
systemMessage += `Name: ${context.name1}\n`;
if (extensionSettings.userStats?.personaDescription) {
systemMessage += `${extensionSettings.userStats.personaDescription}\n`;
}
}
} catch (e) {
systemMessage += `Name: ${context.name1}\n`;
if (extensionSettings.userStats?.personaDescription) {
systemMessage += `${extensionSettings.userStats.personaDescription}\n`;
}
}
// Add ONLY classic stats/attributes if enabled
if (extensionSettings.classicStats) {
const stats = extensionSettings.classicStats;
const config = extensionSettings.trackerConfig?.userStats;
const rpgAttributes = (config?.rpgAttributes && config.rpgAttributes.length > 0) ? config.rpgAttributes : [
{ id: 'str', name: 'STR', enabled: true },
{ id: 'dex', name: 'DEX', enabled: true },
{ id: 'con', name: 'CON', enabled: true },
{ id: 'int', name: 'INT', enabled: true },
{ id: 'wis', name: 'WIS', enabled: true },
{ id: 'cha', name: 'CHA', enabled: true }
];
const enabledAttributes = rpgAttributes.filter(attr => attr && attr.enabled && attr.name && attr.id);
const attributeStrings = enabledAttributes.map(attr => `${attr.name} ${stats[attr.id] || 10}`);
systemMessage += `\nAttributes: ${attributeStrings.join(', ')}, LVL ${extensionSettings.level}\n`;
}
systemMessage += `</persona>\n\n`;
}
messages.push({
role: 'system',
content: systemMessage
});
// Add recent chat history for context - append as user/assistant messages like initial encounter
const currentChat = context.chat || chat;
if (currentChat && currentChat.length > 0) {
const recentMessages = currentChat.slice(-depth);
for (const message of recentMessages) {
const content = message.mes?.trim();
// Skip empty messages
if (content) {
messages.push({
role: message.is_user ? 'user' : 'assistant',
content: content
});
}
}
}
// Add combat log as plain text (previous actions)
if (currentEncounter.encounterLog && currentEncounter.encounterLog.length > 0) {
let combatHistory = 'Previous Combat Actions:\n';
currentEncounter.encounterLog.forEach(entry => {
combatHistory += `- ${entry.action}\n`;
if (entry.result) {
combatHistory += ` ${entry.result}\n`;
}
});
messages.push({
role: 'user',
content: combatHistory
});
}
// Add current combat state with FULL information (but tell AI not to regenerate static parts)
let stateMessage = `Current Combat State:\n`;
stateMessage += `Environment: ${combatStats.environment || 'Unknown location'}\n\n`;
stateMessage += `Party Members:\n`;
combatStats.party.forEach(member => {
stateMessage += `- ${member.name}${member.isPlayer ? ' (Player)' : ''}: ${member.hp}/${member.maxHp} HP\n`;
// For the player, use playerActions if available, otherwise fall back to member data
if (member.isPlayer && currentEncounter.playerActions) {
if (currentEncounter.playerActions.attacks && currentEncounter.playerActions.attacks.length > 0) {
stateMessage += ` Attacks: ${currentEncounter.playerActions.attacks.map(a => typeof a === 'string' ? a : a.name).join(', ')}\n`;
}
if (currentEncounter.playerActions.items && currentEncounter.playerActions.items.length > 0) {
stateMessage += ` Items: ${currentEncounter.playerActions.items.join(', ')}\n`;
}
} else {
// For non-player party members, use their own data
if (member.attacks && member.attacks.length > 0) {
stateMessage += ` Attacks: ${member.attacks.map(a => typeof a === 'string' ? a : a.name).join(', ')}\n`;
}
if (member.items && member.items.length > 0) {
stateMessage += ` Items: ${member.items.join(', ')}\n`;
}
}
if (member.statuses && member.statuses.length > 0) {
const validStatuses = member.statuses.filter(s => s && (s.emoji || s.name));
if (validStatuses.length > 0) {
stateMessage += ` Status Effects: ${validStatuses.map(s => `${s.emoji || ''} ${s.name || ''}`.trim()).join(', ')}\n`;
}
}
});
stateMessage += `\nEnemies:\n`;
combatStats.enemies.forEach(enemy => {
stateMessage += `- ${enemy.name} (${enemy.sprite || ''}): ${enemy.hp}/${enemy.maxHp} HP\n`;
if (enemy.description) {
stateMessage += ` ${enemy.description}\n`;
}
if (enemy.attacks && enemy.attacks.length > 0) {
stateMessage += ` Attacks: ${enemy.attacks.map(a => typeof a === 'string' ? a : a.name).join(', ')}\n`;
}
if (enemy.statuses && enemy.statuses.length > 0) {
const validStatuses = enemy.statuses.filter(s => s && (s.emoji || s.name));
if (validStatuses.length > 0) {
stateMessage += ` Status Effects: ${validStatuses.map(s => `${s.emoji || ''} ${s.name || ''}`.trim()).join(', ')}\n`;
}
}
});
stateMessage += `\n${userName}'s Action: ${action}\n\n`;
stateMessage += `Respond with the exact JSON object as below, containing ONLY these specified values. Remember to consider the user's party and their moves. DO NOT regenerate character descriptions, sprites, or environment.\n\n`;
stateMessage += `IMPORTANT - Update ${userName}'s attacks and items arrays based on what happens in combat:\n`;
stateMessage += `- ${userName}'s action is already specified above - do NOT regenerate it. Only update ${userName}'s attacks/items arrays if their action consumed resources (used item, lost ability, etc.).\n`;
stateMessage += `- If they use an item, decrement its quantity ("Health Potion x3" becomes "Health Potion x2"). If quantity reaches 0, remove the item entirely.\n`;
stateMessage += `- If they gain or lose an ability due to status effects, add or remove it from their attacks array.\n`;
stateMessage += ` Examples: Disarmed → remove weapon attacks. Bound → remove all attacks or set to []. Freed → restore attacks.\n`;
stateMessage += `- If they pick up a weapon/item during combat, add it to their items or attacks array.\n`;
stateMessage += `- If environmental changes enable new actions (near water → "Splash Attack"), add them. If they disable actions (fire goes out → remove "Ignite"), remove them.\n`;
stateMessage += `- Status effects should persist and decrease duration each turn. Remove statuses when duration reaches 0.\n\n`;
stateMessage += `FORMAT:\n`;
stateMessage += `{\n`;
stateMessage += ` "combatStats": {\n`;
stateMessage += ` "party": [\n`;
stateMessage += ` {\n`;
stateMessage += ` "name": "Name",\n`;
stateMessage += ` "hp": X,\n`;
stateMessage += ` "maxHp": X,\n`;
stateMessage += ` "statuses": [{"name": "Status", "emoji": "💀", "duration": X}],\n`;
stateMessage += ` "isPlayer": true|false\n`;
stateMessage += ` }\n`;
stateMessage += ` ],\n`;
stateMessage += ` "enemies": [\n`;
stateMessage += ` {\n`;
stateMessage += ` "name": "Name",\n`;
stateMessage += ` "hp": X,\n`;
stateMessage += ` "maxHp": X,\n`;
stateMessage += ` "statuses": [{"name": "Status", "emoji": "💀", "duration": X}]\n`;
stateMessage += ` }\n`;
stateMessage += ` ]\n`;
stateMessage += ` },\n`;
stateMessage += ` "playerActions": {\n`;
stateMessage += ` "attacks": [{"name": "Attack", "type": "single-target|AoE|both"}],\n`;
stateMessage += ` "items": ["Item Name x3", "Another Item x1"]\n`;
stateMessage += ` },\n`;
stateMessage += ` "enemyActions": [{ "enemyName": "Name", "action": "what they do", "target": "target" }],\n`;
stateMessage += ` "partyActions": [{ "memberName": "Name", "action": "what they do", "target": "target" }],\n`;
stateMessage += ` "narrative": "The roleplay description of what happens"\n`;
stateMessage += `}\n\n`;
stateMessage += `If all enemies are defeated or escape: add "combatEnd": true, "result": "victory". If all party defeated: add "combatEnd": true, "result": "defeat". It's also possible for the encounter to be interrupted by external interference (e.g., an explosion knocks everyone out, sudden environmental catastrophe, third party intervention, etc.). If this occurs, add "combatEnd": true, "result": "interrupted". Each status (if applied) has a format: {"name": "Status Name", "emoji": "💀", "duration": X}.\n`;
stateMessage += `Scale combat difficulty appropriately: Powerful entities (gods, dragons, legendary creatures) should be formidable challenges requiring multiple rounds and strategic play. Weaker foes (common animals, basic enemies, minions) should be resolved more quickly, typically 2-4 rounds. Match HP damage and combat pacing to the narrative weight of the encounter. A wolf should not take 20 rounds to defeat, nor should a deity fall in one hit.\n`;
stateMessage += `For the narrative, write it with intent in ${tense} tense ${person}-person ${narration} from ${pov}'s point of view.\n`;
// Use custom combat narrative prompt if available
const customCombatPrompt = extensionSettings.customCombatNarrativePrompt;
if (customCombatPrompt) {
stateMessage += customCombatPrompt.replace(/{userName}/g, userName) + '\n';
} else {
stateMessage += `Build novel prose. Break patterns from your previous responses by varying sentence structures, rhythms, formats, and openings. Track whether a sentence shape, cadence, sensory detail, or a descriptor appeared recently, and replace it with a different one or skip it entirely. If you mentioned a physical feature, mannerism, or expression once, move on. Don't fixate on the same traits every turn. Well-placed humor, unconventional similes, and subverted turns of phrase are encouraged. Avoid GPTisms, purple prose, and anaphoras, especially the negatives with "no/not/wasn't/didn't." Refrain from describing actions or emotions via negation; focus on what does happen, not what doesn't. Absolutely no asterisks, ellipses, or em-dashes. Explicit content is allowed, no plot armor. Do not play for ${userName}. Keep your response length under 150 words. Never end on handover cues; finish naturally.\n`;
stateMessage += `CRITICAL: Do not repeat, echo, parrot, or restate distinctive words, phrases, and dialogues from the user's last message. If reacting to speech, show interpretation or response, not repetition.\n`;
stateMessage += `EXAMPLE: "Are you a gooner?" User asks.\n`;
stateMessage += `BAD: "Gooner?"\n`;
stateMessage += `GOOD: A flat look. "What type of question is that?"`;
}
messages.push({
role: 'user',
content: stateMessage
});
return messages;
}
/**
* Builds the final summary prompt
* This is sent when combat ends to get a narrative summary
* @param {Array} combatLog - Full combat log
* @param {string} result - Combat result ('victory', 'defeat', or 'fled')
* @returns {Promise<Array>} Message array for the API
*/
export async function buildCombatSummaryPrompt(combatLog, result) {
const context = getContext();
const userName = context.name1;
const messages = [];
// Get narrative style from settings (use summary narrative settings)
const narrativeStyle = extensionSettings.encounterSettings?.summaryNarrative || {};
const tense = narrativeStyle.tense || 'past';
const person = narrativeStyle.person || 'third';
const narration = narrativeStyle.narration || 'omniscient';
const pov = narrativeStyle.pov || 'narrator';
// Build system message with setting info
let systemMessage = `You are summarizing a combat encounter that just concluded.\n\n`;
// Add setting information
systemMessage += `Here is some information for you about the setting:\n`;
systemMessage += `<setting>\n`;
// Get world info using the same method as encounter init
let worldInfoAdded = false;
try {
const getWorldInfoFn = context.getWorldInfoPrompt || window.getWorldInfoPrompt;
const currentChat = context.chat || chat;
if (typeof getWorldInfoFn === 'function' && currentChat && currentChat.length > 0) {
const chatForWI = currentChat.map(x => x.mes || x.message || x).filter(m => m && typeof m === 'string');
const result = await getWorldInfoFn(chatForWI, 8000, false);
const worldInfoString = result?.worldInfoString || result;
if (worldInfoString && typeof worldInfoString === 'string' && worldInfoString.trim()) {
systemMessage += worldInfoString.trim();
worldInfoAdded = true;
}
}
} catch (e) {
console.warn('[RPG Companion] Failed to get world info for summary:', e);
}
// Fallback to activatedWorldInfo
if (!worldInfoAdded && context.activatedWorldInfo && Array.isArray(context.activatedWorldInfo) && context.activatedWorldInfo.length > 0) {
context.activatedWorldInfo.forEach((entry) => {
if (entry && entry.content) {
systemMessage += `${entry.content}\n\n`;
worldInfoAdded = true;
}
});
}
if (!worldInfoAdded) {
systemMessage += 'No world information available.';
}
systemMessage += `\n</setting>\n\n`;
// Add character information
const charactersInfo = await getCharactersInfo();
if (charactersInfo) {
systemMessage += `Here is the information available to you about the characters:\n`;
systemMessage += `<characters>\n${charactersInfo}</characters>\n\n`;
}
// Add persona information
systemMessage += `Here are details about ${userName}:\n`;
systemMessage += `<persona>\n`;
try {
const personaText = substituteParams('{{persona}}');
if (personaText && personaText !== '{{persona}}') {
systemMessage += personaText;
} else {
systemMessage += 'No persona information available.';
}
} catch (e) {
systemMessage += 'No persona information available.';
}
systemMessage += `\n</persona>\n\n`;
// Add the message that triggered the encounter
if (currentEncounter.encounterStartMessage) {
systemMessage += `Here is the last message before combat started:\n`;
systemMessage += `<trigger>\n${currentEncounter.encounterStartMessage}\n</trigger>\n\n`;
}
messages.push({
role: 'system',
content: systemMessage
});
let summaryMessage = `Combat has ended with result: ${result}\n\n`;
summaryMessage += `Full Combat Log:\n`;
combatLog.forEach((entry, index) => {
summaryMessage += `\nRound ${index + 1}:\n`;
summaryMessage += `${entry.action}\n`;
summaryMessage += `${entry.result}\n`;
});
summaryMessage += `\n\nProvide a narrative summary of the entire fight in a way that fits the style from the chat history. Start with [FIGHT CONCLUDED] on the first line, then provide the description.\n\n`;
summaryMessage += `Write with intent in ${tense} tense ${person}-person ${narration} from ${pov}'s point of view.\n`;
summaryMessage += `Build novel prose. Break patterns from your previous responses by varying sentence structures, rhythms, formats, and openings. If you last started with a narration, begin this one with dialogue; if with an action, switch to an internal thought. Track whether a sentence shape, cadence, sensory detail, or a descriptor appeared recently, and replace it with a different one or skip it entirely. If you mentioned a physical feature, mannerism, or expression once, move on. Don't fixate on the same traits every turn. Well-placed humor, unconventional similes, and subverted turns of phrase are encouraged. Avoid GPTisms, purple prose, and anaphoras, especially the negatives with "no/not/wasn't/didn't." Refrain from describing actions or emotions via negation; focus on what does happen, not what doesn't. Minimize asterisks, ellipses, and em-dashes. Explicit content is allowed. Never end on handover cues; finish naturally.\n\n`;
summaryMessage += `Dialogue Guidelines:\n`;
summaryMessage += `- Include ALL dialogue lines spoken by enemies and NPC party members during the encounter in direct quotes.\n`;
summaryMessage += `- Never quote ${userName} directly. Express their actions and dialogue using ONLY indirect speech (e.g., "${userName} swung their sword" or "${userName} asked for help").\n\n`;
// If in Together mode and trackers are enabled, add tracker update instructions
if (extensionSettings.generationMode === 'together' && (extensionSettings.showUserStats || extensionSettings.showInfoBox || isPresentCharactersEnabled())) {
summaryMessage += `\n--- TRACKER UPDATE ---\n\n`;
summaryMessage += `After the [FIGHT CONCLUDED] summary, update the RPG trackers to reflect ${userName}'s state AFTER the combat encounter. `;
summaryMessage += `Account for any injuries sustained, resources used, emotional state changes, or other consequences of the battle.\n\n`;
// Include pre-combat tracker state if available
if (committedTrackerData.userStats || committedTrackerData.infoBox || committedTrackerData.characterThoughts) {
summaryMessage += `Pre-combat tracker state:\n`;
summaryMessage += `<previous>\n`;
if (committedTrackerData.userStats) {
const statsJSON = typeof committedTrackerData.userStats === 'object'
? JSON.stringify(committedTrackerData.userStats, null, 2)
: committedTrackerData.userStats;
summaryMessage += statsJSON + '\n';
}
if (committedTrackerData.infoBox) {
const infoBoxJSON = typeof committedTrackerData.infoBox === 'object'
? JSON.stringify(committedTrackerData.infoBox, null, 2)
: committedTrackerData.infoBox;
summaryMessage += infoBoxJSON + '\n';
}
if (committedTrackerData.characterThoughts) {
const charactersJSON = typeof committedTrackerData.characterThoughts === 'object'
? JSON.stringify(committedTrackerData.characterThoughts, null, 2)
: committedTrackerData.characterThoughts;
summaryMessage += charactersJSON + '\n';
}
summaryMessage += `</previous>\n\n`;
}
// Add tracker instructions and example
const trackerInstructions = generateTrackerInstructions(false, false, true);
summaryMessage += trackerInstructions;
const trackerExample = generateTrackerExample();
if (trackerExample) {
summaryMessage += `\n${trackerExample}`;
}
}
messages.push({
role: 'user',
content: summaryMessage
});
return messages;
}
/**
* Parses JSON response from the AI, handling code blocks
* @param {string} response - The AI response
* @returns {object|null} Parsed JSON object or null if parsing fails
*/
export function parseEncounterJSON(response) {
try {
// Ensure response is a string
if (!response || typeof response !== 'string') {
console.error('[RPG Companion] parseEncounterJSON received non-string input:', typeof response);
return null;
}
// Remove code blocks if present
let cleaned = response.trim();
// Remove ```json, ```markdown, and ``` markers (more comprehensive)
cleaned = cleaned.replace(/```(?:json|markdown)?\s*/gi, '');
// Remove any remaining backticks
cleaned = cleaned.replace(/`/g, '');
// Find the first { and last }
const firstBrace = cleaned.indexOf('{');
const lastBrace = cleaned.lastIndexOf('}');
if (firstBrace !== -1 && lastBrace !== -1) {
cleaned = cleaned.substring(firstBrace, lastBrace + 1);
} else {
console.error('[RPG Companion] No JSON object found in response');
return null;
}
// Try to parse directly first
try {
return JSON.parse(cleaned);
} catch (initialError) {
// If direct parsing fails, try JSON repair
console.warn('[RPG Companion] Initial parse failed, attempting JSON repair...');
const repaired = repairJSON(cleaned);
if (repaired) {
// console.log('[RPG Companion] ✓ Successfully repaired encounter JSON');
return repaired;
}
// If repair also failed, throw the original error
throw initialError;
}
} catch (error) {
console.error('[RPG Companion] Failed to parse encounter JSON:', error);
console.error('[RPG Companion] Response was:', response);
return null;
}
}
+770 -84
View File
@@ -4,21 +4,544 @@
*/
import { getContext } from '../../../../../../extensions.js';
import { setExtensionPrompt, extension_prompt_types, extension_prompt_roles } from '../../../../../../../script.js';
import { extension_prompt_types, extension_prompt_roles, setExtensionPrompt, eventSource, event_types } from '../../../../../../../script.js';
import {
extensionSettings,
committedTrackerData,
lastGeneratedData,
isGenerating,
lastActionWasSwipe,
setLastActionWasSwipe
lastActionWasSwipe
} from '../../core/state.js';
import { evaluateSuppression } from './suppression.js';
import { parseUserStats } from './parser.js';
import {
generateTrackerExample,
generateTrackerInstructions,
generateContextualSummary
generateContextualSummary,
formatHistoricalTrackerData,
DEFAULT_HTML_PROMPT,
DEFAULT_DIALOGUE_COLORING_PROMPT,
DEFAULT_DECEPTION_PROMPT,
DEFAULT_OMNISCIENCE_FILTER_PROMPT,
DEFAULT_CYOA_PROMPT,
DEFAULT_SPOTIFY_PROMPT,
DEFAULT_NARRATOR_PROMPT,
DEFAULT_CONTEXT_INSTRUCTIONS_PROMPT,
SPOTIFY_FORMAT_INSTRUCTION
} from './promptBuilder.js';
import { restoreCheckpointOnLoad } from '../features/chapterCheckpoint.js';
import { commitTrackerDataFromPriorMessage } from '../../core/persistence.js';
// Track suppression state for event handler
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();
// Flag to track if injection already happened in BEFORE_COMBINE
let historyInjectionDone = false;
/**
* Builds a map of historical context data from ST chat messages with rpg_companion_swipes data.
* Returns a map keyed by message index with formatted context strings.
* The index stored depends on the injection position setting.
*
* @returns {Map<number, string>} Map of target message index to formatted context string
*/
function buildHistoricalContextMap() {
const historyPersistence = extensionSettings.historyPersistence;
if (!historyPersistence || !historyPersistence.enabled) {
return new Map();
}
const context = getContext();
const chat = context.chat;
if (!chat || chat.length < 2) {
return new Map();
}
const trackerConfig = extensionSettings.trackerConfig;
const userName = context.name1;
const position = historyPersistence.injectionPosition || 'assistant_message_end';
const contextMap = new Map();
// Determine how many messages to include (0 = all available)
const messageCount = historyPersistence.messageCount || 0;
const maxMessages = messageCount === 0 ? chat.length : Math.min(messageCount, chat.length);
// Find the last assistant message - this is the one that gets current context via setExtensionPrompt
// We should NOT add historical context to it when injecting into assistant messages
// But when injecting into user messages, we DO need to process it to get context for the preceding user message
let lastAssistantIndex = -1;
for (let i = chat.length - 1; i >= 0; i--) {
if (!chat[i].is_user && !chat[i].is_system) {
lastAssistantIndex = i;
break;
}
}
// Iterate through messages to find those with tracker data
// For user_message_end: start from the last assistant message (we need its context for the preceding user message)
// For assistant_message_end: start from before the last assistant message (it gets current context via setExtensionPrompt)
let processedCount = 0;
const startIndex = position === 'user_message_end'
? lastAssistantIndex
: (lastAssistantIndex > 0 ? lastAssistantIndex - 1 : chat.length - 2);
for (let i = startIndex; i >= 0 && (messageCount === 0 || processedCount < maxMessages); i--) {
const message = chat[i];
// Skip system messages
if (message.is_system) {
continue;
}
// Only assistant messages have rpg_companion_swipes data
if (message.is_user) {
continue;
}
// Get the rpg_companion_swipes data for current swipe
// Data can be in two places:
// 1. message.extra.rpg_companion_swipes (current session, before save)
// 2. message.swipe_info[swipeId].extra.rpg_companion_swipes (loaded from file)
const currentSwipeId = message.swipe_id || 0;
let swipeData = message.extra?.rpg_companion_swipes;
// If not in message.extra, check swipe_info
if (!swipeData && message.swipe_info && message.swipe_info[currentSwipeId]) {
swipeData = message.swipe_info[currentSwipeId].extra?.rpg_companion_swipes;
}
if (!swipeData) {
continue;
}
const trackerData = swipeData[currentSwipeId];
if (!trackerData) {
continue;
}
// Format the historical tracker data using the shared function
const formattedContext = formatHistoricalTrackerData(trackerData, trackerConfig, userName);
if (!formattedContext) {
continue;
}
// Build the context wrapper
const preamble = historyPersistence.contextPreamble || 'Context for that moment:';
const wrappedContext = `\n${preamble}\n${formattedContext}`;
// Determine which message index to store based on injection position
let targetIndex = i; // Default: the assistant message itself
if (position === 'user_message_end') {
// Find the preceding user message before this assistant message
// This is the user message that prompted this assistant response
for (let j = i - 1; j >= 0; j--) {
if (chat[j].is_user && !chat[j].is_system) {
targetIndex = j;
break;
}
}
// If no user message found before, skip this one
if (targetIndex === i) {
continue;
}
}
// For assistant_message_end, extra_user_message, extra_assistant_message:
// We inject into the assistant message itself (for now - extra messages handled differently)
// Store the context keyed by target index
// If multiple assistant messages map to the same user message, append
if (contextMap.has(targetIndex)) {
contextMap.set(targetIndex, contextMap.get(targetIndex) + wrappedContext);
} else {
contextMap.set(targetIndex, wrappedContext);
}
processedCount++;
}
return contextMap;
}
/**
* Prepares historical context for injection into prompts.
* This builds the context map and stores it for use by prompt event handlers.
* Does NOT modify the original chat messages.
*/
function prepareHistoricalContextInjection() {
const historyPersistence = extensionSettings.historyPersistence;
if (!historyPersistence || !historyPersistence.enabled) {
pendingContextMap = new Map();
return;
}
if (currentSuppressionState || !extensionSettings.enabled) {
pendingContextMap = new Map();
return;
}
const context = getContext();
const chat = context.chat;
if (!chat || chat.length < 2) {
pendingContextMap = new Map();
historyInjectionDone = false;
return;
}
// Build and store the context map for use by prompt handlers
pendingContextMap = buildHistoricalContextMap();
historyInjectionDone = false; // Reset flag for new generation
}
/**
* Finds the best match position for message content in the prompt.
* Tries full content first, then progressively smaller suffixes.
*
* @param {string} prompt - The prompt to search in
* @param {string} messageContent - The message content to find
* @returns {{start: number, end: number}|null} - Position info or null if not found
*/
function findMessageInPrompt(prompt, messageContent) {
if (!messageContent || !prompt) {
return null;
}
// Try to find the full content first
let searchIndex = prompt.lastIndexOf(messageContent);
if (searchIndex !== -1) {
return { start: searchIndex, end: searchIndex + messageContent.length };
}
// If full content not found, try last N characters with progressively smaller chunks
// This handles cases where messages are truncated in the prompt
const searchLengths = [500, 300, 200, 100, 50];
for (const len of searchLengths) {
if (messageContent.length <= len) {
continue;
}
const searchContent = messageContent.slice(-len);
searchIndex = prompt.lastIndexOf(searchContent);
if (searchIndex !== -1) {
return { start: searchIndex, end: searchIndex + searchContent.length };
}
}
return null;
}
/**
* Injects historical context into a text completion prompt string.
* Searches for message content in the prompt and appends context after matches.
*
* @param {string} prompt - The text completion prompt
* @returns {string} - The modified prompt with injected context
*/
function injectContextIntoTextPrompt(prompt) {
if (pendingContextMap.size === 0) {
return prompt;
}
const context = getContext();
const chat = context.chat;
let modifiedPrompt = prompt;
let injectedCount = 0;
// Sort by message index descending so we inject from end to start
// This prevents position shifts from affecting earlier injections
const sortedEntries = Array.from(pendingContextMap.entries()).sort((a, b) => b[0] - a[0]);
// Process each message that needs context injection
for (const [msgIdx, ctxContent] of sortedEntries) {
const message = chat[msgIdx];
if (!message || typeof message.mes !== 'string') {
continue;
}
// Find the message content in the prompt
const position = findMessageInPrompt(modifiedPrompt, message.mes);
if (!position) {
// Message not found in prompt (might be truncated or not included)
console.debug(`[RPG Companion] Could not find message ${msgIdx} in prompt for context injection`);
continue;
}
// Insert the context after the message content
modifiedPrompt = modifiedPrompt.slice(0, position.end) + ctxContent + modifiedPrompt.slice(position.end);
injectedCount++;
}
if (injectedCount > 0) {
console.log(`[RPG Companion] Injected historical context into ${injectedCount} positions in text prompt`);
}
return modifiedPrompt;
}
/**
* Injects historical context into a chat completion message array.
* Modifies the content of messages in the array directly.
*
* @param {Array} chatMessages - The chat completion message array
* @returns {Array} - The modified message array with injected context
*/
function injectContextIntoChatPrompt(chatMessages) {
if (pendingContextMap.size === 0 || !Array.isArray(chatMessages)) {
return chatMessages;
}
const context = getContext();
const chat = context.chat;
let injectedCount = 0;
// Process each message that needs context injection
for (const [msgIdx, ctxContent] of pendingContextMap) {
const originalMessage = chat[msgIdx];
if (!originalMessage || typeof originalMessage.mes !== 'string') {
continue;
}
const messageContent = originalMessage.mes;
// Find this message in the chat completion array by matching content
// Try full content first, then progressively smaller suffixes
let found = false;
for (const promptMsg of chatMessages) {
if (!promptMsg.content || typeof promptMsg.content !== 'string') {
continue;
}
// Try full content match
if (promptMsg.content.includes(messageContent)) {
promptMsg.content = promptMsg.content + ctxContent;
injectedCount++;
found = true;
break;
}
// Try suffix matches for truncated messages
const searchLengths = [500, 300, 200, 100, 50];
for (const len of searchLengths) {
if (messageContent.length <= len) {
continue;
}
const searchContent = messageContent.slice(-len);
if (promptMsg.content.includes(searchContent)) {
promptMsg.content = promptMsg.content + ctxContent;
injectedCount++;
found = true;
break;
}
}
if (found) {
break;
}
}
if (!found) {
console.debug(`[RPG Companion] Could not find message ${msgIdx} in chat prompt for context injection`);
}
}
if (injectedCount > 0) {
console.log(`[RPG Companion] Injected historical context into ${injectedCount} messages in chat prompt`);
}
return chatMessages;
}
/**
* Injects historical context into finalMesSend message array (text completion).
* Iterates through chat and finalMesSend in order, matching by content to skip injected messages.
*
* @param {Array} finalMesSend - The array of message objects {message: string, extensionPrompts: []}
* @returns {number} - Number of injections made
*/
function injectContextIntoFinalMesSend(finalMesSend) {
if (pendingContextMap.size === 0 || !Array.isArray(finalMesSend) || finalMesSend.length === 0) {
return 0;
}
const context = getContext();
const chat = context.chat;
if (!chat || chat.length === 0) {
return 0;
}
let injectedCount = 0;
// Build a map from chat index to finalMesSend index by matching content in order
// This handles injected messages (author's note, OOC, etc.) that exist in finalMesSend but not in chat
const chatToMesSendMap = new Map();
let mesSendIdx = 0;
for (let chatIdx = 0; chatIdx < chat.length && mesSendIdx < finalMesSend.length; chatIdx++) {
const chatMsg = chat[chatIdx];
if (!chatMsg || chatMsg.is_system) {
continue;
}
const chatContent = chatMsg.mes || '';
// Look for this chat message in finalMesSend starting from current position
// Skip any finalMesSend entries that don't match (they're injected content)
while (mesSendIdx < finalMesSend.length) {
const mesSendObj = finalMesSend[mesSendIdx];
if (!mesSendObj || !mesSendObj.message) {
mesSendIdx++;
continue;
}
// Check if this finalMesSend message contains the chat content
// Use a substring match since instruct formatting adds prefixes/suffixes
// Match with sufficient content (first 50 chars or full message if shorter)
const matchContent = chatContent.length > 50
? chatContent.substring(0, 50)
: chatContent;
if (matchContent && mesSendObj.message.includes(matchContent)) {
// Found a match - record the mapping
chatToMesSendMap.set(chatIdx, mesSendIdx);
mesSendIdx++;
break;
}
// This finalMesSend entry doesn't match - it's injected content, skip it
mesSendIdx++;
}
}
// Now inject context using the map
for (const [chatIdx, ctxContent] of pendingContextMap) {
const targetMesSendIdx = chatToMesSendMap.get(chatIdx);
if (targetMesSendIdx === undefined) {
console.debug(`[RPG Companion] Chat message ${chatIdx} not found in finalMesSend mapping`);
continue;
}
const mesSendObj = finalMesSend[targetMesSendIdx];
if (!mesSendObj || !mesSendObj.message) {
continue;
}
// Append context to this message
mesSendObj.message = mesSendObj.message + ctxContent;
injectedCount++;
console.debug(`[RPG Companion] Injected context for chat[${chatIdx}] into finalMesSend[${targetMesSendIdx}]`);
}
return injectedCount;
}
/**
* Event handler for GENERATE_BEFORE_COMBINE_PROMPTS (text completion).
* Injects historical context into the finalMesSend array before prompt combination.
* This is more reliable than post-combine string searching.
*
* @param {Object} eventData - Event data with finalMesSend and other properties
*/
function onGenerateBeforeCombinePrompts(eventData) {
if (!eventData || !Array.isArray(eventData.finalMesSend)) {
return;
}
// Skip for OpenAI (uses chat completion)
if (eventData.api === 'openai') {
return;
}
// Only inject if we have pending context
if (pendingContextMap.size === 0) {
return;
}
const injectedCount = injectContextIntoFinalMesSend(eventData.finalMesSend);
if (injectedCount > 0) {
console.log(`[RPG Companion] Injected historical context into ${injectedCount} messages in finalMesSend`);
historyInjectionDone = true; // Mark as done to prevent double injection
}
}
/**
* Event handler for GENERATE_AFTER_COMBINE_PROMPTS (text completion).
* This is now a backup/fallback - primary injection happens in BEFORE_COMBINE.
* Also fixes newline spacing after </context> tag.
*
* @param {Object} eventData - Event data with prompt property
*/
function onGenerateAfterCombinePrompts(eventData) {
if (!eventData || typeof eventData.prompt !== 'string') {
return;
}
if (eventData.dryRun) {
return;
}
let didInjectHistory = false;
// Inject historical context if available and not already done
if (!historyInjectionDone && pendingContextMap.size > 0) {
// Fallback injection for edge cases where BEFORE_COMBINE didn't work
console.log('[RPG Companion] Using fallback string-based injection (AFTER_COMBINE)');
eventData.prompt = injectContextIntoTextPrompt(eventData.prompt);
didInjectHistory = true;
}
// Always fix newlines around context tags (whether we just injected or not)
eventData.prompt = eventData.prompt.replace(/<context>/g, '\n<context>');
eventData.prompt = eventData.prompt.replace(/<\/context>/g, '</context>\n');
}
/**
* Event handler for CHAT_COMPLETION_PROMPT_READY.
* Injects historical context into the chat message array.
* Also fixes newline spacing around <context> tags.
*
* @param {Object} eventData - Event data with chat property
*/
function onChatCompletionPromptReady(eventData) {
if (!eventData || !Array.isArray(eventData.chat)) {
return;
}
if (eventData.dryRun) {
return;
}
// Inject historical context if we have pending context
if (pendingContextMap.size > 0) {
eventData.chat = injectContextIntoChatPrompt(eventData.chat);
// DON'T clear pendingContextMap here - let it persist for other generations
// (e.g., prewarm extensions). It will be cleared on GENERATION_ENDED.
}
// Fix newlines around context tags for all messages
for (const message of eventData.chat) {
if (message.content && typeof message.content === 'string') {
message.content = message.content.replace(/<context>/g, '\n<context>');
message.content = message.content.replace(/<\/context>/g, '</context>\n');
}
}
}
/**
* Event handler for generation start.
@@ -26,8 +549,15 @@ import {
*
* @param {string} type - Event type
* @param {Object} data - Event data
* @param {boolean} dryRun - If true, this is a dry run (page reload, prompt preview, etc.) - skip all logic
*/
export function onGenerationStarted(type, data) {
export async function onGenerationStarted(type, data, dryRun) {
// Skip dry runs (page reload, prompt manager preview, etc.)
if (dryRun) {
// console.log('[RPG Companion] Skipping onGenerationStarted: dry run detected');
return;
}
// console.log('[RPG Companion] onGenerationStarted called');
// console.log('[RPG Companion] enabled:', extensionSettings.enabled);
// console.log('[RPG Companion] generationMode:', extensionSettings.generationMode);
@@ -35,70 +565,55 @@ export function onGenerationStarted(type, data) {
// console.log('[RPG Companion] Committed Prompt:', committedTrackerData);
// Skip tracker injection for image generation requests
if (data?.quietImage) {
// console.log('[RPG Companion] Detected image generation (quietImage=true), skipping tracker injection');
if (data?.quietImage || data?.quiet_image || data?.isImageGeneration) {
// console.log('[RPG Companion] Detected image generation, skipping tracker injection');
return;
}
if (!extensionSettings.enabled) {
// Extension is disabled - clear any existing prompts to ensure nothing is injected
setExtensionPrompt('rpg-companion-inject', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-example', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-dialogue-coloring', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-spotify', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false);
return;
}
const chat = getContext().chat;
const lastMessage = chat && chat.length > 0 ? chat[chat.length - 1] : null;
const context = getContext();
const chat = context.chat;
// Detect if a guided generation is active (GuidedGenerations and similar extensions
// inject an ephemeral 'instruct' injection into chatMetadata.script_injects).
// If present, we should avoid injecting RPG tracker instructions that ask
// the model to include stats/etc. This prevents conflicts when guided prompts
// are used (e.g., GuidedGenerations Extension).
// Evaluate suppression using the shared helper
const suppression = evaluateSuppression(extensionSettings, context, data);
const { shouldSuppress, skipMode, isGuidedGeneration, isImpersonationGeneration, hasQuietPrompt, instructContent, quietPromptRaw, matchedPattern } = suppression;
// For SEPARATE mode only: Check if we need to commit extension data
// BUT: Only do this for the MAIN generation, not the tracker update generation
// If isGenerating is true, this is the tracker update generation (second call), so skip flag logic
// console.log('[RPG Companion DEBUG] Before generating:', lastGeneratedData.characterThoughts, ' , committed - ', committedTrackerData.characterThoughts);
if (extensionSettings.generationMode === 'separate' && !isGenerating) {
if (!lastActionWasSwipe) {
// User sent a new message - commit lastGeneratedData before generation
// console.log('[RPG Companion] 📝 COMMIT: New message - committing lastGeneratedData');
// console.log('[RPG Companion] BEFORE commit - committedTrackerData:', {
// userStats: committedTrackerData.userStats ? 'exists' : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null'
// });
// console.log('[RPG Companion] BEFORE commit - lastGeneratedData:', {
// userStats: lastGeneratedData.userStats ? 'exists' : 'null',
// infoBox: lastGeneratedData.infoBox ? 'exists' : 'null',
// characterThoughts: lastGeneratedData.characterThoughts ? 'exists' : 'null'
// });
committedTrackerData.userStats = lastGeneratedData.userStats;
committedTrackerData.infoBox = lastGeneratedData.infoBox;
committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts;
// console.log('[RPG Companion] AFTER commit - committedTrackerData:', {
// userStats: committedTrackerData.userStats ? 'exists' : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null'
// });
if (shouldSuppress) {
// Debugging: indicate active suppression and which source triggered it
console.debug(`[RPG Companion] Suppression active (mode=${skipMode}). isGuided=${isGuidedGeneration}, isImpersonation=${isImpersonationGeneration}, hasQuietPrompt=${hasQuietPrompt} - skipping RPG tracker injections for this generation.`);
// Reset flag after committing (ready for next cycle)
} else {
// console.log('[RPG Companion] 🔄 SWIPE: Using existing committedTrackerData (no commit)');
// console.log('[RPG Companion] committedTrackerData:', {
// userStats: committedTrackerData.userStats ? 'exists' : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null'
// });
// Reset flag after using it (swipe generation complete, ready for next action)
}
// Also clear any existing RPG Companion prompts so they do not leak into this generation
// (e.g., previously set extension prompts should not be used alongside a guided prompt)
setExtensionPrompt('rpg-companion-inject', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-example', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-spotify', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false);
}
// For TOGETHER mode: Check if we need to commit extension data
// Same logic as separate mode - commit on new messages, keep existing data on swipes
if (extensionSettings.generationMode === 'together') {
if (!lastActionWasSwipe) {
// User sent a new message - commit lastGeneratedData before generation
// console.log('[RPG Companion] 📝 TOGETHER MODE COMMIT: New message - committing lastGeneratedData');
committedTrackerData.userStats = lastGeneratedData.userStats;
committedTrackerData.infoBox = lastGeneratedData.infoBox;
committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts;
} else {
// console.log('[RPG Companion] 🔄 TOGETHER MODE SWIPE: Using existing committedTrackerData (no commit)');
}
// Ensure checkpoint is applied before generation
await restoreCheckpointOnLoad();
// If this is a new generation (not a swipe and not the tracker update pass),
// commit the tracker data from the last assistant message (N-1 rule).
// Passing chat.length ensures we start searching backwards from the end of the chat,
// correctly finding the latest valid assistant state regardless of where the user message is.
if (!lastActionWasSwipe && !isGenerating) {
commitTrackerDataFromPriorMessage(chat ? chat.length : 0);
}
// Use the committed tracker data as source for generation
@@ -114,7 +629,10 @@ export function onGenerationStarted(type, data) {
if (extensionSettings.generationMode === 'together') {
// console.log('[RPG Companion] In together mode, generating prompts...');
const example = generateTrackerExample();
const exampleRaw = generateTrackerExample();
// Wrap example in ```json``` code blocks for consistency with format instructions
// Add only 1 newline after the closing ``` (ST adds its own newline when injecting)
const example = exampleRaw ? `\`\`\`json\n${exampleRaw}\n\`\`\`\n` : null;
// Don't include HTML prompt in instructions - inject it separately to avoid duplication on swipes
const instructions = generateTrackerInstructions(false, true);
@@ -145,7 +663,7 @@ export function onGenerationStarted(type, data) {
}
// If we have previous tracker data and found an assistant message, inject it as an assistant message
if (example && lastAssistantDepth > 0) {
if (!shouldSuppress && example && lastAssistantDepth > 0) {
setExtensionPrompt('rpg-companion-example', example, extension_prompt_types.IN_CHAT, lastAssistantDepth, false, extension_prompt_roles.ASSISTANT);
// console.log('[RPG Companion] Injected tracker example as assistant message at depth:', lastAssistantDepth);
} else {
@@ -153,17 +671,18 @@ export function onGenerationStarted(type, data) {
}
// Inject the instructions as a user message at depth 0 (right before generation)
// If this is a guided generation (user explicitly injected 'instruct'), skip adding
// our tracker instructions to avoid clobbering the guided prompt.
if (!shouldSuppress) {
setExtensionPrompt('rpg-companion-inject', instructions, extension_prompt_types.IN_CHAT, 0, false, extension_prompt_roles.USER);
}
// console.log('[RPG Companion] Injected RPG tracking instructions at depth 0 (right before generation)');
// Inject HTML prompt separately at depth 0 if enabled (prevents duplication on swipes)
if (extensionSettings.enableHtmlPrompt) {
const htmlPrompt = `\nIf appropriate, include inline HTML, CSS, and JS elements for creative, visual storytelling throughout your response:
- Use them liberally to depict any in-world content that can be visualized (screens, posters, books, signs, letters, logos, crests, seals, medallions, labels, etc.), with creative license for animations, 3D effects, pop-ups, dropdowns, websites, and so on.
- Style them thematically to match the theme (e.g., sleek for sci-fi, rustic for fantasy), ensuring text is visible.
- Embed all resources directly (e.g., inline SVGs) so nothing relies on external fonts or libraries.
- Place elements naturally in the narrative where characters would see or use them, with no limits on format or application.
- These HTML/CSS/JS elements must be rendered directly without enclosing them in code fences.`;
if (extensionSettings.enableHtmlPrompt && !shouldSuppress) {
// Use custom HTML prompt if set, otherwise use default
const htmlPromptText = extensionSettings.customHtmlPrompt || DEFAULT_HTML_PROMPT;
const htmlPrompt = `\n- ${htmlPromptText}\n`;
setExtensionPrompt('rpg-companion-html', htmlPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected HTML prompt at depth 0 for together mode');
@@ -171,44 +690,175 @@ export function onGenerationStarted(type, data) {
// Clear HTML prompt if disabled
setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false);
}
} else if (extensionSettings.generationMode === 'separate') {
// In SEPARATE mode, inject the contextual summary for main roleplay generation
// Inject Dialogue Coloring prompt separately at depth 0 if enabled
if (extensionSettings.enableDialogueColoring && !shouldSuppress) {
// Use custom Dialogue Coloring prompt if set, otherwise use default
const dialogueColoringPromptText = extensionSettings.customDialogueColoringPrompt || DEFAULT_DIALOGUE_COLORING_PROMPT;
const dialogueColoringPrompt = `\n- ${dialogueColoringPromptText}\n`;
setExtensionPrompt('rpg-companion-dialogue-coloring', dialogueColoringPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected Dialogue Coloring prompt at depth 0 for together mode');
} else {
// Clear Dialogue Coloring prompt if disabled
setExtensionPrompt('rpg-companion-dialogue-coloring', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject Deception System prompt separately at depth 0 if enabled
if (extensionSettings.enableDeceptionSystem && !shouldSuppress) {
// Use custom Deception prompt if set, otherwise use default
const deceptionPromptText = extensionSettings.customDeceptionPrompt || DEFAULT_DECEPTION_PROMPT;
const deceptionPrompt = `\n- ${deceptionPromptText}\n`;
setExtensionPrompt('rpg-companion-deception', deceptionPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected Deception System prompt at depth 0 for together mode');
} else {
// Clear Deception System prompt if disabled
setExtensionPrompt('rpg-companion-deception', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject Omniscience Filter prompt separately at depth 0 if enabled
if (extensionSettings.enableOmniscienceFilter && !shouldSuppress) {
// Use custom Omniscience Filter prompt if set, otherwise use default
const omnisciencePromptText = extensionSettings.customOmnisciencePrompt || DEFAULT_OMNISCIENCE_FILTER_PROMPT;
const omnisciencePrompt = `\n${omnisciencePromptText}\n`;
setExtensionPrompt('rpg-companion-omniscience', omnisciencePrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected Omniscience Filter prompt at depth 0 for together mode');
} else {
// Clear Omniscience Filter prompt if disabled
setExtensionPrompt('rpg-companion-omniscience', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject Spotify prompt separately at depth 0 if enabled
if (extensionSettings.enableSpotifyMusic && !shouldSuppress) {
// Use custom Spotify prompt if set, otherwise use default
const spotifyPromptText = extensionSettings.customSpotifyPrompt || DEFAULT_SPOTIFY_PROMPT;
const spotifyPrompt = `\n- ${spotifyPromptText} ${SPOTIFY_FORMAT_INSTRUCTION}\n`;
setExtensionPrompt('rpg-companion-spotify', spotifyPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected Spotify prompt at depth 0 for together mode');
} else {
// Clear Spotify prompt if disabled
setExtensionPrompt('rpg-companion-spotify', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject CYOA prompt separately at depth 0 if enabled (injected last to appear last in prompt)
if (extensionSettings.enableCYOA && !shouldSuppress) {
// Use custom CYOA prompt if set, otherwise use default
const cyoaPromptText = extensionSettings.customCYOAPrompt || DEFAULT_CYOA_PROMPT;
const cyoaPrompt = `\n- ${cyoaPromptText}\n`;
setExtensionPrompt('rpg-companion-zzz-cyoa', cyoaPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected CYOA prompt at depth 0 for together mode');
} else {
// Clear CYOA prompt if disabled
setExtensionPrompt('rpg-companion-zzz-cyoa', '', extension_prompt_types.IN_CHAT, 0, false);
}
} else if (extensionSettings.generationMode === 'separate' || extensionSettings.generationMode === 'external') {
// In SEPARATE and EXTERNAL modes, inject the contextual summary for main roleplay generation
const contextSummary = generateContextualSummary();
if (contextSummary) {
const wrappedContext = `Here is context information about the current scene, and what follows is the last message in the chat history:
// Use custom context instructions prompt if set, otherwise use default
const contextInstructionsText = extensionSettings.customContextInstructionsPrompt || DEFAULT_CONTEXT_INSTRUCTIONS_PROMPT;
const wrappedContext = `
<context>
${contextSummary}
Ensure these details naturally reflect and influence the narrative. Character behavior, dialogue, and story events should acknowledge these conditions when relevant, such as fatigue affecting performance, low hygiene influencing social interactions, environmental factors shaping the scene, or a character's emotional state coloring their responses.
</context>
`;
${contextInstructionsText}
</context>`;
// Inject context at depth 1 (before last user message) as SYSTEM
// Skip when a guided generation injection is present to avoid conflicting instructions
if (!shouldSuppress) {
setExtensionPrompt('rpg-companion-context', wrappedContext, extension_prompt_types.IN_CHAT, 1, false);
// console.log('[RPG Companion] Injected contextual summary for separate mode:', contextSummary);
}
// console.log('[RPG Companion] Injected contextual summary for separate/external mode:', contextSummary);
} else {
// Clear if no data yet
setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false);
}
// Inject HTML prompt separately at depth 0 if enabled (same as together mode pattern)
if (extensionSettings.enableHtmlPrompt) {
const htmlPrompt = `\nIf appropriate, include inline HTML, CSS, and JS elements for creative, visual storytelling throughout your response:
- Use them liberally to depict any in-world content that can be visualized (screens, posters, books, signs, letters, logos, crests, seals, medallions, labels, etc.), with creative license for animations, 3D effects, pop-ups, dropdowns, websites, and so on.
- Style them thematically to match the theme (e.g., sleek for sci-fi, rustic for fantasy), ensuring text is visible.
- Embed all resources directly (e.g., inline SVGs) so nothing relies on external fonts or libraries.
- Place elements naturally in the narrative where characters would see or use them, with no limits on format or application.
- These HTML/CSS/JS elements must be rendered directly without enclosing them in code fences.`;
if (extensionSettings.enableHtmlPrompt && !shouldSuppress) {
// Use custom HTML prompt if set, otherwise use default
const htmlPromptText = extensionSettings.customHtmlPrompt || DEFAULT_HTML_PROMPT;
const htmlPrompt = `\n- ${htmlPromptText}\n`;
setExtensionPrompt('rpg-companion-html', htmlPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected HTML prompt at depth 0 for separate mode');
// console.log('[RPG Companion] Injected HTML prompt at depth 0 for separate/external mode');
} else {
// Clear HTML prompt if disabled
setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject Dialogue Coloring prompt separately at depth 0 if enabled
if (extensionSettings.enableDialogueColoring && !shouldSuppress) {
// Use custom Dialogue Coloring prompt if set, otherwise use default
const dialogueColoringPromptText = extensionSettings.customDialogueColoringPrompt || DEFAULT_DIALOGUE_COLORING_PROMPT;
const dialogueColoringPrompt = `\n- ${dialogueColoringPromptText}\n`;
setExtensionPrompt('rpg-companion-dialogue-coloring', dialogueColoringPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected Dialogue Coloring prompt at depth 0 for separate/external mode');
} else {
// Clear Dialogue Coloring prompt if disabled
setExtensionPrompt('rpg-companion-dialogue-coloring', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject Deception System prompt separately at depth 0 if enabled
if (extensionSettings.enableDeceptionSystem && !shouldSuppress) {
// Use custom Deception prompt if set, otherwise use default
const deceptionPromptText = extensionSettings.customDeceptionPrompt || DEFAULT_DECEPTION_PROMPT;
const deceptionPrompt = `\n- ${deceptionPromptText}\n`;
setExtensionPrompt('rpg-companion-deception', deceptionPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected Deception System prompt at depth 0 for separate/external mode');
} else {
// Clear Deception System prompt if disabled
setExtensionPrompt('rpg-companion-deception', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject Omniscience Filter prompt separately at depth 0 if enabled
if (extensionSettings.enableOmniscienceFilter && !shouldSuppress) {
// Use custom Omniscience Filter prompt if set, otherwise use default
const omnisciencePromptText = extensionSettings.customOmnisciencePrompt || DEFAULT_OMNISCIENCE_FILTER_PROMPT;
const omnisciencePrompt = `\n${omnisciencePromptText}\n`;
setExtensionPrompt('rpg-companion-omniscience', omnisciencePrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected Omniscience Filter prompt at depth 0 for separate/external mode');
} else {
// Clear Omniscience Filter prompt if disabled
setExtensionPrompt('rpg-companion-omniscience', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject Spotify prompt separately at depth 0 if enabled
if (extensionSettings.enableSpotifyMusic && !shouldSuppress) {
// Use custom Spotify prompt if set, otherwise use default
const spotifyPromptText = extensionSettings.customSpotifyPrompt || DEFAULT_SPOTIFY_PROMPT;
const spotifyPrompt = `\n- ${spotifyPromptText} ${SPOTIFY_FORMAT_INSTRUCTION}\n`;
setExtensionPrompt('rpg-companion-spotify', spotifyPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected Spotify prompt at depth 0 for separate/external mode');
} else {
// Clear Spotify prompt if disabled
setExtensionPrompt('rpg-companion-spotify', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject CYOA prompt separately at depth 0 if enabled (injected last to appear last in prompt)
if (extensionSettings.enableCYOA && !shouldSuppress) {
// Use custom CYOA prompt if set, otherwise use default
const cyoaPromptText = extensionSettings.customCYOAPrompt || DEFAULT_CYOA_PROMPT;
const cyoaPrompt = `\n- ${cyoaPromptText}\n`;
setExtensionPrompt('rpg-companion-zzz-cyoa', cyoaPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected CYOA prompt at depth 0 for separate/external mode');
} else {
// Clear CYOA prompt if disabled
setExtensionPrompt('rpg-companion-zzz-cyoa', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Clear together mode injections
setExtensionPrompt('rpg-companion-inject', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-example', '', extension_prompt_types.IN_CHAT, 0, false);
@@ -217,5 +867,41 @@ Ensure these details naturally reflect and influence the narrative. Character be
setExtensionPrompt('rpg-companion-inject', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-example', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false);
setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-dialogue-coloring', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-deception', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-omniscience', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-zzz-cyoa', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-spotify', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Set suppression state for the historical context injection
currentSuppressionState = shouldSuppress;
// Prepare historical context for injection into prompts
// This builds the context map but does NOT modify original chat messages
// The persistent event listeners will inject it into all prompts until cleared
prepareHistoricalContextInjection();
}
/**
* Initialize the history injection event listeners.
* These are persistent listeners that inject context into ALL generations
* while pendingContextMap has data. Should be called once at extension init.
*/
export function initHistoryInjectionListeners() {
// Register persistent listeners for prompt injection
// These check pendingContextMap and only inject if there's data
// Primary: BEFORE_COMBINE for text completion (more reliable - modifies message objects)
eventSource.on(event_types.GENERATE_BEFORE_COMBINE_PROMPTS, onGenerateBeforeCombinePrompts);
// Fallback: AFTER_COMBINE for text completion (string-based injection)
eventSource.on(event_types.GENERATE_AFTER_COMBINE_PROMPTS, onGenerateAfterCombinePrompts);
// Chat completion (OpenAI, etc.)
eventSource.on(event_types.CHAT_COMPLETION_PROMPT_READY, onChatCompletionPromptReady);
console.log('[RPG Companion] History injection listeners initialized');
}
+10
View File
@@ -28,6 +28,7 @@ export function extractInventoryData(statsText) {
const result = {
version: 2,
onPerson: "None",
clothing: "None",
stored: {},
assets: "None"
};
@@ -48,6 +49,14 @@ export function extractInventoryData(statsText) {
continue;
}
// Parse "Clothing: ..." line
const clothingMatch = trimmed.match(/^Clothing:\s*(.+)$/i);
if (clothingMatch) {
result.clothing = clothingMatch[1].trim() || "None";
foundAnyInventoryData = true;
continue;
}
// Parse "Stored - [Location]: ..." lines
const storedMatch = trimmed.match(/^Stored\s*-\s*([^:]+):\s*(.+)$/i);
if (storedMatch) {
@@ -122,6 +131,7 @@ export function extractInventory(statsText) {
return {
version: 2,
onPerson: v1Data,
clothing: "None",
stored: {},
assets: "None"
};
+249
View File
@@ -0,0 +1,249 @@
/**
* JSON Prompt Builder Helpers
* Helper functions for building JSON format tracker prompts
*/
import { extensionSettings, committedTrackerData } from '../../core/state.js';
import { getContext } from '../../../../../../extensions.js';
import { getWeatherKeywordsAsPromptString } from '../ui/weatherEffects.js';
import { i18n } from '../../core/i18n.js';
/**
* Converts a field name to snake_case for use as JSON key
* Example: "Test Tracker" -> "test_tracker"
* @param {string} name - Field name to convert
* @returns {string} snake_case version
*/
function toSnakeCase(name) {
return name
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '_')
.replace(/^_+|_+$/g, '');
}
/**
* Extracts the base name (before parentheses) and converts to snake_case for use as JSON key.
* Parenthetical content is treated as a description/hint, not part of the key.
* Example: "Conditions (up to 5 traits)" -> "conditions"
* Example: "Status Effects" -> "status_effects"
* @param {string} name - Field name, possibly with parenthetical description
* @returns {string} snake_case key from the base name only
*/
function toFieldKey(name) {
const baseName = name.replace(/\s*\(.*\)\s*$/, '').trim();
return toSnakeCase(baseName);
}
/**
* Builds User Stats JSON format instruction
* @returns {string} JSON format instruction for user stats
*/
export function buildUserStatsJSONInstruction() {
const userName = getContext().name1;
const trackerConfig = extensionSettings.trackerConfig;
const userStatsConfig = trackerConfig?.userStats;
const enabledStats = userStatsConfig?.customStats?.filter(s => s && s.enabled && s.name) || [];
const displayMode = userStatsConfig?.statsDisplayMode || 'percentage';
let instruction = '{\n';
instruction += ' "stats": [\n';
// Add stats dynamically
for (let i = 0; i < enabledStats.length; i++) {
const stat = enabledStats[i];
const comma = i < enabledStats.length - 1 ? ',' : '';
if (displayMode === 'number') {
const maxValue = stat.maxValue || 100;
instruction += ` {"id": "${stat.id}", "name": "${stat.name}", "value": X}${comma} // 0 to ${maxValue}\n`;
} else {
instruction += ` {"id": "${stat.id}", "name": "${stat.name}", "value": X}${comma} // 0 to 100 (percentage)\n`;
}
}
instruction += ' ],\n';
// Status section
if (userStatsConfig?.statusSection?.enabled) {
instruction += ' "status": {\n';
if (userStatsConfig.statusSection.showMoodEmoji) {
instruction += ' "mood": "Mood Emoji"';
}
// Add all custom status fields
const customFields = userStatsConfig.statusSection.customFields || [];
if (customFields.length > 0) {
for (let i = 0; i < customFields.length; i++) {
const fieldName = customFields[i].toLowerCase();
const fieldKey = toFieldKey(fieldName);
const comma = (i === customFields.length - 1 && !userStatsConfig.statusSection.showMoodEmoji) ? '' : (userStatsConfig.statusSection.showMoodEmoji || i < customFields.length - 1 ? ',\n' : '\n');
if (i === 0 && userStatsConfig.statusSection.showMoodEmoji) {
instruction += ',\n';
}
instruction += ` "${fieldKey}": "[${fieldName}]"${comma}`;
}
}
if (!userStatsConfig.statusSection.showMoodEmoji && customFields.length > 0) {
instruction += '\n';
}
instruction += ' },\n';
}
// Skills section
if (userStatsConfig?.skillsSection?.enabled) {
instruction += ' "skills": [\n';
instruction += ' {"name": "Skill1"},\n';
instruction += ' {"name": "Skill2"}\n';
instruction += ' ],\n';
}
// Inventory section
if (extensionSettings.showInventory) {
instruction += ' "inventory": {\n';
instruction += ' "onPerson": [\n';
instruction += ' {"name": "Item1", "quantity": X},\n';
instruction += ' {"name": "Item2", "quantity": X}\n';
instruction += ' ],\n';
instruction += ' "clothing": [\n';
instruction += ' {"name": "Clothing1"}\n';
instruction += ' ],\n';
instruction += ' "stored": {\n';
instruction += ' "Location1": [\n';
instruction += ' {"name": "Item", "quantity": X}\n';
instruction += ' ]\n';
instruction += ' },\n';
instruction += ' "assets": [\n';
instruction += ' {"name": "Asset1", "location": "Location"}\n';
instruction += ' ]\n';
instruction += ' },\n';
}
// Quests section
instruction += ' "quests": {\n';
instruction += ' "main": {"title": "Quest title"},\n';
instruction += ' "optional": [\n';
instruction += ' {"title": "Quest1"},\n';
instruction += ' {"title": "Quest2"}\n';
instruction += ' ]\n';
instruction += ' }\n';
instruction += '}';
return instruction;
}
/**
* Builds Info Box JSON format instruction
* @returns {string} JSON format instruction for info box
*/
export function buildInfoBoxJSONInstruction() {
const infoBoxConfig = extensionSettings.trackerConfig?.infoBox;
const widgets = infoBoxConfig?.widgets || {};
let instruction = '{\n';
let hasFields = false;
if (widgets.date?.enabled) {
const dateFormat = widgets.date.format || 'Weekday, Month, Year';
instruction += ` "date": {"value": "${dateFormat}"}`;
hasFields = true;
}
if (widgets.weather?.enabled) {
// Get valid weather keywords for the current language to guide LLM generation
const currentLang = i18n.currentLanguage || 'en';
const weatherHint = getWeatherKeywordsAsPromptString(currentLang);
instruction += (hasFields ? ',\n' : '') + ` "weather": {"emoji": "Weather Emoji", "forecast": "Forecast"} // ${weatherHint}`;
hasFields = true;
}
if (widgets.temperature?.enabled) {
const unit = widgets.temperature.unit === 'F' ? 'F' : 'C';
instruction += (hasFields ? ',\n' : '') + ` "temperature": {"value": X, "unit": "${unit}"}`;
hasFields = true;
}
if (widgets.time?.enabled) {
instruction += (hasFields ? ',\n' : '') + ' "time": {"start": "TimeStart", "end": "TimeEnd"}';
hasFields = true;
}
if (widgets.location?.enabled) {
instruction += (hasFields ? ',\n' : '') + ' "location": {"value": "Location"}';
hasFields = true;
}
if (widgets.recentEvents?.enabled) {
instruction += (hasFields ? ',\n' : '') + ' "recentEvents": ["Event1", "Event2", "Event3"]';
hasFields = true;
}
instruction += '\n}';
return instruction;
}
/**
* Builds Present Characters JSON format instruction
* @returns {string} JSON format instruction for present characters
*/
export function buildCharactersJSONInstruction() {
const userName = getContext().name1;
const presentCharsConfig = extensionSettings.trackerConfig?.presentCharacters;
const enabledFields = presentCharsConfig?.customFields?.filter(f => f && f.enabled && f.name) || [];
const relationshipsEnabled = presentCharsConfig?.relationships?.enabled !== false;
const thoughtsConfig = presentCharsConfig?.thoughts;
const characterStats = presentCharsConfig?.characterStats;
const enabledCharStats = characterStats?.enabled && characterStats?.customStats?.filter(s => s && s.enabled && s.name) || [];
let instruction = '[\n';
instruction += ' {\n';
instruction += ' "name": "CharacterName",\n';
instruction += ' "emoji": "Character Emoji"';
// Details fields
if (enabledFields.length > 0) {
instruction += ',\n "details": {\n';
for (let i = 0; i < enabledFields.length; i++) {
const field = enabledFields[i];
const fieldKey = toSnakeCase(field.name);
const comma = i < enabledFields.length - 1 ? ',' : '';
instruction += ` "${fieldKey}": "${field.description}"${comma}\n`;
}
instruction += ' }';
}
// Relationship
if (relationshipsEnabled) {
const relationshipFields = presentCharsConfig?.relationshipFields || [];
const options = relationshipFields.join('/');
instruction += ',\n "relationship": {"status": "(choose one: ' + options + ')"}';
}
// Stats
if (enabledCharStats.length > 0) {
instruction += ',\n "stats": [\n';
for (let i = 0; i < enabledCharStats.length; i++) {
const stat = enabledCharStats[i];
const comma = i < enabledCharStats.length - 1 ? ',' : '';
instruction += ` {"name": "${stat.name}", "value": X}${comma}\n`;
}
instruction += ' ]';
}
// Thoughts
if (thoughtsConfig?.enabled) {
const thoughtsDescription = thoughtsConfig.description || 'Internal monologue';
instruction += `,\n "thoughts": {"content": "${thoughtsDescription}"}`;
}
instruction += '\n }\n';
instruction += ']';
return instruction;
}
/**
* Adds lock information to instruction text
* @param {string} baseInstruction - Base instruction text
* @returns {string} Instruction with lock information added
*/
export function addLockInstruction(baseInstruction) {
return baseInstruction + '\n\nIMPORTANT: If an item, stat, quest, or field has "locked": true in its object, you MUST NOT change its value. Keep it exactly as it appears in the previous trackers. Only unlocked items can be modified. The "locked" field should ONLY be included if the item is actually locked - omit it for unlocked items.';
}
+466
View File
@@ -0,0 +1,466 @@
/**
* Lock Manager
* Handles applying and removing locks for tracker items
* Locks prevent AI from modifying specific values
*/
import { extensionSettings } from '../../core/state.js';
import { repairJSON } from '../../utils/jsonRepair.js';
/**
* Apply locks to tracker data before sending to AI.
* Adds "locked": true to locked items in JSON format.
*
* @param {string} trackerData - JSON string of tracker data
* @param {string} trackerType - Type of tracker ('userStats', 'infoBox', 'characters')
* @returns {string} Tracker data with locks applied
*/
export function applyLocks(trackerData, trackerType) {
if (!trackerData) return trackerData;
// Try to parse as JSON
const parsed = repairJSON(trackerData);
if (!parsed) {
// Not JSON format, return as-is (text format doesn't support locks)
return trackerData;
}
// Get locked items for this tracker type
const lockedItems = extensionSettings.lockedItems?.[trackerType] || {};
// Apply locks based on tracker type
switch (trackerType) {
case 'userStats':
return applyUserStatsLocks(parsed, lockedItems);
case 'infoBox':
return applyInfoBoxLocks(parsed, lockedItems);
case 'characters':
return applyCharactersLocks(parsed, lockedItems);
default:
return trackerData;
}
}
/**
* Apply locks to User Stats tracker
* @param {Object} data - Parsed user stats data
* @param {Object} lockedItems - Locked items configuration
* @returns {string} JSON string with locks applied
*/
function applyUserStatsLocks(data, lockedItems) {
// Lock individual stats within stats object
if (data.stats && lockedItems.stats) {
// Handle both section lock and individual stat locks
const isStatsLocked = lockedItems.stats === true;
if (isStatsLocked) {
// Lock entire stats section
for (const statName in data.stats) {
data.stats[statName] = {
value: data.stats[statName].value || data.stats[statName],
locked: true
};
}
} else {
// Lock individual stats
for (const statName in lockedItems.stats) {
if (lockedItems.stats[statName] && data.stats[statName] !== undefined) {
data.stats[statName] = {
value: data.stats[statName].value || data.stats[statName],
locked: true
};
}
}
}
}
// Lock status field
if (data.status && lockedItems.status) {
data.status = {
...data.status,
locked: true
};
}
// Lock individual skills
if (data.skills && lockedItems.skills) {
if (Array.isArray(data.skills)) {
data.skills = data.skills.map(skill => {
if (typeof skill === 'string') {
if (lockedItems.skills[skill]) {
return { name: skill, locked: true };
}
return skill;
} else if (skill.name && lockedItems.skills[skill.name]) {
return { ...skill, locked: true };
}
return skill;
});
}
}
// Lock inventory items - match by item name instead of index
if (data.inventory && lockedItems.inventory) {
// Helper function to apply locks based on item name
const applyInventoryLocks = (items, category) => {
if (!Array.isArray(items)) return items;
if (!lockedItems.inventory[category]) return items;
return items.map((item) => {
// Get item name (handle both string and object formats)
const itemName = typeof item === 'string' ? item : (item.item || item.name || '');
// Check if this specific item name is locked
if (lockedItems.inventory[category][itemName]) {
return typeof item === 'string'
? { item, locked: true }
: { ...item, locked: true };
}
return item;
});
};
// Apply locks to onPerson items
if (data.inventory.onPerson) {
data.inventory.onPerson = applyInventoryLocks(data.inventory.onPerson, 'onPerson');
}
// Apply locks to clothing items
if (data.inventory.clothing) {
data.inventory.clothing = applyInventoryLocks(data.inventory.clothing, 'clothing');
}
// Apply locks to assets
if (data.inventory.assets) {
data.inventory.assets = applyInventoryLocks(data.inventory.assets, 'assets');
}
// Apply locks to stored items - match by item name
if (data.inventory.stored && lockedItems.inventory.stored) {
for (const location in data.inventory.stored) {
if (Array.isArray(data.inventory.stored[location]) && lockedItems.inventory.stored[location]) {
data.inventory.stored[location] = data.inventory.stored[location].map((item) => {
const itemName = typeof item === 'string' ? item : (item.item || item.name || '');
if (lockedItems.inventory.stored[location][itemName]) {
return typeof item === 'string'
? { item, locked: true }
: { ...item, locked: true };
}
return item;
});
}
}
}
}
// Lock individual quests - handle paths like "quests.main" and "quests.optional[0]"
if (data.quests && lockedItems.quests) {
// Check if main quest is locked (entire section)
if (data.quests.main && lockedItems.quests.main === true) {
data.quests.main = { value: data.quests.main, locked: true };
}
// Check individual optional quests
if (data.quests.optional && Array.isArray(data.quests.optional)) {
data.quests.optional = data.quests.optional.map((quest, index) => {
const bracketPath = `optional[${index}]`;
if (lockedItems.quests[bracketPath]) {
return typeof quest === 'string'
? { title: quest, locked: true }
: { ...quest, locked: true };
}
return quest;
});
}
}
return JSON.stringify(data, null, 2);
}
/**
* Apply locks to Info Box tracker
* @param {Object} data - Parsed info box data
* @param {Object} lockedItems - Locked items configuration
* @returns {string} JSON string with locks applied
*/
function applyInfoBoxLocks(data, lockedItems) {
if (lockedItems.date && data.date) {
data.date = { ...data.date, locked: true };
}
if (lockedItems.weather && data.weather) {
data.weather = { ...data.weather, locked: true };
}
if (lockedItems.temperature && data.temperature) {
data.temperature = { ...data.temperature, locked: true };
}
if (lockedItems.time && data.time) {
data.time = { ...data.time, locked: true };
}
if (lockedItems.location && data.location) {
data.location = { ...data.location, locked: true };
}
if (lockedItems.recentEvents && data.recentEvents) {
data.recentEvents = { ...data.recentEvents, locked: true };
}
return JSON.stringify(data, null, 2);
}
/**
* Apply locks to Characters tracker
* @param {Object} data - Parsed characters data
* @param {Object} lockedItems - Locked items configuration
* @returns {string} JSON string with locks applied
*/
function applyCharactersLocks(data, lockedItems) {
// console.log('[Lock Manager] applyCharactersLocks called');
// console.log('[Lock Manager] Locked items:', JSON.stringify(lockedItems, null, 2));
// console.log('[Lock Manager] Input data:', JSON.stringify(data, null, 2));
// Handle both array format and object format
let characters = Array.isArray(data) ? data : (data.characters || []);
characters = characters.map((char, index) => {
const charName = char.name || char.characterName;
// Check if entire character is locked (index-based)
if (lockedItems[index] === true) {
// console.log('[Lock Manager] Locking entire character by index:', index);
return { ...char, locked: true };
}
// Check if character name exists in locked items (could be nested object for field locks or boolean for full lock)
const charLocks = lockedItems[charName];
if (charLocks === true) {
// Entire character is locked
// console.log('[Lock Manager] Locking entire character:', charName);
return { ...char, locked: true };
} else if (charLocks && typeof charLocks === 'object') {
// Character has field-level locks
const modifiedChar = { ...char };
for (const fieldName in charLocks) {
if (charLocks[fieldName] === true) {
// Check both the original field name and snake_case version
// (AI returns snake_case, but locks are stored with original configured names)
// Use the same conversion as toSnakeCase in thoughts.js
const snakeCaseFieldName = fieldName
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '_')
.replace(/^_+|_+$/g, '');
let locked = false;
// Check at root level first (backward compatibility)
if (modifiedChar[fieldName] !== undefined) {
// console.log('[Lock Manager] Applying lock to field:', `${charName}.${fieldName}`);
modifiedChar[fieldName] = {
value: modifiedChar[fieldName],
locked: true
};
locked = true;
} else if (modifiedChar[snakeCaseFieldName] !== undefined) {
// console.log('[Lock Manager] Applying lock to snake_case field:', `${charName}.${snakeCaseFieldName} (from ${fieldName})`);
modifiedChar[snakeCaseFieldName] = {
value: modifiedChar[snakeCaseFieldName],
locked: true
};
locked = true;
}
// Check in nested objects (details, relationship, thoughts)
if (!locked && modifiedChar.details) {
if (modifiedChar.details[fieldName] !== undefined) {
// console.log('[Lock Manager] Applying lock to details field:', `${charName}.details.${fieldName}`);
if (!modifiedChar.details || typeof modifiedChar.details !== 'object') {
modifiedChar.details = {};
} else {
modifiedChar.details = { ...modifiedChar.details };
}
modifiedChar.details[fieldName] = {
value: modifiedChar.details[fieldName],
locked: true
};
locked = true;
} else if (modifiedChar.details[snakeCaseFieldName] !== undefined) {
// console.log('[Lock Manager] Applying lock to details snake_case field:', `${charName}.details.${snakeCaseFieldName} (from ${fieldName})`);
if (!modifiedChar.details || typeof modifiedChar.details !== 'object') {
modifiedChar.details = {};
} else {
modifiedChar.details = { ...modifiedChar.details };
}
modifiedChar.details[snakeCaseFieldName] = {
value: modifiedChar.details[snakeCaseFieldName],
locked: true
};
locked = true;
}
}
// Check in relationship object
if (!locked && modifiedChar.relationship) {
if (modifiedChar.relationship[fieldName] !== undefined) {
// console.log('[Lock Manager] Applying lock to relationship field:', `${charName}.relationship.${fieldName}`);
modifiedChar.relationship = { ...modifiedChar.relationship };
modifiedChar.relationship[fieldName] = {
value: modifiedChar.relationship[fieldName],
locked: true
};
locked = true;
} else if (modifiedChar.relationship[snakeCaseFieldName] !== undefined) {
// console.log('[Lock Manager] Applying lock to relationship snake_case field:', `${charName}.relationship.${snakeCaseFieldName} (from ${fieldName})`);
modifiedChar.relationship = { ...modifiedChar.relationship };
modifiedChar.relationship[snakeCaseFieldName] = {
value: modifiedChar.relationship[snakeCaseFieldName],
locked: true
};
locked = true;
}
}
// Check in thoughts object
if (!locked && modifiedChar.thoughts) {
if (modifiedChar.thoughts[fieldName] !== undefined) {
// console.log('[Lock Manager] Applying lock to thoughts field:', `${charName}.thoughts.${fieldName}`);
modifiedChar.thoughts = { ...modifiedChar.thoughts };
modifiedChar.thoughts[fieldName] = {
value: modifiedChar.thoughts[fieldName],
locked: true
};
locked = true;
} else if (modifiedChar.thoughts[snakeCaseFieldName] !== undefined) {
// console.log('[Lock Manager] Applying lock to thoughts snake_case field:', `${charName}.thoughts.${snakeCaseFieldName} (from ${fieldName})`);
modifiedChar.thoughts = { ...modifiedChar.thoughts };
modifiedChar.thoughts[snakeCaseFieldName] = {
value: modifiedChar.thoughts[snakeCaseFieldName],
locked: true
};
locked = true;
}
}
}
}
return modifiedChar;
}
// No locks for this character
return char;
});
const result = Array.isArray(data)
? JSON.stringify(characters, null, 2)
: JSON.stringify({ ...data, characters }, null, 2);
// console.log('[Lock Manager] Output data:', result);
return result;
}
/**
* Remove locks from tracker data received from AI.
* Strips "locked": true from all items to clean up the data.
*
* @param {string} trackerData - JSON string of tracker data
* @returns {string} Tracker data with locks removed
*/
export function removeLocks(trackerData) {
if (!trackerData) return trackerData;
// Try to parse as JSON
const parsed = repairJSON(trackerData);
if (!parsed) {
// Not JSON format, return as-is
return trackerData;
}
// Recursively remove all "locked" properties
const cleaned = removeLockedProperties(parsed);
return JSON.stringify(cleaned, null, 2);
}
/**
* Recursively remove "locked" properties from an object
* @param {*} obj - Object to clean
* @returns {*} Object with locked properties removed
*/
function removeLockedProperties(obj) {
if (Array.isArray(obj)) {
return obj.map(item => removeLockedProperties(item));
} else if (obj !== null && typeof obj === 'object') {
const cleaned = {};
for (const key in obj) {
if (key !== 'locked') {
cleaned[key] = removeLockedProperties(obj[key]);
}
}
return cleaned;
}
return obj;
}
/**
* Check if a specific item is locked
* @param {string} trackerType - Type of tracker
* @param {string} itemPath - Path to the item (e.g., 'stats.Health', 'quests.main.0')
* @returns {boolean} Whether the item is locked
*/
export function isItemLocked(trackerType, itemPath) {
const lockedItems = extensionSettings.lockedItems?.[trackerType];
if (!lockedItems) return false;
const parts = itemPath.split('.');
let current = lockedItems;
for (const part of parts) {
if (current[part] === undefined) return false;
current = current[part];
}
return !!current;
}
/**
* Toggle lock state for a specific item
* @param {string} trackerType - Type of tracker
* @param {string} itemPath - Path to the item
* @param {boolean} locked - New lock state
*/
export function setItemLock(trackerType, itemPath, locked) {
// console.log('[Lock Manager] setItemLock called:', { trackerType, itemPath, locked });
if (!extensionSettings.lockedItems) {
extensionSettings.lockedItems = {};
}
if (!extensionSettings.lockedItems[trackerType]) {
extensionSettings.lockedItems[trackerType] = {};
}
const parts = itemPath.split('.');
let current = extensionSettings.lockedItems[trackerType];
// Navigate to parent of target
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!current[part]) {
current[part] = {};
}
current = current[part];
}
// Set or remove lock
const finalKey = parts[parts.length - 1];
if (locked) {
current[finalKey] = true;
} else {
delete current[finalKey];
}
// console.log('[Lock Manager] Locked items after set:', JSON.stringify(extensionSettings.lockedItems, null, 2));
}
+540 -8
View File
@@ -1,11 +1,63 @@
/**
* Parser Module
* Handles parsing of AI responses to extract tracker data
* Supports both legacy text format and new v3 JSON format
*/
import { extensionSettings, FEATURE_FLAGS, addDebugLog } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { extractInventory } from './inventoryParser.js';
import { repairJSON, extractJSONFromText } from '../../utils/jsonRepair.js';
/**
* Unwraps common envelope keys models may use around tracker payloads.
* Keeps extraction resilient when output is nested under wrappers like "trackers".
*
* @param {object} payload - Parsed JSON payload
* @returns {object} Unwrapped payload (or original when no wrapper exists)
*/
function unwrapTrackerEnvelope(payload) {
let current = payload;
for (let depth = 0; depth < 4; depth++) {
if (!current || typeof current !== 'object' || Array.isArray(current)) {
return payload;
}
if (
current.userStats ||
current.infoBox ||
current.characters ||
current.characterThoughts ||
current.presentCharacters
) {
return current;
}
const next = current.trackers || current.tracker || current.context || current.state || null;
if (!next || typeof next !== 'object') {
break;
}
current = next;
}
return payload;
}
/**
* Extracts the base name (before parentheses) and converts to snake_case for use as JSON key.
* Example: "Conditions (up to 5 traits)" -> "conditions"
* @param {string} name - Field name, possibly with parenthetical description
* @returns {string} snake_case key from the base name only
*/
function toFieldKey(name) {
const baseName = name.replace(/\s*\(.*\)\s*$/, '').trim();
return baseName
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '_')
.replace(/^_+|_+$/g, '');
}
/**
* Helper to separate emoji from text in a string
@@ -127,7 +179,7 @@ function stripBrackets(text) {
* Helper to log to both console and debug logs array
*/
function debugLog(message, data = null) {
console.log(message, data || '');
// console.log(message, data || '');
if (extensionSettings.debugMode) {
addDebugLog(message, data);
}
@@ -139,9 +191,12 @@ function debugLog(message, data = null) {
* Handles both separate code blocks and combined code blocks gracefully.
*
* @param {string} responseText - The raw AI response text
* @param {Object} [options] - Parser behavior options
* @param {boolean} [options.suppressNoDataError=false] - Avoid console error when no tracker data is found
* @returns {{userStats: string|null, infoBox: string|null, characterThoughts: string|null}} Parsed tracker data
*/
export function parseResponse(responseText) {
export function parseResponse(responseText, options = {}) {
const { suppressNoDataError = false } = options;
const result = {
userStats: null,
infoBox: null,
@@ -159,6 +214,293 @@ export function parseResponse(responseText) {
cleanedResponse = cleanedResponse.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');
debugLog('[RPG Parser] Removed thinking tags, new length:', cleanedResponse.length + ' chars');
// Remove "FORMAT:" markers that the model might accidentally output
cleanedResponse = cleanedResponse.replace(/FORMAT:\s*/gi, '');
debugLog('[RPG Parser] Removed FORMAT: markers, new length:', cleanedResponse.length + ' chars');
// First, try to extract raw JSON objects (v3 format)
// Note: Prompts now instruct models to use ```json``` code blocks, but we extract
// from any JSON found using brace-matching for maximum compatibility
// Use brace-matching to find complete JSON objects
const extractedObjects = [];
let i = 0;
while (i < cleanedResponse.length) {
if (cleanedResponse[i] === '{') {
// Found opening brace, find matching closing brace
let depth = 1;
let j = i + 1;
let inString = false;
let escapeNext = false;
while (j < cleanedResponse.length && depth > 0) {
const char = cleanedResponse[j];
if (escapeNext) {
escapeNext = false;
} else if (char === '\\') {
escapeNext = true;
} else if (char === '"') {
inString = !inString;
} else if (!inString) {
if (char === '{') depth++;
else if (char === '}') depth--;
}
j++;
}
if (depth === 0) {
// Found complete JSON object
const jsonContent = cleanedResponse.substring(i, j).trim();
if (jsonContent) {
extractedObjects.push(jsonContent);
}
i = j;
} else {
i++;
}
} else {
i++;
}
}
if (extractedObjects.length > 0) {
// console.log(`[RPG Parser] ✓ Found ${extractedObjects.length} raw JSON objects (v3 format)`);
debugLog(`[RPG Parser] ✓ Found ${extractedObjects.length} raw JSON objects (v3 format)`);
// First, try to parse as unified JSON structure (new v3.1 format)
// Look through all extracted objects for unified structure
let foundUnified = false;
for (let idx = 0; idx < extractedObjects.length; idx++) {
const parsed = repairJSON(extractedObjects[idx]);
const unwrapped = parsed ? unwrapTrackerEnvelope(parsed) : null;
if (unwrapped && (unwrapped.userStats || unwrapped.infoBox || unwrapped.characters || unwrapped.characterThoughts || unwrapped.presentCharacters)) {
// console.log('[RPG Parser] ✓ Detected unified JSON structure (v3.1 format)');
if (unwrapped.userStats) {
result.userStats = JSON.stringify(unwrapped.userStats);
// console.log('[RPG Parser] ✓ Extracted userStats from unified structure');
}
if (unwrapped.infoBox) {
result.infoBox = JSON.stringify(unwrapped.infoBox);
// console.log('[RPG Parser] ✓ Extracted infoBox from unified structure');
}
const unifiedCharacters = unwrapped.characters || unwrapped.presentCharacters || unwrapped.characterThoughts;
if (unifiedCharacters) {
result.characterThoughts = JSON.stringify(unifiedCharacters);
// console.log('[RPG Parser] ✓ Extracted characters from unified structure');
}
foundUnified = true;
break; // Found unified structure, stop searching
}
}
if (foundUnified) {
// console.log('[RPG Parser] ✓ Returning unified JSON parse results');
return result;
}
// If no unified structure found, proceed to multi-object classification
// Fall back to parsing multiple separate JSON objects (legacy v3.0 format)
for (let idx = 0; idx < extractedObjects.length; idx++) {
const jsonContent = extractedObjects[idx];
// console.log(`[RPG Parser] Parsing object ${idx + 1}:`, jsonContent.substring(0, 100) + '...');
// console.log(`[RPG Parser] Full object ${idx + 1} length:`, jsonContent.length);
const parsed = repairJSON(jsonContent);
if (parsed) {
const normalizedParsed = unwrapTrackerEnvelope(parsed);
// console.log(`[RPG Parser] Object ${idx + 1} parsed successfully, keys:`, Object.keys(parsed));
// Check if object is wrapped (e.g., {"userStats": {...}})
// Unwrap single-key objects that match our tracker types
let unwrapped = normalizedParsed;
if (Object.keys(parsed).length === 1) {
const key = Object.keys(parsed)[0];
if (key === 'userStats' || key === 'infoBox' || key === 'characters') {
unwrapped = parsed[key];
// console.log(`[RPG Parser] ✓ Unwrapped ${key} object`);
}
}
// Check for unified structure format (even if previous detection missed it)
// This handles the prompt-requested format: {"userStats": {...}, "infoBox": {...}, "characters": [...]}
if (normalizedParsed.userStats || normalizedParsed.infoBox || normalizedParsed.characters || normalizedParsed.characterThoughts || normalizedParsed.presentCharacters) {
if (normalizedParsed.userStats) {
result.userStats = JSON.stringify(normalizedParsed.userStats);
}
if (normalizedParsed.infoBox) {
result.infoBox = JSON.stringify(normalizedParsed.infoBox);
}
const normalizedCharacters = normalizedParsed.characters || normalizedParsed.presentCharacters || normalizedParsed.characterThoughts;
if (normalizedCharacters) {
result.characterThoughts = JSON.stringify(normalizedCharacters);
}
continue; // Skip further classification
}
// Detect tracker type by checking for top-level fields
if (unwrapped.stats || unwrapped.status || unwrapped.skills || unwrapped.inventory || unwrapped.quests) {
result.userStats = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to User Stats');
debugLog('[RPG Parser] ✓ Extracted raw JSON User Stats');
} else if (unwrapped.date || unwrapped.location || unwrapped.weather || unwrapped.temperature || unwrapped.time) {
result.infoBox = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to Info Box');
debugLog('[RPG Parser] ✓ Extracted raw JSON Info Box');
} else if (unwrapped.characters || Array.isArray(unwrapped)) {
result.characterThoughts = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to Characters');
debugLog('[RPG Parser] ✓ Extracted raw JSON Characters');
} else {
console.warn('[RPG Parser] ⚠️ Could not categorize object with keys:', Object.keys(parsed));
}
} else {
console.error('[RPG Parser] ✗ Failed to parse raw JSON object', idx + 1);
}
}
if (result.userStats || result.infoBox || result.characterThoughts) {
// console.log('[RPG Parser] ✓ Returning raw JSON parse results:', {
// hasUserStats: !!result.userStats,
// hasInfoBox: !!result.infoBox,
// hasCharacters: !!result.characterThoughts
// });
debugLog('[RPG Parser] Returning raw JSON parse results');
return result;
} else {
console.warn('[RPG Parser] ⚠️ No tracker data extracted from', extractedObjects.length, 'objects');
}
}
// Check for JSON code blocks (legacy v3 format with ```json fences)
// Look for ```json code blocks which indicate JSON format
const jsonBlockRegex = /```json\s*\n([\s\S]*?)```/g;
const jsonMatches = [...cleanedResponse.matchAll(jsonBlockRegex)];
if (jsonMatches.length > 0) {
// console.log('[RPG Parser] ✓ Found', jsonMatches.length, 'JSON code blocks (v3 format with fences)');
debugLog('[RPG Parser] ✓ Found JSON code blocks (v3 format), parsing as JSON');
for (let idx = 0; idx < jsonMatches.length; idx++) {
const match = jsonMatches[idx];
const jsonContent = match[1].trim();
if (!jsonContent) continue;
// console.log(`[RPG Parser] Parsing JSON block ${idx + 1}:`, jsonContent.substring(0, 100) + '...');
const parsed = repairJSON(jsonContent);
if (parsed) {
const normalizedParsed = unwrapTrackerEnvelope(parsed);
// console.log(`[RPG Parser] JSON block ${idx + 1} parsed successfully, keys:`, Object.keys(parsed));
// Detect tracker type by checking for top-level fields
if (normalizedParsed.userStats || normalizedParsed.infoBox || normalizedParsed.characters || normalizedParsed.characterThoughts || normalizedParsed.presentCharacters) {
if (normalizedParsed.userStats) {
result.userStats = JSON.stringify(normalizedParsed.userStats);
}
if (normalizedParsed.infoBox) {
result.infoBox = JSON.stringify(normalizedParsed.infoBox);
}
const normalizedCharacters = normalizedParsed.characters || normalizedParsed.presentCharacters || normalizedParsed.characterThoughts;
if (normalizedCharacters) {
result.characterThoughts = JSON.stringify(normalizedCharacters);
}
} else if (normalizedParsed.stats || normalizedParsed.status || normalizedParsed.skills || normalizedParsed.inventory || normalizedParsed.quests) {
result.userStats = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to User Stats');
debugLog('[RPG Parser] ✓ Extracted JSON User Stats');
} else if (normalizedParsed.date || normalizedParsed.location || normalizedParsed.weather || normalizedParsed.temperature || normalizedParsed.time) {
result.infoBox = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to Info Box');
debugLog('[RPG Parser] ✓ Extracted JSON Info Box');
} else if (normalizedParsed.characters || normalizedParsed.presentCharacters || normalizedParsed.characterThoughts || Array.isArray(normalizedParsed)) {
result.characterThoughts = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to Characters');
debugLog('[RPG Parser] ✓ Extracted JSON Characters');
} else {
console.warn('[RPG Parser] ⚠️ Could not categorize JSON block with keys:', Object.keys(parsed));
}
} else {
console.error('[RPG Parser] ✗ Failed to parse JSON code block', idx + 1);
debugLog('[RPG Parser] ✗ Failed to parse JSON block, will try text fallback');
}
}
// If we found at least one valid JSON block, return the result
// Mixed formats (some JSON, some text) will still work
if (result.userStats || result.infoBox || result.characterThoughts) {
// console.log('[RPG Parser] ✓ Returning JSON code block parse results:', {
// hasUserStats: !!result.userStats,
// hasInfoBox: !!result.infoBox,
// hasCharacters: !!result.characterThoughts
// });
debugLog('[RPG Parser] Returning JSON parse results');
return result;
} else {
console.warn('[RPG Parser] ⚠️ No tracker data extracted from', jsonMatches.length, 'JSON blocks');
}
}
// Check if response uses XML <trackers> tags (hybrid format)
const xmlMatch = cleanedResponse.match(/<trackers>([\s\S]*?)<\/trackers>/i);
if (xmlMatch) {
debugLog('[RPG Parser] ✓ Found XML <trackers> tags, using XML parser');
const trackersContent = xmlMatch[1].trim();
// Try to parse JSON blocks within XML first
const xmlJsonMatches = [...trackersContent.matchAll(jsonBlockRegex)];
if (xmlJsonMatches.length > 0) {
debugLog('[RPG Parser] Found JSON blocks within XML tags');
for (const match of xmlJsonMatches) {
const jsonContent = match[1].trim();
if (!jsonContent) continue;
const parsed = repairJSON(jsonContent);
if (parsed) {
if (parsed.type === 'userStats' || parsed.stats) {
result.userStats = jsonContent;
} else if (parsed.type === 'infoBox' || parsed.date || parsed.location) {
result.infoBox = jsonContent;
} else if (parsed.type === 'characters' || parsed.characters || Array.isArray(parsed)) {
result.characterThoughts = jsonContent;
}
}
}
} else {
// Fallback to text extraction from XML content (legacy v2 text format)
const statsMatch = trackersContent.match(/(User )?Stats\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*(Info Box|Present Characters)|$)/i);
if (statsMatch) {
result.userStats = stripBrackets(statsMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Stats from XML (text format)');
}
const infoBoxMatch = trackersContent.match(/Info Box\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*Present Characters|$)/i);
if (infoBoxMatch) {
result.infoBox = stripBrackets(infoBoxMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Info Box from XML (text format)');
}
const charactersMatch = trackersContent.match(/Present Characters\s*\n\s*---[\s\S]*$/i);
if (charactersMatch) {
result.characterThoughts = stripBrackets(charactersMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Present Characters from XML (text format)');
}
}
debugLog('[RPG Parser] Parsed from XML:', result);
return result;
}
// Fallback to markdown code block parsing (old text format or mixed format)
debugLog('[RPG Parser] No XML tags found, using code block parser');
// Extract code blocks
const codeBlockRegex = /```([^`]+)```/g;
const matches = [...cleanedResponse.matchAll(codeBlockRegex)];
@@ -256,8 +598,51 @@ export function parseResponse(responseText) {
debugLog('[RPG Parser] Found Characters:', !!result.characterThoughts);
debugLog('[RPG Parser] =======================================================');
// Final fallback: try to extract tracker JSON from any fenced block content
// This catches responses where JSON is embedded in non-standard markdown structure.
if (!result.userStats && !result.infoBox && !result.characterThoughts) {
const fencedRegex = /```(?:json)?\s*\n?([\s\S]*?)```/gi;
const fencedMatches = [...cleanedResponse.matchAll(fencedRegex)];
for (const match of fencedMatches) {
const fencedContent = (match[1] || '').trim();
if (!fencedContent) continue;
const extracted = extractJSONFromText(fencedContent) || fencedContent;
const parsed = repairJSON(extracted);
const normalizedParsed = parsed ? unwrapTrackerEnvelope(parsed) : null;
if (!normalizedParsed) continue;
if (normalizedParsed.userStats && !result.userStats) {
result.userStats = JSON.stringify(normalizedParsed.userStats);
}
if (normalizedParsed.infoBox && !result.infoBox) {
result.infoBox = JSON.stringify(normalizedParsed.infoBox);
}
const normalizedCharacters = normalizedParsed.characters || normalizedParsed.presentCharacters || normalizedParsed.characterThoughts;
if (normalizedCharacters && !result.characterThoughts) {
result.characterThoughts = JSON.stringify(normalizedCharacters);
}
if (result.userStats || result.infoBox || result.characterThoughts) {
debugLog('[RPG Parser] ✓ Extracted trackers from final fenced-block fallback');
break;
}
}
}
// Check if we found at least one section - if not, mark as parsing failure
if (!result.userStats && !result.infoBox && !result.characterThoughts) {
result.parsingFailed = true;
if (!suppressNoDataError) {
console.error('[RPG Parser] ❌ No tracker data found in response - parsing failed');
} else {
debugLog('[RPG Parser] No tracker data found (suppressed no-data error)');
}
}
return result;
}
} // End parseResponse
/**
* Parses user stats from the text and updates the extensionSettings.
@@ -271,6 +656,133 @@ export function parseUserStats(statsText) {
debugLog('[RPG Parser] Stats text preview:', statsText.substring(0, 200));
try {
// Check if this is v3 JSON format - try to parse it first
let statsData = null;
const trimmed = statsText.trim();
if (trimmed && (trimmed.startsWith('{') || trimmed.startsWith('['))) {
statsData = repairJSON(statsText);
if (statsData) {
debugLog('[RPG Parser] ✓ Parsed as v3 JSON format');
// Extract stats from v3 JSON structure
if (statsData.stats && Array.isArray(statsData.stats)) {
// console.log('[RPG Parser] ✓ Extracting stats array, count:', statsData.stats.length);
statsData.stats.forEach(stat => {
if (stat.id && typeof stat.value !== 'undefined') {
extensionSettings.userStats[stat.id] = stat.value;
// console.log(`[RPG Parser] ✓ Set ${stat.id} = ${stat.value}`);
}
});
}
// Extract status
if (statsData.status) {
// console.log('[RPG Parser] ✓ Extracting status:', statsData.status);
if (statsData.status.mood) {
extensionSettings.userStats.mood = statsData.status.mood;
// console.log('[RPG Parser] ✓ Set mood =', statsData.status.mood);
}
// Extract all custom status fields
const trackerConfig = extensionSettings.trackerConfig;
const customFields = trackerConfig?.userStats?.statusSection?.customFields || [];
for (const fieldName of customFields) {
const fieldKey = toFieldKey(fieldName);
// Try the base key first (e.g., "conditions"), then fall back to full lowercase name
const value = statsData.status[fieldKey] || statsData.status[fieldName.toLowerCase()];
if (value) {
extensionSettings.userStats[fieldKey] = value;
// console.log(`[RPG Parser] ✓ Set ${fieldKey} =`, value);
}
}
}
// Extract inventory (convert v3 array format to v2 string format)
if (statsData.inventory) {
const inv = statsData.inventory;
// Convert arrays of {name, quantity} objects to comma-separated strings
const convertItems = (items) => {
if (!items || !Array.isArray(items)) return '';
return items.map(item => {
if (typeof item === 'object' && item.name) {
// Include quantity if > 1
return item.quantity && item.quantity > 1
? `${item.quantity}x ${item.name}`
: item.name;
}
return String(item);
}).join(', ');
};
// Convert stored object {location: [items]} to {location: "item1, item2"}
const convertStoredInventory = (stored) => {
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return {};
const result = {};
for (const [location, items] of Object.entries(stored)) {
if (Array.isArray(items)) {
result[location] = convertItems(items);
} else if (typeof items === 'string') {
result[location] = items;
} else {
result[location] = '';
}
}
return result;
};
extensionSettings.userStats.inventory = {
onPerson: convertItems(inv.onPerson),
clothing: convertItems(inv.clothing),
stored: convertStoredInventory(inv.stored),
assets: convertItems(inv.assets)
};
// console.log('[RPG Parser] ✓ Converted v3 inventory:', extensionSettings.userStats.inventory);
}
// Extract quests (convert v3 object format to v2 string format)
if (statsData.quests) {
// Convert quest objects to strings
const convertQuest = (quest) => {
if (!quest) return '';
if (typeof quest === 'string') return quest;
if (typeof quest === 'object') {
// Check for locked format: {value, locked}
// Recursively extract value if it's nested
let extracted = quest;
while (typeof extracted === 'object' && extracted.value !== undefined) {
extracted = extracted.value;
}
if (typeof extracted === 'string') return extracted;
// v3 format: {title, description, status}
return quest.title || quest.description || JSON.stringify(quest);
}
return String(quest);
};
extensionSettings.quests = {
main: convertQuest(statsData.quests.main),
optional: Array.isArray(statsData.quests.optional)
? statsData.quests.optional.map(convertQuest)
: []
};
// console.log('[RPG Parser] ✓ Converted v3 quests:', extensionSettings.quests);
}
// Extract skills if present (store as object, not JSON string)
if (statsData.skills && Array.isArray(statsData.skills)) {
extensionSettings.userStats.skills = statsData.skills;
// console.log('[RPG Parser] ✓ Set skills:', extensionSettings.userStats.skills);
}
debugLog('[RPG Parser] ✓ Successfully extracted v3 JSON data');
saveSettings();
return; // Done processing v3 format
}
}
// Fall back to v2 text format parsing if JSON parsing failed
debugLog('[RPG Parser] Falling back to v2 text format parsing');
// Get custom stat configuration
const trackerConfig = extensionSettings.trackerConfig;
const customStats = trackerConfig?.userStats?.customStats || [];
@@ -317,6 +829,7 @@ export function parseUserStats(statsText) {
const statusConfig = trackerConfig?.userStats?.statusSection;
if (statusConfig?.enabled) {
let moodMatch = null;
const customFields = statusConfig.customFields || [];
// Try Status: format
const statusMatch = statsText.match(/Status:\s*(.+)/i);
@@ -329,14 +842,30 @@ export function parseUserStats(statsText) {
if (emoji) {
extensionSettings.userStats.mood = emoji;
// Remaining text contains custom status fields
if (text) {
extensionSettings.userStats.conditions = text;
if (text && customFields.length > 0) {
// For first custom field, use the remaining text
const firstFieldKey = customFields[0].toLowerCase();
extensionSettings.userStats[firstFieldKey] = text;
}
moodMatch = true;
}
} else {
// No mood emoji, whole status is conditions
extensionSettings.userStats.conditions = statusContent;
// No mood emoji, whole status goes to first custom field
if (customFields.length > 0) {
const firstFieldKey = customFields[0].toLowerCase();
extensionSettings.userStats[firstFieldKey] = statusContent;
}
moodMatch = true;
}
}
// Try to extract individual custom status fields by name
for (const fieldName of customFields) {
const fieldKey = fieldName.toLowerCase();
const fieldRegex = new RegExp(`${fieldName}:\\s*(.+?)(?:,|$)`, 'i');
const fieldMatch = statsText.match(fieldRegex);
if (fieldMatch) {
extensionSettings.userStats[fieldKey] = fieldMatch[1].trim();
moodMatch = true;
}
}
@@ -344,7 +873,10 @@ export function parseUserStats(statsText) {
debugLog('[RPG Parser] Status match:', {
found: !!moodMatch,
mood: extensionSettings.userStats.mood,
conditions: extensionSettings.userStats.conditions
customFields: customFields.map(f => ({
name: f,
value: extensionSettings.userStats[f.toLowerCase()]
}))
});
}
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
/**
* Suppression helper for guided generation injection behavior.
*
* This module exports a pure function `evaluateSuppression` that computes
* whether RPG Companion should suppress tracker and HTML injections for a
* given generation request, based on runtime settings, extended context, and
* generation data (quiet prompt flags, etc.).
*/
/**
* Determine if suppression should be applied for this generation.
*
* @param {any} extensionSettings - extension settings object (may contain skipInjectionsForGuided)
* @param {any} context - SillyTavern context object (used to find chatMetadata.script_injects.instruct)
* @param {any} data - Generation data (contains quiet_prompt/quietPrompt flags)
* @returns {Object} - An object describing the suppression decision.
*/
export function evaluateSuppression(extensionSettings, context, data) {
// Detect presence of any injected `instruct` script
const instructObj = context?.chatMetadata?.script_injects?.instruct;
const isGuidedGeneration = !!instructObj;
const quietPromptRaw = data?.quiet_prompt || data?.quietPrompt || '';
const hasQuietPrompt = !!quietPromptRaw;
// Normalize the injected instruction body (it may be an object with a 'value' field or a raw string)
let instructContent = '';
if (instructObj) {
if (typeof instructObj === 'object') {
instructContent = String(instructObj.value || instructObj || '');
} else {
instructContent = String(instructObj);
}
}
const IMPERSONATION_PATTERNS = [
{ id: 'first-perspective', re: /write in first person perspective from/i },
{ id: 'second-perspective', re: /write in second person perspective from/i },
{ id: 'third-perspective', re: /write in third person perspective from/i },
{ id: 'you-yours', re: /using you\/yours for/i },
{ id: 'third-person-pronouns', re: /third-person pronouns for/i },
{ id: 'impersonate-word', re: /\bimpersonat(e|ion)?\b/i },
{ id: 'assume-role', re: /assume the role of/i },
{ id: 'play-role', re: /play the role of/i },
{ id: 'impersonate-command', re: /\/impersonate await=true/i },
{ id: 'generic-first', re: /\bfirst person\b/i },
{ id: 'generic-second', re: /\bsecond person\b/i },
{ id: 'generic-third', re: /\bthird person\b/i }
];
// Include quietPrompt raw text in detection; guided impersonation flows may pass it directly here
const combinedTextForDetection = [instructContent, quietPromptRaw].filter(Boolean).join('\n');
let matchedPattern = '';
let isImpersonationGeneration = false;
if (combinedTextForDetection.length) {
for (const pat of IMPERSONATION_PATTERNS) {
if (pat.re.test(combinedTextForDetection)) {
matchedPattern = pat.id;
isImpersonationGeneration = true;
break;
}
}
}
const skipMode = (extensionSettings && extensionSettings.skipInjectionsForGuided) || 'none';
// Compute suppression according to mode
const shouldSuppress = skipMode === 'guided'
? (isGuidedGeneration || hasQuietPrompt)
: (skipMode === 'impersonation' ? isImpersonationGeneration : false);
return {
shouldSuppress,
skipMode,
isGuidedGeneration,
isImpersonationGeneration,
hasQuietPrompt,
instructContent,
quietPromptRaw,
matchedPattern
};
}
+603 -67
View File
@@ -4,7 +4,7 @@
*/
import { getContext } from '../../../../../../extensions.js';
import { chat, user_avatar, setExtensionPrompt, extension_prompt_types } from '../../../../../../../script.js';
import { chat, chat_metadata, user_avatar, setExtensionPrompt, extension_prompt_types } from '../../../../../../../script.js';
// Core modules
import {
@@ -13,27 +13,99 @@ import {
committedTrackerData,
lastActionWasSwipe,
isPlotProgression,
isAwaitingNewMessage,
setLastActionWasSwipe,
setIsPlotProgression,
setIsGenerating,
setIsAwaitingNewMessage,
updateLastGeneratedData,
updateCommittedTrackerData
updateCommittedTrackerData,
$musicPlayerContainer,
incrementSeparateGenerationId
} from '../../core/state.js';
import { saveChatData, loadChatData } from '../../core/persistence.js';
import {
saveChatData,
loadChatData,
autoSwitchPresetForEntity,
getMessageSwipeTrackerData,
getCurrentMessageSwipeTrackerData,
restoreLatestTrackerStateFromChat,
setMessageSwipeTrackerData,
getSwipeData,
commitTrackerDataFromPriorMessage,
inheritSwipeDataFromPriorMessage,
flushDeferredChatDataSave
} from '../../core/persistence.js';
import { i18n } from '../../core/i18n.js';
// Generation & Parsing
import { parseResponse, parseUserStats } from '../generation/parser.js';
import { parseAndStoreSpotifyUrl, convertToEmbedUrl } from '../features/musicPlayer.js';
import { updateRPGData } from '../generation/apiClient.js';
import { removeLocks } from '../generation/lockManager.js';
import { onGenerationStarted, initHistoryInjectionListeners } from '../generation/injector.js';
// Rendering
import { renderUserStats } from '../rendering/userStats.js';
import { renderInfoBox } from '../rendering/infoBox.js';
import { renderThoughts, updateChatThoughts } from '../rendering/thoughts.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';
// Utils
import { getSafeThumbnailUrl } from '../../utils/avatars.js';
// UI
import { setFabLoadingState, updateFabWidgets } from '../ui/mobile.js';
import { updateStripWidgets } from '../ui/desktop.js';
// Chapter checkpoint
import { updateAllCheckpointIndicators } from '../ui/checkpointUI.js';
import { restoreCheckpointOnLoad } from '../features/chapterCheckpoint.js';
let chatStateRehydrateRunId = 0;
/**
* Reads the swipe store of the last assistant message in `currentChat` and
* writes its data into `lastGeneratedData`, including syncing stat bars via
* `parseUserStats`. If no assistant message exists, or none has stored swipe
* data, `lastGeneratedData` is left unchanged.
*
* Use this wherever the displayed tracker state must be re-derived from the
* authoritative swipe store rather than from chat_metadata (e.g. after a
* CHAT_CHANGED caused by branching, or after a message deletion).
*
* @param {Array} currentChat - Live chat array from getContext().chat
* @returns {boolean} True if swipe data was found and applied
*/
function syncLastGeneratedDataFromSwipeStore(currentChat) {
for (let i = currentChat.length - 1; i >= 0; i--) {
const msg = currentChat[i];
if (!msg.is_user && !msg.is_system) {
const swipeId = msg.swipe_id || 0;
const swipeData = getSwipeData(msg, swipeId);
if (swipeData) {
lastGeneratedData.userStats = swipeData.userStats || null;
lastGeneratedData.infoBox = swipeData.infoBox || null;
// Normalize characterThoughts to string (backward compat with old object format).
if (swipeData.characterThoughts && typeof swipeData.characterThoughts === 'object') {
lastGeneratedData.characterThoughts = JSON.stringify(swipeData.characterThoughts, null, 2);
} else {
lastGeneratedData.characterThoughts = swipeData.characterThoughts || null;
}
if (swipeData.userStats) {
parseUserStats(swipeData.userStats);
}
return true;
}
return false; // Last assistant message exists but has no swipe data yet
}
}
return false; // No assistant messages in chat
}
/**
* Commits the tracker data from the last assistant message to be used as source for next generation.
* This should be called when the user has replied to a message, ensuring all swipes of the next
@@ -48,60 +120,379 @@ export function commitTrackerData() {
// Find the last assistant message
for (let i = chat.length - 1; i >= 0; i--) {
const message = chat[i];
if (!message.is_user) {
if (!message.is_user && !message.is_system) {
// Found last assistant message - commit its tracker data
if (message.extra && message.extra.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0;
const swipeData = message.extra.rpg_companion_swipes[swipeId];
const swipeData = getSwipeData(message, swipeId);
if (swipeData) {
// console.log('[RPG Companion] Committing tracker data from assistant message at index', i, 'swipe', swipeId);
committedTrackerData.userStats = swipeData.userStats || null;
committedTrackerData.infoBox = swipeData.infoBox || null;
committedTrackerData.characterThoughts = swipeData.characterThoughts || null;
const rawCharacterThoughts = swipeData.characterThoughts;
if (rawCharacterThoughts == null) {
committedTrackerData.characterThoughts = null;
} else if (typeof rawCharacterThoughts === 'object') {
committedTrackerData.characterThoughts = JSON.stringify(rawCharacterThoughts);
} else {
// console.log('[RPG Companion] No swipe data found for swipe', swipeId);
committedTrackerData.characterThoughts = String(rawCharacterThoughts);
}
} else {
// console.log('[RPG Companion] No RPG data found in last assistant message');
// No saved swipe data — treat as empty (e.g. first message, no prior generation)
committedTrackerData.userStats = null;
committedTrackerData.infoBox = null;
committedTrackerData.characterThoughts = null;
}
break;
}
}
}
function getSwipeTrackerData(message) {
return getMessageSwipeTrackerData(message);
}
function getCurrentSwipeTrackerData(message) {
return getCurrentMessageSwipeTrackerData(message);
}
function hasAssistantMessageBody() {
const $messages = $('#chat .mes');
for (let i = $messages.length - 1; i >= 0; i--) {
const $message = $messages.eq(i);
if ($message.attr('is_user') === 'true') continue;
if ($message.find('.mes_text').length > 0) {
return true;
}
}
return false;
}
function hasAnyTrackerStateInChat() {
const chatMessages = getContext()?.chat || [];
for (let i = chatMessages.length - 1; i >= 0; i--) {
const swipeData = getSwipeTrackerData(chatMessages[i]);
if (swipeData?.userStats || swipeData?.infoBox || swipeData?.characterThoughts) {
return true;
}
}
return false;
}
function hasAssistantMessagesInChat() {
const chatMessages = getContext()?.chat || [];
return chatMessages.some(message => message && !message.is_user && !message.is_system);
}
function hasPotentialTrackerSourceInChat() {
const chatMessages = getContext()?.chat || [];
for (const message of chatMessages) {
if (!message || message.is_user || message.is_system) {
continue;
}
if (message.extra?.rpg_companion_swipes) {
return true;
}
if (Array.isArray(message.swipe_info) && message.swipe_info.some(info => info?.extra?.rpg_companion_swipes)) {
return true;
}
if (Array.isArray(message.swipes) && message.swipes.length > 1) {
return true;
}
}
return false;
}
function maybeRehydrateUserStatsFromDisplayData() {
const hasSavedUserStats = !!chat_metadata?.rpg_companion?.userStats;
if (!hasSavedUserStats && lastGeneratedData.userStats) {
try {
parseUserStats(lastGeneratedData.userStats);
} catch (error) {
console.warn('[RPG Companion] Failed to rebuild user stats from display data:', error);
}
}
}
function getCurrentSwipeText(message) {
const swipeId = Number(message?.swipe_id ?? 0);
if (Array.isArray(message?.swipes) && typeof message.swipes[swipeId] === 'string' && message.swipes[swipeId].trim()) {
return message.swipes[swipeId];
}
return typeof message?.mes === 'string' ? message.mes : '';
}
/**
* Resolves the currently active swipe index for a message.
* Some ST flows can briefly expose a stale message.swipe_id during swipe transitions,
* so we also match against message.mes in the swipes array when possible.
*
* @param {Object} message - Assistant message object
* @returns {number} Active swipe index
*/
function resolveActiveSwipeId(message) {
const fallbackSwipeId = Number(message?.swipe_id ?? 0);
const swipes = Array.isArray(message?.swipes) ? message.swipes : null;
if (!swipes || swipes.length === 0) {
return Math.max(0, fallbackSwipeId);
}
const currentText = typeof message?.mes === 'string' ? message.mes : '';
if (currentText) {
for (let i = swipes.length - 1; i >= 0; i--) {
if (typeof swipes[i] === 'string' && swipes[i] === currentText) {
return i;
}
}
}
if (fallbackSwipeId < 0) {
return 0;
}
return Math.min(fallbackSwipeId, swipes.length - 1);
}
function repairLatestTrackerStateFromCurrentSwipeContent(chatMessages = getContext()?.chat || []) {
for (let i = chatMessages.length - 1; i >= 0; i--) {
const message = chatMessages[i];
if (!message || message.is_user || message.is_system) {
continue;
}
const swipeId = Number(message.swipe_id ?? 0);
if (getCurrentSwipeTrackerData(message)) {
continue;
}
const currentSwipeText = getCurrentSwipeText(message);
if (!currentSwipeText) {
continue;
}
const parsedData = parseResponse(currentSwipeText, { suppressNoDataError: true });
if (parsedData.userStats) {
parsedData.userStats = removeLocks(parsedData.userStats);
}
if (parsedData.infoBox) {
parsedData.infoBox = removeLocks(parsedData.infoBox);
}
if (parsedData.characterThoughts) {
parsedData.characterThoughts = removeLocks(parsedData.characterThoughts);
}
if (!parsedData.userStats && !parsedData.infoBox && !parsedData.characterThoughts) {
continue;
}
setMessageSwipeTrackerData(message, swipeId, {
userStats: parsedData.userStats || null,
infoBox: parsedData.infoBox || null,
characterThoughts: parsedData.characterThoughts || null
});
return true;
}
return false;
}
function restoreOrRepairLatestTrackerState() {
const chatMessages = getContext()?.chat || [];
let restored = restoreLatestTrackerStateFromChat(chatMessages);
if (!restored) {
const repaired = repairLatestTrackerStateFromCurrentSwipeContent(chatMessages);
if (repaired) {
restored = restoreLatestTrackerStateFromChat(chatMessages);
}
}
return restored;
}
function rerenderRpgState() {
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderEquipment();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
updateFabWidgets();
updateStripWidgets();
}
export function scheduleChatStateRehydration() {
chatStateRehydrateRunId++;
const runId = chatStateRehydrateRunId;
let attempts = 0;
const maxAttempts = 15;
const eagerRetryAttempts = 4;
const tryRestoreState = () => {
if (runId !== chatStateRehydrateRunId) {
return;
}
attempts++;
loadChatData();
restoreOrRepairLatestTrackerState();
maybeRehydrateUserStatsFromDisplayData();
rerenderRpgState();
const hasRestoredTrackerState = !!(
lastGeneratedData.userStats
|| lastGeneratedData.infoBox
|| lastGeneratedData.characterThoughts
|| committedTrackerData.userStats
|| committedTrackerData.infoBox
|| committedTrackerData.characterThoughts
);
const hasStoredTrackerState = !!chat_metadata?.rpg_companion || hasAnyTrackerStateInChat();
const hasAssistantMessages = hasAssistantMessagesInChat();
const hasPotentialTrackerSource = hasPotentialTrackerSourceInChat();
const chatBodyReady = hasAssistantMessageBody();
if (chatBodyReady) {
updateChatThoughts();
}
const shouldRetryForRestore = !hasRestoredTrackerState && (
hasStoredTrackerState
|| (hasAssistantMessages && attempts < eagerRetryAttempts)
|| (hasPotentialTrackerSource && attempts < maxAttempts)
);
const shouldRetryForDom = !chatBodyReady && hasAssistantMessages;
if ((shouldRetryForRestore || shouldRetryForDom) && attempts < maxAttempts) {
setTimeout(tryRestoreState, 200);
}
};
setTimeout(tryRestoreState, 200);
}
export function onChatLoaded() {
loadChatData();
restoreOrRepairLatestTrackerState();
maybeRehydrateUserStatsFromDisplayData();
rerenderRpgState();
flushDeferredChatDataSave();
scheduleChatStateRehydration();
updateAllCheckpointIndicators();
}
function syncDisplayedTrackerStateFromChat() {
const restored = restoreOrRepairLatestTrackerState();
if (!restored) {
lastGeneratedData.userStats = null;
lastGeneratedData.infoBox = null;
lastGeneratedData.characterThoughts = null;
committedTrackerData.userStats = null;
committedTrackerData.infoBox = null;
committedTrackerData.characterThoughts = null;
}
rerenderRpgState();
updateChatThoughts();
}
/**
* Event handler for when the user sends a message.
* Sets the flag to indicate this is NOT a swipe.
* In together mode, commits displayed data (only for real messages, not streaming placeholders).
*/
export function onMessageSent() {
if (!extensionSettings.enabled) return;
// User sent a new message - NOT a swipe
setLastActionWasSwipe(false);
// console.log('[RPG Companion] 🟢 EVENT: onMessageSent - lastActionWasSwipe =', lastActionWasSwipe);
// Check if this is a streaming placeholder message (content = "...")
// When streaming is on, ST sends a "..." placeholder before generation starts
const context = getContext();
const chat = context.chat;
const lastMessage = chat && chat.length > 0 ? chat[chat.length - 1] : null;
if (lastMessage && lastMessage.mes === '...') {
// console.log('[RPG Companion] 🟢 Ignoring onMessageSent: streaming placeholder message');
return;
}
// console.log('[RPG Companion] 🟢 EVENT: onMessageSent (after placeholder check)');
// console.log('[RPG Companion] 🟢 NOTE: lastActionWasSwipe will be reset in onMessageReceived after generation completes');
// Set flag to indicate we're expecting a new message from generation
// This allows auto-update to distinguish between new generations and loading chat history
setIsAwaitingNewMessage(true);
// Note: FAB spinning is NOT shown for together mode since no extra API request is made
// The RPG data comes embedded in the main response
// FAB spinning is handled by apiClient.js for separate/external modes when updateRPGData() is called
}
/**
* Event handler for when a message is generated.
*/
export async function onMessageReceived(data) {
// console.log('[RPG Companion] onMessageReceived called, lastActionWasSwipe:', lastActionWasSwipe);
if (!extensionSettings.enabled) {
return;
}
// Reset swipe flag after generation completes
// This ensures next user message (whether from original or swipe) triggers commit
setLastActionWasSwipe(false);
// console.log('[RPG Companion] 🟢 Reset lastActionWasSwipe = false (generation completed)');
if (extensionSettings.generationMode === 'together') {
// In together mode, parse the response to extract RPG data
// The message should be in chat[chat.length - 1]
// Commit happens in onMessageSent (when user sends message, before generation)
const lastMessage = chat[chat.length - 1];
if (lastMessage && !lastMessage.is_user) {
const rawSwipeId = Number(lastMessage.swipe_id ?? 0);
const responseText = lastMessage.mes;
// console.log('[RPG Companion] Parsing together mode response:', responseText);
const parsedData = parseResponse(responseText, { suppressNoDataError: true });
const parsedData = parseResponse(responseText);
// console.log('[RPG Companion] Parsed data:', parsedData);
// Note: Don't show parsing error here - this event fires when loading chat history too
// Error notification is handled in apiClient.js for fresh generations only
// Update stored data
// Remove locks from parsed data (JSON format only, text format is unaffected)
if (parsedData.userStats) {
parsedData.userStats = removeLocks(parsedData.userStats);
}
if (parsedData.infoBox) {
parsedData.infoBox = removeLocks(parsedData.infoBox);
}
if (parsedData.characterThoughts) {
parsedData.characterThoughts = removeLocks(parsedData.characterThoughts);
}
// Parse and store Spotify URL if feature is enabled
parseAndStoreSpotifyUrl(responseText);
// Update display data with newly parsed response
// console.log('[RPG Companion] 📝 TOGETHER MODE: Updating lastGeneratedData with parsed response');
if (parsedData.userStats) {
lastGeneratedData.userStats = parsedData.userStats;
parseUserStats(parsedData.userStats);
@@ -121,28 +512,23 @@ export async function onMessageReceived(data) {
lastMessage.extra.rpg_companion_swipes = {};
}
const currentSwipeId = lastMessage.swipe_id || 0;
lastMessage.extra.rpg_companion_swipes[currentSwipeId] = {
const currentSwipeId = resolveActiveSwipeId(lastMessage);
setMessageSwipeTrackerData(lastMessage, currentSwipeId, {
userStats: parsedData.userStats,
infoBox: parsedData.infoBox,
characterThoughts: parsedData.characterThoughts
};
});
// console.log('[RPG Companion] Stored RPG data for swipe', currentSwipeId);
// If there's no committed data yet (first time generating), automatically commit
if (!committedTrackerData.userStats && !committedTrackerData.infoBox && !committedTrackerData.characterThoughts) {
committedTrackerData.userStats = parsedData.userStats;
committedTrackerData.infoBox = parsedData.infoBox;
committedTrackerData.characterThoughts = parsedData.characterThoughts;
// console.log('[RPG Companion] 🔆 FIRST TIME: Auto-committed tracker data');
} else {
// console.log('[RPG Companion] Data will be committed when user replies');
}
// Remove the tracker code blocks from the visible message
let cleanedMessage = responseText;
// Remove all code blocks that contain tracker data
// Remove JSON code blocks (v3 format) — primary defense, works regardless of regex script
cleanedMessage = cleanedMessage.replace(/```(?:json|markdown)?\s*[\s\S]*?```/gim, '');
// Remove old text format code blocks (legacy support)
cleanedMessage = cleanedMessage.replace(/```[^`]*?Stats\s*\n\s*---[^`]*?```\s*/gi, '');
cleanedMessage = cleanedMessage.replace(/```[^`]*?Info Box\s*\n\s*---[^`]*?```\s*/gi, '');
cleanedMessage = cleanedMessage.replace(/```[^`]*?Present Characters\s*\n\s*---[^`]*?```\s*/gi, '');
@@ -150,6 +536,8 @@ export async function onMessageReceived(data) {
cleanedMessage = cleanedMessage.replace(/^\s*---\s*$/gm, '');
// Clean up multiple consecutive newlines
cleanedMessage = cleanedMessage.replace(/\n{3,}/g, '\n\n');
// Note: <trackers> XML tags are automatically hidden by SillyTavern
// Note: <Song - Artist/> tags are also automatically hidden by SillyTavern
// Update the message in chat history
lastMessage.mes = cleanedMessage.trim();
@@ -164,28 +552,73 @@ export async function onMessageReceived(data) {
renderInfoBox();
renderThoughts();
renderInventory();
renderEquipment();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Update FAB widgets and strip widgets with newly parsed data
updateFabWidgets();
updateStripWidgets();
// Then update the DOM to reflect the cleaned message
const lastMessageElement = $('#chat').children('.mes').last();
if (lastMessageElement.length) {
const messageText = lastMessageElement.find('.mes_text');
if (messageText.length) {
messageText.html(substituteParams(cleanedMessage.trim()));
}
}
// Using updateMessageBlock to perform macro substitutions + regex formatting
const messageId = chat.length - 1;
updateMessageBlock(messageId, lastMessage, { rerenderMessage: true });
// console.log('[RPG Companion] Cleaned message, removed tracker code blocks from DOM');
// Re-insert chat thoughts after SillyTavern finishes rerendering the cleaned message DOM.
if (parsedData.characterThoughts) {
setTimeout(() => updateChatThoughts(), 100);
}
// Save to chat metadata
saveChatData();
}
} else if (extensionSettings.generationMode === 'separate' && extensionSettings.autoUpdate) {
// In separate mode with auto-update, trigger update after message
} else if (extensionSettings.generationMode === 'separate' || extensionSettings.generationMode === 'external') {
// In separate/external mode, also parse Spotify URLs from the main roleplay response
const lastMessage = chat[chat.length - 1];
if (lastMessage && !lastMessage.is_user) {
const responseText = lastMessage.mes;
// Parse and store Spotify URL
const foundSpotifyUrl = parseAndStoreSpotifyUrl(responseText);
// No need to clean message - SillyTavern auto-hides <Song - Artist/> tags
if (foundSpotifyUrl && extensionSettings.enableSpotifyMusic) {
// Just render the music player
renderMusicPlayer($musicPlayerContainer[0]);
}
// When auto-update is disabled, no tracker API call will run for this message.
// Inherit the prior assistant message's tracker data into this swipe slot so that
// commitTrackerDataFromPriorMessage can find a valid state next turn instead of nulling everything.
// Inheritance does not overwrite existing data, so it's safe to call even if the condition misses an edge case.
if (!extensionSettings.autoUpdate || !isAwaitingNewMessage) {
inheritSwipeDataFromPriorMessage(lastMessage, chat.length - 1);
}
}
// Trigger auto-update if enabled (for both separate and external modes)
// Only trigger if this is a newly generated message, not loading chat history
if (extensionSettings.autoUpdate && isAwaitingNewMessage) {
// Capture the current generation ID before the async gap so that any
// message deletion (or a newer generation) that increments the counter
// while the 500ms timer or the API call is in-flight will cause
// updateRPGData to discard its result rather than stomping the UI.
const genId = incrementSeparateGenerationId();
setTimeout(async () => {
await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory);
await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory, genId);
// Update FAB widgets and strip widgets after separate/external mode update completes
setFabLoadingState(false);
updateFabWidgets();
updateStripWidgets();
}, 500);
}
}
// Reset the awaiting flag after processing the message
setIsAwaitingNewMessage(false);
// Reset the swipe flag after generation completes
// This ensures that if the user swiped → auto-reply generated → flag is now cleared
@@ -201,6 +634,14 @@ export async function onMessageReceived(data) {
setIsPlotProgression(false);
// console.log('[RPG Companion] Plot progression generation completed');
}
// Stop FAB loading state and update widgets
setFabLoadingState(false);
updateFabWidgets();
updateStripWidgets();
// Re-apply checkpoint in case SillyTavern unhid messages during generation
await restoreCheckpointOnLoad();
}
/**
@@ -210,27 +651,48 @@ export function onCharacterChanged() {
// Remove thought panel and icon when changing characters
$('#rpg-thought-panel').remove();
$('#rpg-thought-icon').remove();
$('.rpg-inline-thoughts, .rpg-inline-thought').remove();
$('#chat').off('scroll.thoughtPanel');
$(window).off('resize.thoughtPanel');
$(document).off('click.thoughtPanel');
// Auto-switch to the preset associated with this character/group (if any)
const presetSwitched = autoSwitchPresetForEntity();
// if (presetSwitched) {
// console.log('[RPG Companion] Auto-switched preset for character');
// }
// Load chat-specific data when switching chats
loadChatData();
flushDeferredChatDataSave();
// chat_metadata may not reflect the actual chat tail for branches, so
// loadChatData() may have just restored stale data from the parent chat.
// Override lastGeneratedData from the swipe store of the last assistant message.
// The message objects in the branch already carry their full swipe stores, making this authoritative.
// If no swipe data exists (e.g. branching at message 0, or a chat with no generations yet),
// null out lastGeneratedData and committedTrackerData so we don't display stale values from the parent chat.
const hadSwipeData = syncLastGeneratedDataFromSwipeStore(getContext().chat);
if (!hadSwipeData) {
lastGeneratedData.userStats = null;
lastGeneratedData.infoBox = null;
lastGeneratedData.characterThoughts = null;
committedTrackerData.userStats = null;
committedTrackerData.infoBox = null;
committedTrackerData.characterThoughts = null;
}
// Don't call commitTrackerData() here - it would overwrite the loaded committedTrackerData
// with data from the last message, which may be null/empty. The loaded committedTrackerData
// already contains the committed state from when we last left this chat.
// commitTrackerData() will be called naturally when new messages arrive.
// Re-render with the loaded data
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderQuests();
// Re-render with the loaded data and retry once SillyTavern finishes restoring chat state.
rerenderRpgState();
scheduleChatStateRehydration();
// Update chat thought overlays
updateChatThoughts();
// Update checkpoint indicators for the loaded chat
updateAllCheckpointIndicators();
}
/**
@@ -242,15 +704,17 @@ export function onMessageSwiped(messageIndex) {
return;
}
// console.log('[RPG Companion] Message swiped at index:', messageIndex);
// console.log('[RPG Companion] 🔵 EVENT: onMessageSwiped at index:', messageIndex);
// Get the message that was swiped
const message = chat[messageIndex];
if (!message || message.is_user) {
// console.log('[RPG Companion] 🔵 Ignoring swipe - message is user or undefined');
return;
}
const currentSwipeId = message.swipe_id || 0;
const currentSwipeId = resolveActiveSwipeId(message);
const swipeCount = Array.isArray(message.swipes) ? message.swipes.length : 0;
// Only set flag to true if this swipe will trigger a NEW generation
// Check if the swipe already exists (has content in the swipes array)
@@ -258,53 +722,100 @@ export function onMessageSwiped(messageIndex) {
message.swipes[currentSwipeId] !== undefined &&
message.swipes[currentSwipeId] !== null &&
message.swipes[currentSwipeId].length > 0;
const swipeData = getSwipeData(message, currentSwipeId);
const isPendingNewSwipe = currentSwipeId >= swipeCount;
if (!isExistingSwipe) {
// This is a NEW swipe that will trigger generation
setLastActionWasSwipe(true);
// console.log('[RPG Companion] 🔵 EVENT: onMessageSwiped (NEW generation) - lastActionWasSwipe =', lastActionWasSwipe);
setIsAwaitingNewMessage(true);
// Immediately commit context from the prior assistant message (N-1) so generation
// uses the world state before this message, not the last-viewed sibling swipe.
commitTrackerDataFromPriorMessage(messageIndex);
// console.log('[RPG Companion] 🔵 NEW swipe detected - Set lastActionWasSwipe = true');
} else {
// This is navigating to an EXISTING swipe - don't change the flag
// console.log('[RPG Companion] 🔵 EVENT: onMessageSwiped (existing swipe navigation) - lastActionWasSwipe unchanged =', lastActionWasSwipe);
// console.log('[RPG Companion] 🔵 EXISTING swipe navigation - lastActionWasSwipe unchanged =', lastActionWasSwipe);
}
if (isPendingNewSwipe) {
lastGeneratedData.characterThoughts = null;
}
// console.log('[RPG Companion] Loading data for swipe', currentSwipeId);
// Load RPG data for this swipe into lastGeneratedData (for display only)
// This updates what the user sees, but does NOT commit it
// Committed data will be updated when/if the user replies to this swipe
if (message.extra && message.extra.rpg_companion_swipes && message.extra.rpg_companion_swipes[currentSwipeId]) {
const swipeData = message.extra.rpg_companion_swipes[currentSwipeId];
// Update display data
// Load saved swipe data for the active swipe only.
// Using the current-swipe helper here avoids falling back to another
// stored swipe payload and showing stale tracker state.
if (swipeData) {
// Load swipe data into lastGeneratedData for display (both modes)
lastGeneratedData.userStats = swipeData.userStats || null;
lastGeneratedData.infoBox = swipeData.infoBox || null;
lastGeneratedData.characterThoughts = swipeData.characterThoughts || null;
// Parse user stats if available
// Normalize characterThoughts to string format (for backward compatibility with old object format)
if (swipeData.characterThoughts && typeof swipeData.characterThoughts === 'object') {
lastGeneratedData.characterThoughts = JSON.stringify(swipeData.characterThoughts, null, 2);
} else {
lastGeneratedData.characterThoughts = swipeData.characterThoughts || null;
}
// Sync extensionSettings.userStats so stat bars reflect this swipe
if (swipeData.userStats) {
parseUserStats(swipeData.userStats);
}
// console.log('[RPG Companion] Loaded RPG data for swipe', currentSwipeId, '(display only, NOT committed)');
// console.log('[RPG Companion] committedTrackerData unchanged - will be updated if user replies to this swipe');
// console.log('[RPG Companion] 🔄 Loaded swipe data for swipe:', currentSwipeId);
} else {
// No data for this swipe - keep existing lastGeneratedData (don't clear it)
// This ensures the display remains consistent and data is available for next commit
// console.log('[RPG Companion] No RPG data for swipe', currentSwipeId, '- keeping existing lastGeneratedData');
// console.log('[RPG Companion] ️ No stored data for swipe:', currentSwipeId);
}
// Re-render the panels (display only - committedTrackerData unchanged)
// Re-render the panels
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderEquipment();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Update widget strips with the newly loaded swipe data
updateFabWidgets();
updateStripWidgets();
// Update chat thought overlays
updateChatThoughts();
}
export function onMessageDeleted() {
if (!extensionSettings.enabled) {
return;
}
// Invalidate any pending or in-flight separate-mode generation so
// its result is not applied to the (now-changed) chat tail.
incrementSeparateGenerationId();
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;
}
}
syncDisplayedTrackerStateFromChat();
// After the display state has been rebuilt, restore generation context from
// the assistant message immediately before the new tail message so the next
// generation uses the correct N-1 tracker state.
if (lastAssistantIndex !== -1) {
commitTrackerDataFromPriorMessage(lastAssistantIndex);
}
saveChatData();
}
/**
* Update the persona avatar image when user switches personas
*/
@@ -346,7 +857,32 @@ export function clearExtensionPrompts() {
setExtensionPrompt('rpg-companion-inject', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-example', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-dialogue-coloring', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-spotify', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false);
// Note: rpg-companion-plot is not cleared here since it's passed via quiet_prompt option
// console.log('[RPG Companion] Cleared all extension prompts');
}
/**
* Event handler for when generation stops or ends
* Re-applies checkpoint if SillyTavern unhid messages
*/
export async function onGenerationEnded() {
// console.log('[RPG Companion] 🏁 onGenerationEnded called');
// Note: isGenerating flag is cleared in onMessageReceived after parsing (together mode)
// or in apiClient.js after separate generation completes (separate mode)
// SillyTavern may auto-unhide messages when generation stops
// Re-apply checkpoint if one exists
await restoreCheckpointOnLoad();
}
/**
* Initialize history injection event listeners.
* Should be called once during extension initialization.
*/
export function initHistoryInjection() {
initHistoryInjectionListeners();
}
@@ -0,0 +1,550 @@
/**
* Thought-based Character Expressions for the below-chat Present Characters panel.
*
* Derives portrait expressions from the current Present Characters thoughts
* payload, while keeping SillyTavern's native Character Expressions widget
* independent from the below-chat panel.
*/
import { getContext } from '../../../../../../extensions.js';
import {
extensionSettings,
thoughtBasedExpressionPortraits,
setThoughtBasedExpressionPortraits
} from '../../core/state.js';
import {
getCurrentMessageSwipeTrackerData,
saveChatData,
setMessageSwipeTrackerField
} from '../../core/persistence.js';
import { isUsableThoughtBasedExpressionSrc } from '../../utils/thoughtBasedExpressionPortraits.js';
import {
getPresentCharactersTrackerData,
parsePresentCharacters
} from '../../utils/presentCharacters.js';
import {
classifyExpressionText,
clearExpressionsCompatibilityCache,
getExpressionClassificationSettingsSignature,
getExpressionPortraitSettingsSignature,
getExpressionsSettingsSignature,
isExpressionsExtensionEnabled,
resolveSpriteFolderNameForCharacter,
resolveExpressionPortraitForCharacter
} from '../../utils/sillyTavernExpressions.js';
const OFF_SCENE_THOUGHT_PATTERN = /\b(not\s+(currently\s+)?(in|at|present|in\s+the)\s+(the\s+)?(scene|area|room|location|vicinity))\b|\b(off[\s-]?scene)\b|\b(not\s+present)\b|\b(absent)\b|\b(away\s+from\s+(the\s+)?scene)\b/i;
const CHAT_CHANGE_RETRY_DELAYS = [0, 80, 220, 500];
const REFRESH_DEBOUNCE_DELAY = 80;
const THOUGHT_BASED_EXPRESSIONS_CACHE_VERSION = 1;
const THOUGHT_BASED_EXPRESSIONS_CACHE_FIELD = 'thoughtBasedExpressions';
let hiddenExpressionStyleElement = null;
let thoughtBasedExpressionsRefreshHandler = null;
let scheduledRefreshTimer = null;
let activeRefreshRunId = 0;
let lastCompletedRefreshSignature = null;
let lastExpressionSettingsSignature = null;
function normalizeName(name) {
return String(name || '').trim().toLowerCase();
}
function shouldHideNativeExpressionDisplay() {
return extensionSettings.enabled === true && extensionSettings.hideDefaultExpressionDisplay === true;
}
function shouldUseThoughtBasedExpressions() {
return extensionSettings.enabled === true
&& extensionSettings.enableThoughtBasedExpressions === true
&& extensionSettings.showAlternatePresentCharactersPanel === true;
}
function notifyThoughtBasedExpressionsConsumers() {
thoughtBasedExpressionsRefreshHandler?.();
}
function getHideStyleCss() {
return `
#expression-image,
#expression-holder,
.expression-holder,
[data-expression-container],
#expression-image img,
#expression-holder img,
.expression-holder img,
[data-expression-container] img {
position: absolute !important;
left: -10000px !important;
top: 0 !important;
width: 1px !important;
height: 1px !important;
overflow: hidden !important;
opacity: 0 !important;
pointer-events: none !important;
visibility: hidden !important;
}
`;
}
function hideNativeExpressionDisplay() {
if (hiddenExpressionStyleElement?.isConnected) {
return;
}
const styleElement = document.createElement('style');
styleElement.id = 'rpg-hidden-native-expression-display-style';
styleElement.textContent = getHideStyleCss();
document.head.appendChild(styleElement);
hiddenExpressionStyleElement = styleElement;
}
function showNativeExpressionDisplay() {
if (hiddenExpressionStyleElement?.isConnected) {
hiddenExpressionStyleElement.remove();
} else {
document.getElementById('rpg-hidden-native-expression-display-style')?.remove();
}
hiddenExpressionStyleElement = null;
}
function updateNativeExpressionDisplayVisibility() {
if (shouldHideNativeExpressionDisplay()) {
hideNativeExpressionDisplay();
} else {
showNativeExpressionDisplay();
}
}
function clearScheduledRefresh() {
if (scheduledRefreshTimer !== null) {
clearTimeout(scheduledRefreshTimer);
scheduledRefreshTimer = null;
}
}
function stableStringify(value) {
if (Array.isArray(value)) {
return `[${value.map(item => stableStringify(item)).join(',')}]`;
}
if (value && typeof value === 'object') {
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
}
return JSON.stringify(value);
}
function normalizeThoughtPayload(payload) {
if (!payload) {
return null;
}
if (typeof payload === 'object') {
return stableStringify(payload);
}
if (typeof payload !== 'string') {
return String(payload);
}
const trimmed = payload.trim();
if (!trimmed) {
return null;
}
try {
return stableStringify(JSON.parse(trimmed));
} catch {
return trimmed.replace(/\r\n/g, '\n');
}
}
function normalizeExpressionLabel(label) {
return String(label || '').trim().toLowerCase();
}
function arePortraitMapsEqual(left, right) {
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) {
return false;
}
return leftKeys.every(key => left[key] === right[key]);
}
function applyThoughtBasedExpressionPortraits(nextPortraits) {
if (arePortraitMapsEqual(thoughtBasedExpressionPortraits, nextPortraits)) {
return false;
}
setThoughtBasedExpressionPortraits(nextPortraits);
return true;
}
function purgeInvalidThoughtBasedExpressionPortraits() {
const nextPortraits = {};
for (const [characterName, src] of Object.entries(thoughtBasedExpressionPortraits)) {
if (isUsableThoughtBasedExpressionSrc(src)) {
nextPortraits[characterName] = src;
}
}
return applyThoughtBasedExpressionPortraits(nextPortraits);
}
function getMessageThoughtPayload(message) {
if (!message || message.is_user) {
return null;
}
const swipeData = getCurrentMessageSwipeTrackerData(message);
return normalizeThoughtPayload(swipeData?.characterThoughts ?? null);
}
function findThoughtSourceMessageInfo(characterThoughtsData) {
const chatMessages = getContext()?.chat || [];
const currentThoughts = normalizeThoughtPayload(characterThoughtsData);
let fallback = null;
for (let i = chatMessages.length - 1; i >= 0; i--) {
const message = chatMessages[i];
if (!message || message.is_user || message.is_system) {
continue;
}
const swipeData = getCurrentMessageSwipeTrackerData(message);
if (!swipeData) {
continue;
}
const sourceInfo = {
message,
messageIndex: i,
swipeId: Number(message.swipe_id ?? 0),
swipeData
};
if (!fallback) {
fallback = sourceInfo;
}
const messageThoughts = getMessageThoughtPayload(message);
if (currentThoughts && messageThoughts === currentThoughts) {
return sourceInfo;
}
}
return currentThoughts ? null : fallback;
}
function isThoughtBasedExpressionsCache(candidate) {
return !!(
candidate
&& typeof candidate === 'object'
&& !Array.isArray(candidate)
&& candidate.version === THOUGHT_BASED_EXPRESSIONS_CACHE_VERSION
&& candidate.entries
&& typeof candidate.entries === 'object'
&& !Array.isArray(candidate.entries)
);
}
function getSwipeThoughtBasedExpressionsCache(sourceInfo) {
const directCache = sourceInfo?.swipeData?.[THOUGHT_BASED_EXPRESSIONS_CACHE_FIELD];
return isThoughtBasedExpressionsCache(directCache) ? directCache : null;
}
function areThoughtBasedExpressionsCachesEqual(left, right) {
return stableStringify(left) === stableStringify(right);
}
function getThoughtBasedExpressionEntries(characterThoughtsData) {
const thoughtsConfig = extensionSettings.trackerConfig?.presentCharacters?.thoughts;
if (thoughtsConfig?.enabled === false) {
return [];
}
if (!characterThoughtsData) {
return [];
}
const presentCharacters = parsePresentCharacters(characterThoughtsData);
return presentCharacters
.map(character => ({
name: String(character?.name || '').trim(),
thought: String(character?.ThoughtsContent || '').trim()
}))
.filter(character => character.name && character.thought && !OFF_SCENE_THOUGHT_PATTERN.test(character.thought));
}
function buildRefreshSignature(thoughtEntries, expressionsSettingsSignature) {
return JSON.stringify({
expressionsSettingsSignature,
thoughtEntries: thoughtEntries.map(entry => ({
name: normalizeName(entry.name),
thought: entry.thought,
spriteFolderName: resolveSpriteFolderNameForCharacter(entry.name)
}))
});
}
async function refreshThoughtBasedExpressions({ force = false } = {}) {
updateNativeExpressionDisplayVisibility();
if (!extensionSettings.enabled) {
showNativeExpressionDisplay();
return;
}
if (!shouldUseThoughtBasedExpressions()) {
return;
}
if (!isExpressionsExtensionEnabled()) {
lastCompletedRefreshSignature = null;
lastExpressionSettingsSignature = null;
clearExpressionsCompatibilityCache();
const portraitsChanged = applyThoughtBasedExpressionPortraits({});
if (portraitsChanged) {
saveChatData();
}
notifyThoughtBasedExpressionsConsumers();
return;
}
const expressionsSettingsSignature = getExpressionsSettingsSignature();
if (expressionsSettingsSignature !== lastExpressionSettingsSignature) {
clearExpressionsCompatibilityCache();
lastExpressionSettingsSignature = expressionsSettingsSignature;
lastCompletedRefreshSignature = null;
}
const characterThoughtsData = getPresentCharactersTrackerData({ useCommittedFallback: true });
const thoughtEntries = getThoughtBasedExpressionEntries(characterThoughtsData);
const refreshSignature = buildRefreshSignature(thoughtEntries, expressionsSettingsSignature);
if (!force && refreshSignature === lastCompletedRefreshSignature) {
return;
}
const sourceInfo = findThoughtSourceMessageInfo(characterThoughtsData);
const cachedThoughtBasedExpressions = getSwipeThoughtBasedExpressionsCache(sourceInfo);
const cachedEntries = cachedThoughtBasedExpressions?.entries && typeof cachedThoughtBasedExpressions.entries === 'object' && !Array.isArray(cachedThoughtBasedExpressions.entries)
? cachedThoughtBasedExpressions.entries
: {};
const currentThoughtsSignature = normalizeThoughtPayload(characterThoughtsData);
const classificationSettingsSignature = getExpressionClassificationSettingsSignature();
const portraitSettingsSignature = getExpressionPortraitSettingsSignature();
const runId = ++activeRefreshRunId;
const nextPortraits = {};
const nextCacheEntries = {};
for (const entry of thoughtEntries) {
const portraitKey = normalizeName(entry.name);
if (!portraitKey) {
continue;
}
const spriteFolderName = resolveSpriteFolderNameForCharacter(entry.name);
const cachedEntry = cachedEntries[portraitKey] && typeof cachedEntries[portraitKey] === 'object'
? cachedEntries[portraitKey]
: null;
const previousSrc = nextPortraits[portraitKey] || thoughtBasedExpressionPortraits[portraitKey] || null;
const canReuseExpression = cachedEntry
&& cachedEntry.thought === entry.thought
&& cachedEntry.classificationSettingsSignature === classificationSettingsSignature
&& cachedEntry.spriteFolderName === spriteFolderName
&& typeof cachedEntry.expression === 'string';
const expression = canReuseExpression
? normalizeExpressionLabel(cachedEntry.expression)
: normalizeExpressionLabel(await classifyExpressionText(entry.thought, { characterName: entry.name }));
if (runId !== activeRefreshRunId) {
return;
}
const canReusePortrait = cachedEntry
&& cachedEntry.thought === entry.thought
&& cachedEntry.expression === expression
&& cachedEntry.portraitSettingsSignature === portraitSettingsSignature
&& cachedEntry.spriteFolderName === spriteFolderName
&& cachedEntry.portraitResolved === true;
const portraitSrc = canReusePortrait
? (isUsableThoughtBasedExpressionSrc(cachedEntry.portraitSrc) ? cachedEntry.portraitSrc : null)
: await resolveExpressionPortraitForCharacter(entry.name, expression, { previousSrc });
if (runId !== activeRefreshRunId) {
return;
}
if (isUsableThoughtBasedExpressionSrc(portraitSrc)) {
nextPortraits[portraitKey] = portraitSrc;
}
nextCacheEntries[portraitKey] = {
name: entry.name,
thought: entry.thought,
spriteFolderName,
classificationSettingsSignature,
portraitSettingsSignature,
expression,
portraitSrc: isUsableThoughtBasedExpressionSrc(portraitSrc) ? portraitSrc : null,
portraitResolved: true
};
}
if (runId !== activeRefreshRunId) {
return;
}
let cacheChanged = false;
if (sourceInfo) {
const nextCache = {
version: THOUGHT_BASED_EXPRESSIONS_CACHE_VERSION,
thoughtsSignature: currentThoughtsSignature,
entries: nextCacheEntries
};
if (!areThoughtBasedExpressionsCachesEqual(cachedThoughtBasedExpressions, nextCache)) {
setMessageSwipeTrackerField(sourceInfo.message, sourceInfo.swipeId, THOUGHT_BASED_EXPRESSIONS_CACHE_FIELD, nextCache);
cacheChanged = true;
}
}
lastCompletedRefreshSignature = refreshSignature;
const portraitsChanged = applyThoughtBasedExpressionPortraits(nextPortraits);
if (portraitsChanged || cacheChanged) {
saveChatData();
}
if (portraitsChanged) {
notifyThoughtBasedExpressionsConsumers();
}
}
export function setThoughtBasedExpressionsRefreshHandler(handler) {
thoughtBasedExpressionsRefreshHandler = typeof handler === 'function' ? handler : null;
}
export function queueThoughtBasedExpressionsUpdate({ immediate = false, force = false } = {}) {
clearScheduledRefresh();
const runRefresh = () => {
refreshThoughtBasedExpressions({ force }).catch(error => {
console.warn('[RPG Companion] Thought-based expressions update failed:', error);
});
};
if (immediate) {
runRefresh();
return;
}
scheduledRefreshTimer = setTimeout(() => {
scheduledRefreshTimer = null;
runRefresh();
}, REFRESH_DEBOUNCE_DELAY);
}
export function initThoughtBasedExpressions() {
const purged = purgeInvalidThoughtBasedExpressionPortraits();
updateNativeExpressionDisplayVisibility();
if (purged) {
saveChatData();
notifyThoughtBasedExpressionsConsumers();
}
if (shouldUseThoughtBasedExpressions()) {
queueThoughtBasedExpressionsUpdate({ immediate: true, force: true });
}
}
export function onThoughtBasedExpressionsChatChanged() {
if (!extensionSettings.enabled) {
showNativeExpressionDisplay();
return;
}
clearScheduledRefresh();
activeRefreshRunId += 1;
lastCompletedRefreshSignature = null;
lastExpressionSettingsSignature = null;
clearExpressionsCompatibilityCache();
const purged = purgeInvalidThoughtBasedExpressionPortraits();
if (purged) {
saveChatData();
notifyThoughtBasedExpressionsConsumers();
}
for (const delay of CHAT_CHANGE_RETRY_DELAYS) {
setTimeout(() => {
updateNativeExpressionDisplayVisibility();
if (shouldUseThoughtBasedExpressions()) {
queueThoughtBasedExpressionsUpdate({ immediate: true, force: true });
} else {
notifyThoughtBasedExpressionsConsumers();
}
}, delay);
}
}
export function onThoughtBasedExpressionsSettingChanged(enabled) {
updateNativeExpressionDisplayVisibility();
if (enabled) {
const purged = purgeInvalidThoughtBasedExpressionPortraits();
if (purged) {
saveChatData();
notifyThoughtBasedExpressionsConsumers();
}
if (shouldUseThoughtBasedExpressions()) {
queueThoughtBasedExpressionsUpdate({ immediate: true, force: true });
} else {
notifyThoughtBasedExpressionsConsumers();
}
return;
}
clearScheduledRefresh();
activeRefreshRunId += 1;
lastCompletedRefreshSignature = null;
lastExpressionSettingsSignature = null;
clearExpressionsCompatibilityCache();
notifyThoughtBasedExpressionsConsumers();
}
export function onAlternatePresentCharactersVisibilityChanged() {
updateNativeExpressionDisplayVisibility();
if (shouldUseThoughtBasedExpressions()) {
queueThoughtBasedExpressionsUpdate({ immediate: true, force: true });
return;
}
clearScheduledRefresh();
activeRefreshRunId += 1;
lastCompletedRefreshSignature = null;
lastExpressionSettingsSignature = null;
}
export function onHideDefaultExpressionDisplaySettingChanged(enabled) {
extensionSettings.hideDefaultExpressionDisplay = enabled === true;
updateNativeExpressionDisplayVisibility();
setTimeout(() => updateNativeExpressionDisplayVisibility(), 0);
setTimeout(() => updateNativeExpressionDisplayVisibility(), 120);
}
export function clearThoughtBasedExpressionsCache() {
clearScheduledRefresh();
activeRefreshRunId += 1;
lastCompletedRefreshSignature = null;
lastExpressionSettingsSignature = null;
clearExpressionsCompatibilityCache();
showNativeExpressionDisplay();
}
+578
View File
@@ -0,0 +1,578 @@
/**
* Equipment Actions Module
* Handles all user interactions with the equipment system
*/
import { extensionSettings, lastGeneratedData, committedTrackerData } from '../../core/state.js';
import { saveSettings, saveChatData, updateMessageSwipeData } from '../../core/persistence.js';
import { renderEquipment } from '../rendering/equipment.js';
import { renderUserStats } from '../rendering/userStats.js';
import { EQUIPMENT_CATEGORIES, escapeHtml } from '../equipment/constants.js';
import { i18n } from '../../core/i18n.js';
/**
* Check if a given slot is currently occupied by any item
* @param {string} slotId - Slot to check
* @param {Array} items - Equipment items array
* @returns {boolean}
*/
function isSlotOccupied(slotId, items) {
return items.some(item => item.slot === slotId);
}
/**
* Find the first available slot for a given equipment type
* @param {string} type - Equipment type (helmet, ring, accessory, etc.)
* @param {Array} items - Equipment items array
* @returns {string|null} Available slot ID or null if all full
*/
function findAvailableSlot(type, items) {
const category = EQUIPMENT_CATEGORIES[type];
if (!category) return null;
return category.slots.find(slot => !isSlotOccupied(slot, items)) || null;
}
/**
* Get the slot ID currently assigned to an item
* @param {Object} item - Equipment item
* @returns {string|null}
*/
function getItemSlot(item) {
return item.slot || null;
}
/**
* Convert old specific slot type (e.g. 'ring3') to generic category (e.g. 'ring')
* @param {string} type - Equipment type
* @returns {string} Generic type
*/
function normalizeType(type) {
if (!type) return type;
if (type.startsWith('ring') && type !== 'ring') return 'ring';
if (type.startsWith('accessory') && type !== 'accessory') return 'accessory';
return type;
}
/**
* Migrate old item types in the entire items array (for v6 → v7 migration)
* @param {Array} items - Equipment items array
*/
function migrateItemTypes(items) {
for (const item of items) {
if (item.type) {
item.type = normalizeType(item.type);
}
}
}
/**
* Generate a unique ID for equipment items
* @returns {string} Unique ID
*/
function generateItemId() {
return 'eq_' + Date.now() + '_' + Math.random().toString(36).substr(2, 6);
}
/**
* Updates lastGeneratedData and committedTrackerData to include current equipment state
*/
function updateEquipmentData() {
const equipment = extensionSettings.userStats?.equipment || { items: [], slots: {} };
const currentData = lastGeneratedData.userStats || committedTrackerData.userStats;
if (currentData) {
const trimmed = currentData.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
const jsonData = JSON.parse(currentData);
if (jsonData && typeof jsonData === 'object') {
jsonData.equipment = JSON.parse(JSON.stringify(equipment));
const updatedJSON = JSON.stringify(jsonData, null, 2);
lastGeneratedData.userStats = updatedJSON;
committedTrackerData.userStats = updatedJSON;
return;
}
} catch (e) {
console.warn('[RPG Equipment] Failed to parse JSON, falling back to text format:', e);
}
}
}
// Fallback: rebuild text format
const stats = extensionSettings.userStats;
const config = extensionSettings.trackerConfig?.userStats || {};
let text = '';
const enabledStats = config.customStats?.filter(stat => stat && stat.enabled && stat.name && stat.id) || [];
for (const stat of enabledStats) {
const value = stats[stat.id] !== undefined ? stats[stat.id] : 100;
text += `${stat.name}: ${value}%\n`;
}
if (config.statusSection?.enabled) {
if (config.statusSection.showMoodEmoji) {
text += `${stats.mood}: `;
}
text += `${stats.conditions || 'None'}\n`;
}
// Include equipment data in fallback
const equipped = equipment.items.filter(item => item.slot);
if (equipped.length > 0) {
text += `\nEquipment:\n`;
for (const item of equipped) {
const bonuses = Object.entries(item.stats || {})
.filter(([_, val]) => val > 0)
.map(([key, val]) => `${key.toUpperCase()}+${val}`)
.join(' ');
text += `- ${item.slot}: ${item.name}${bonuses ? ' (' + bonuses + ')' : ''}\n`;
}
}
lastGeneratedData.userStats = text.trim();
committedTrackerData.userStats = text.trim();
}
/**
* Shows the equipment creation modal
*/
export function showCreateModal() {
const modalHtml = generateCreateModalHTML();
$('body').append(modalHtml);
const $modal = $('#rpg-equipment-modal');
$modal.hide().fadeIn(200);
$('#rpg-eq-name').focus();
initStatCheckboxes();
}
/**
* Shows the equipment edit modal
* @param {string} itemId - ID of the item to edit
*/
export function showEditModal(itemId) {
const equipment = extensionSettings.userStats?.equipment || { items: [], slots: {} };
const item = equipment.items.find(i => i.id === itemId);
if (!item) return;
const modalHtml = generateEditModalHTML(item);
$('body').append(modalHtml);
const $modal = $('#rpg-equipment-modal');
$modal.hide().fadeIn(200);
initStatCheckboxes();
}
/**
* Closes the equipment modal
*/
export function closeModal() {
$('#rpg-equipment-modal').fadeOut(150, function() {
$(this).remove();
});
}
/**
* Generates HTML for the create modal
* @returns {string} Modal HTML
*/
function generateCreateModalHTML() {
const attributes = getAvailableAttributes();
const typeOptions = generateTypeOptions(null);
const statCheckboxes = generateStatCheckboxes(attributes, null);
return `
<div id="rpg-equipment-modal" class="rpg-equipment-modal" role="dialog" aria-modal="true">
<div class="rpg-equipment-modal-content">
<header class="rpg-equipment-modal-header">
<h3>
<i class="fa-solid fa-shield-halved"></i>
<span>${i18n.getTranslation('equipment.createItemTitle') || 'Create Equipment'}</span>
</h3>
<button id="rpg-equipment-modal-close" class="rpg-popup-close" type="button">
<i class="fa-solid fa-times"></i>
</button>
</header>
<div class="rpg-equipment-modal-body">
<div class="rpg-equipment-form-group">
<label for="rpg-eq-name">${i18n.getTranslation('equipment.name') || 'Name'}:</label>
<input type="text" id="rpg-eq-name" class="rpg-input" maxlength="50" placeholder="${i18n.getTranslation('equipment.namePlaceholder') || 'Enter equipment name...'}" />
</div>
<div class="rpg-equipment-form-group">
<label for="rpg-eq-type">${i18n.getTranslation('equipment.type') || 'Type'}:</label>
<select id="rpg-eq-type" class="rpg-select">${typeOptions}</select>
</div>
<div class="rpg-equipment-form-group">
<label>${i18n.getTranslation('equipment.stats') || 'Stats'}:</label>
<div class="rpg-eq-stats-grid">${statCheckboxes}</div>
</div>
<div class="rpg-equipment-form-group">
<label for="rpg-eq-description">${i18n.getTranslation('equipment.description') || 'Description'}:</label>
<textarea id="rpg-eq-description" class="rpg-textarea" maxlength="200" rows="2" placeholder="${i18n.getTranslation('equipment.descriptionPlaceholder') || 'Enter description (optional)...'}"></textarea>
</div>
</div>
<footer class="rpg-equipment-modal-footer">
<button id="rpg-equipment-modal-cancel" class="rpg-btn-secondary" type="button">
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button id="rpg-equipment-modal-save" class="rpg-btn-primary" type="button">
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('global.save') || 'Save'}
</button>
</footer>
</div>
</div>
`;
}
/**
* Generates HTML for the edit modal
* @param {Object} item - The item to edit
* @returns {string} Modal HTML
*/
function generateEditModalHTML(item) {
const attributes = getAvailableAttributes();
const typeOptions = generateTypeOptions(item.type);
const statCheckboxes = generateStatCheckboxes(attributes, item);
return `
<div id="rpg-equipment-modal" class="rpg-equipment-modal" role="dialog" aria-modal="true">
<div class="rpg-equipment-modal-content">
<header class="rpg-equipment-modal-header">
<h3>
<i class="fa-solid fa-shield-halved"></i>
<span>${i18n.getTranslation('equipment.editItemTitle') || 'Edit Equipment'}</span>
</h3>
<button id="rpg-equipment-modal-close" class="rpg-popup-close" type="button">
<i class="fa-solid fa-times"></i>
</button>
</header>
<div class="rpg-equipment-modal-body">
<div class="rpg-equipment-form-group">
<label for="rpg-eq-name">${i18n.getTranslation('equipment.name') || 'Name'}:</label>
<input type="text" id="rpg-eq-name" class="rpg-input" maxlength="50" value="${escapeHtml(item.name)}" placeholder="${i18n.getTranslation('equipment.namePlaceholder') || 'Enter equipment name...'}" />
</div>
<div class="rpg-equipment-form-group">
<label for="rpg-eq-type">${i18n.getTranslation('equipment.type') || 'Type'}:</label>
<select id="rpg-eq-type" class="rpg-select">${typeOptions}</select>
</div>
<div class="rpg-equipment-form-group">
<label>${i18n.getTranslation('equipment.stats') || 'Stats'}:</label>
<div class="rpg-eq-stats-grid">${statCheckboxes}</div>
</div>
<div class="rpg-equipment-form-group">
<label for="rpg-eq-description">${i18n.getTranslation('equipment.description') || 'Description'}:</label>
<textarea id="rpg-eq-description" class="rpg-textarea" maxlength="200" rows="2" placeholder="${i18n.getTranslation('equipment.descriptionPlaceholder') || 'Enter description (optional)...'}">${escapeHtml(item.description || '')}</textarea>
</div>
</div>
<footer class="rpg-equipment-modal-footer">
<button id="rpg-equipment-modal-cancel" class="rpg-btn-secondary" type="button">
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button id="rpg-equipment-modal-save" class="rpg-btn-primary" type="button" data-edit-id="${item.id}">
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('global.save') || 'Save'}
</button>
</footer>
</div>
</div>
`;
}
/**
* Generates type dropdown options
* @param {string|null} selectedType - Currently selected type or null
* @returns {string} HTML options
*/
function generateTypeOptions(selectedType) {
const types = Object.entries(EQUIPMENT_CATEGORIES).map(([value, def]) => ({
value,
label: def.slots[0] === value
? def.slots[0].charAt(0).toUpperCase() + def.slots[0].slice(1)
: value.charAt(0).toUpperCase() + value.slice(1) + (def.slots.length > 1 ? ` (max ${def.maxEquipped})` : '')
}));
return types.map(type => {
const selected = selectedType === type.value ? 'selected' : '';
return `<option value="${type.value}" ${selected}>${type.label}</option>`;
}).join('');
}
/**
* Generates stat checkboxes HTML
* @param {string[]} attributes - Available attribute IDs
* @param {Object|null} item - Current item for editing or null
* @returns {string} HTML for stat checkboxes
*/
function generateStatCheckboxes(attributes, item) {
return attributes.map(attr => {
const checked = item && item.stats && item.stats[attr] > 0;
const val = checked ? item.stats[attr] : 1;
return `
<label class="rpg-eq-stat-checkbox">
<input type="checkbox" class="rpg-eq-stat-check" data-attr="${attr}" ${checked ? 'checked' : ''} />
<span class="rpg-eq-stat-check-label">${attr.toUpperCase()}</span>
<input type="number" class="rpg-eq-stat-value-input" data-attr="${attr}" value="${val}" min="1" max="20" ${!checked ? 'disabled' : ''} />
</label>
`;
}).join('');
}
/**
* Gets available RPG attribute IDs from config
* @returns {string[]} Array of attribute IDs
*/
function getAvailableAttributes() {
const config = extensionSettings.trackerConfig?.userStats || {};
const rpgAttributes = config.rpgAttributes || [];
return rpgAttributes
.filter(attr => attr && attr.enabled && attr.id)
.map(attr => attr.id);
}
/**
* Initializes stat checkbox change handlers
*/
function initStatCheckboxes() {
$('.rpg-eq-stat-check').on('change', function() {
const $input = $(this).siblings('.rpg-eq-stat-value-input');
$input.prop('disabled', !$(this).prop('checked'));
});
}
/**
* Saves the equipment item from the modal form
*/
export function saveEquipmentItem() {
const name = $('#rpg-eq-name').val().trim();
const type = $('#rpg-eq-type').val();
const description = $('#rpg-eq-description').val().trim();
const editId = $('#rpg-equipment-modal-save').data('edit-id');
if (!name) {
$('#rpg-eq-name').focus();
return;
}
// Collect stats
const stats = {};
$('.rpg-eq-stat-check:checked').each(function() {
const attr = $(this).data('attr');
const val = parseInt($(this).siblings('.rpg-eq-stat-value-input').val()) || 1;
stats[attr] = Math.max(1, Math.min(20, val));
});
const equipment = extensionSettings.userStats?.equipment || { items: [], slots: {} };
// Migrate old types (ring1-ring10 -> ring, accessory1-3 -> accessory)
migrateItemTypes(equipment.items);
if (editId) {
// Edit existing item
const item = equipment.items.find(i => i.id === editId);
if (item) {
const wasEquipped = !!item.slot;
item.name = name;
item.type = normalizeType(type);
item.stats = stats;
item.description = description;
// If type changed and item was equipped, re-equip to valid slot for new type
if (wasEquipped) {
const newCategory = EQUIPMENT_CATEGORIES[item.type];
if (newCategory && !newCategory.slots.includes(item.slot)) {
// Old slot is invalid for new type, find a new one
const newSlot = findAvailableSlot(item.type, equipment.items.filter(i => i.id !== editId));
item.slot = newSlot || null;
}
}
}
} else {
// Create new item
const newItem = {
id: generateItemId(),
name: name,
type: normalizeType(type),
stats: stats,
description: description,
slot: null
};
equipment.items.push(newItem);
// Auto-equip to first available slot
const availableSlot = findAvailableSlot(newItem.type, equipment.items);
if (availableSlot) {
newItem.slot = availableSlot;
} else {
console.warn(`[RPG Equipment] Created "${name}" but no available slot for type "${newItem.type}". Item added to inventory unequipped.`);
}
}
updateEquipmentData();
saveSettings();
saveChatData();
updateMessageSwipeData();
closeModal();
renderEquipment();
renderUserStats();
}
/**
* Equips an item to its designated slot
* @param {string} itemId - ID of the item to equip
*/
export function equipItem(itemId) {
const equipment = extensionSettings.userStats?.equipment || { items: [], slots: {} };
const item = equipment.items.find(i => i.id === itemId);
if (!item) return;
const type = normalizeType(item.type);
item.type = type;
const category = EQUIPMENT_CATEGORIES[type];
if (!category) return;
const availableSlot = findAvailableSlot(type, equipment.items);
if (!availableSlot) {
console.warn(`[RPG Equipment] No available slot for type "${type}". All ${EQUIPMENT_CATEGORIES[type].maxEquipped} slots are full.`);
return;
}
item.slot = availableSlot;
updateEquipmentData();
saveSettings();
saveChatData();
updateMessageSwipeData();
renderEquipment();
renderUserStats();
}
/**
* Unequips an item from a slot
* @param {string} slotId - The slot to unequip from
*/
export function unequipItem(slotId) {
const equipment = extensionSettings.userStats?.equipment || { items: [], slots: {} };
const item = equipment.items.find(i => i.slot === slotId);
if (item) {
item.slot = null;
}
updateEquipmentData();
saveSettings();
saveChatData();
updateMessageSwipeData();
renderEquipment();
renderUserStats();
}
/**
* Deletes an equipment item
* @param {string} itemId - ID of the item to delete
*/
export function deleteItem(itemId) {
const equipment = extensionSettings.userStats?.equipment || { items: [], slots: {} };
// Unequip if currently equipped
const item = equipment.items.find(i => i.id === itemId);
if (item && item.slot) {
item.slot = null;
}
// Remove from items array
equipment.items = equipment.items.filter(i => i.id !== itemId);
updateEquipmentData();
saveSettings();
saveChatData();
updateMessageSwipeData();
renderEquipment();
renderUserStats();
}
/**
* Calculates total equipment bonuses per attribute from currently equipped items
* @returns {Object} Map of attribute ID to total bonus value
*/
export function getEquipmentBonuses() {
const equipment = extensionSettings.userStats?.equipment || { items: [], slots: {} };
const bonuses = {};
const items = equipment.items || [];
for (const item of items) {
if (!item.slot || !item.stats) continue;
for (const [attr, val] of Object.entries(item.stats)) {
if (val > 0) {
bonuses[attr] = (bonuses[attr] || 0) + val;
}
}
}
return bonuses;
}
/**
* Initializes all event listeners for equipment interactions
*/
export function initEquipmentEventListeners() {
// Show create modal
$(document).on('click', '[data-action="show-create-modal"]', function(e) {
e.preventDefault();
showCreateModal();
});
// Equip item from inventory
$(document).on('click', '[data-action="equip"]', function(e) {
e.preventDefault();
const itemId = $(this).data('item-id');
equipItem(itemId);
});
// Unequip item from slot
$(document).on('click', '[data-action="unequip"]', function(e) {
e.preventDefault();
const slot = $(this).closest('.rpg-equipment-slot').data('slot');
unequipItem(slot);
});
// Edit item
$(document).on('click', '[data-action="edit-item"]', function(e) {
e.preventDefault();
const itemId = $(this).data('item-id');
showEditModal(itemId);
});
// Delete item
$(document).on('click', '[data-action="delete-item"]', function(e) {
e.preventDefault();
const itemId = $(this).data('item-id');
deleteItem(itemId);
});
// Modal close button
$(document).on('click', '#rpg-equipment-modal-close', closeModal);
// Modal cancel button
$(document).on('click', '#rpg-equipment-modal-cancel', closeModal);
// Modal save button
$(document).on('click', '#rpg-equipment-modal-save', saveEquipmentItem);
// Close modal on backdrop click
$(document).on('click', '#rpg-equipment-modal', function(e) {
if (e.target === this) {
closeModal();
}
});
// Enter key to save in modal inputs
$(document).on('keypress', '#rpg-eq-name', function(e) {
if (e.which === 13) {
e.preventDefault();
saveEquipmentItem();
}
});
}
+46 -5
View File
@@ -39,15 +39,56 @@ let openForms = {
/**
* Updates lastGeneratedData.userStats AND committedTrackerData.userStats to include
* current inventory in text format.
* current inventory.
* Maintains JSON format if current data is JSON, otherwise uses text format.
* This ensures manual edits are immediately visible to AI in next generation.
*/
function updateLastGeneratedDataInventory() {
// Rebuild the userStats text format using custom stat names
const statsText = buildUserStatsText();
// Check if current data is in JSON format
const currentData = lastGeneratedData.userStats || committedTrackerData.userStats;
if (currentData) {
const trimmed = currentData.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
// Maintain JSON format
try {
const jsonData = JSON.parse(currentData);
if (jsonData && typeof jsonData === 'object') {
// Update inventory in JSON
const stats = extensionSettings.userStats;
// Update BOTH lastGeneratedData AND committedTrackerData
// This makes manual edits immediately visible to AI
// Convert inventory back to v3 format (arrays of {name, quantity})
const convertToV3Items = (itemString) => {
if (!itemString) return [];
const items = itemString.split(',').map(s => s.trim()).filter(s => s);
return items.map(item => {
const qtyMatch = item.match(/^(\d+)x\s+(.+)$/);
if (qtyMatch) {
return { name: qtyMatch[2].trim(), quantity: parseInt(qtyMatch[1]) };
}
return { name: item, quantity: 1 };
});
};
jsonData.inventory = {
onPerson: convertToV3Items(stats.inventory.onPerson),
clothing: convertToV3Items(stats.inventory.clothing),
stored: stats.inventory.stored || {},
assets: convertToV3Items(stats.inventory.assets)
};
const updatedJSON = JSON.stringify(jsonData, null, 2);
lastGeneratedData.userStats = updatedJSON;
committedTrackerData.userStats = updatedJSON;
return;
}
} catch (e) {
console.warn('[RPG Companion] Failed to parse JSON, falling back to text format:', e);
}
}
}
// Fall back to text format
const statsText = buildUserStatsText();
lastGeneratedData.userStats = statsText;
committedTrackerData.userStats = statsText;
}
+46 -6
View File
@@ -79,15 +79,58 @@ export function updateInventoryItem(field, index, newName, location) {
/**
* Updates lastGeneratedData.userStats AND committedTrackerData.userStats to include
* current inventory in text format.
* current inventory.
* Maintains JSON format if current data is JSON, otherwise uses text format.
* This ensures manual edits are immediately visible to AI in next generation.
* @private
*/
function updateLastGeneratedDataInventory() {
// Check if current data is in JSON format
const currentData = lastGeneratedData.userStats || committedTrackerData.userStats;
if (currentData) {
const trimmed = currentData.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
// Maintain JSON format
try {
const jsonData = JSON.parse(currentData);
if (jsonData && typeof jsonData === 'object') {
// Update inventory in JSON
const stats = extensionSettings.userStats;
// Convert inventory back to v3 format (arrays of {name, quantity})
const convertToV3Items = (itemString) => {
if (!itemString) return [];
const items = itemString.split(',').map(s => s.trim()).filter(s => s);
return items.map(item => {
const qtyMatch = item.match(/^(\d+)x\s+(.+)$/);
if (qtyMatch) {
return { name: qtyMatch[2].trim(), quantity: parseInt(qtyMatch[1]) };
}
return { name: item, quantity: 1 };
});
};
jsonData.inventory = {
onPerson: convertToV3Items(stats.inventory.onPerson),
clothing: convertToV3Items(stats.inventory.clothing),
stored: stats.inventory.stored || {},
assets: convertToV3Items(stats.inventory.assets)
};
const updatedJSON = JSON.stringify(jsonData, null, 2);
lastGeneratedData.userStats = updatedJSON;
committedTrackerData.userStats = updatedJSON;
return;
}
} catch (e) {
console.warn('[RPG Companion] Failed to parse JSON, falling back to text format:', e);
}
}
}
// Fall back to text format
const stats = extensionSettings.userStats;
const inventorySummary = buildInventorySummary(stats.inventory);
// Rebuild the userStats text format
const statsText =
`Health: ${stats.health}%\n` +
`Satiety: ${stats.satiety}%\n` +
@@ -96,9 +139,6 @@ function updateLastGeneratedDataInventory() {
`Arousal: ${stats.arousal}%\n` +
`${stats.mood}: ${stats.conditions}\n` +
`${inventorySummary}`;
// Update BOTH lastGeneratedData AND committedTrackerData
// This makes manual edits immediately visible to AI
lastGeneratedData.userStats = statsText;
committedTrackerData.userStats = statsText;
}
+157
View File
@@ -0,0 +1,157 @@
/**
* Equipment Rendering Module
* Handles UI rendering for the equipment grid and item creation
*/
import { extensionSettings, $equipmentContainer } from '../../core/state.js';
import { i18n } from '../../core/i18n.js';
import { EQUIPMENT_CATEGORIES, SLOTS_LIST, escapeHtml } from '../equipment/constants.js';
/**
* Renders a single equipment slot
* @param {Object} slotDef - Slot definition from SLOTS_LIST
* @param {Object|null} item - The equipped item or null
* @returns {string} HTML for the slot
*/
function renderSlot(slotDef, item) {
const slotId = slotDef.id;
const slotName = i18n.getTranslation(`equipment.slots.${slotId}`) || slotId.replace(/(\d+)/, ' $1');
const equippedClass = item ? 'equipped' : '';
if (item) {
const statsText = Object.entries(item.stats || {})
.filter(([_, val]) => val > 0)
.map(([key, val]) => `<span class="rpg-eq-stat"><span class="rpg-eq-stat-label">${escapeHtml(key.toUpperCase())}</span><span class="rpg-eq-stat-value">+${val}</span></span>`)
.join('');
return `
<div class="rpg-equipment-slot ${equippedClass}" data-slot="${slotId}" data-item-id="${item.id}">
<div class="rpg-equipment-slot-header">
<i class="fa-solid ${slotDef.icon}"></i>
<span class="rpg-equipment-slot-name">${escapeHtml(slotName)}</span>
<button class="rpg-equipment-unequip-btn" data-action="unequip" title="${i18n.getTranslation('equipment.unequip') || 'Unequip'}">
<i class="fa-solid fa-times"></i>
</button>
</div>
<div class="rpg-equipment-item-name">${escapeHtml(item.name)}</div>
${statsText ? `<div class="rpg-equipment-stats">${statsText}</div>` : ''}
${item.description ? `<div class="rpg-equipment-description">${escapeHtml(item.description)}</div>` : ''}
<div class="rpg-equipment-item-actions">
<button class="rpg-equipment-edit-btn" data-action="edit-item" data-item-id="${item.id}" title="${i18n.getTranslation('equipment.editItem') || 'Edit item'}">
<i class="fa-solid fa-pen"></i>
</button>
<button class="rpg-equipment-delete-btn" data-action="delete-item" data-item-id="${item.id}" title="${i18n.getTranslation('equipment.deleteItem') || 'Delete item'}">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
`;
}
return `
<div class="rpg-equipment-slot" data-slot="${slotId}">
<div class="rpg-equipment-slot-header">
<i class="fa-solid ${slotDef.icon}"></i>
<span class="rpg-equipment-slot-name">${escapeHtml(slotName)}</span>
</div>
<div class="rpg-equipment-empty">${i18n.getTranslation('equipment.emptySlot') || 'Empty'}</div>
</div>
`;
}
/**
* Generates the full equipment section HTML
* @returns {string} Complete HTML for the equipment section
*/
function generateEquipmentHTML() {
const equipment = extensionSettings.userStats?.equipment || { items: [], slots: {} };
const slots = equipment.slots || {};
const items = equipment.items || [];
let html = '<div class="rpg-equipment-container">';
// Header with add button
html += `
<div class="rpg-equipment-header">
<h3>
<i class="fa-solid fa-shield-halved"></i>
<span data-i18n-key="equipment.title">${i18n.getTranslation('equipment.title') || 'Equipment'}</span>
</h3>
<button class="rpg-equipment-add-btn" data-action="show-create-modal" title="${i18n.getTranslation('equipment.createItem') || 'Create new equipment'}">
<i class="fa-solid fa-plus"></i> ${i18n.getTranslation('equipment.createItem') || 'Create Equipment'}
</button>
</div>
`;
// Equipment grid
html += '<div class="rpg-equipment-grid">';
// Render each slot
for (const slotDef of SLOTS_LIST) {
const item = items.find(i => i.slot === slotDef.id);
html += renderSlot(slotDef, item);
}
html += '</div>';
// Inventory list (items not currently equipped)
const unequipped = items.filter(item => !item.slot);
if (unequipped.length > 0) {
html += '<div class="rpg-equipment-inventory">';
html += `<h4>${i18n.getTranslation('equipment.inventoryTitle') || 'Inventory'}</h4>`;
html += '<div class="rpg-equipment-inventory-list">';
for (const item of unequipped) {
const category = EQUIPMENT_CATEGORIES[item.type];
const statsText = Object.entries(item.stats || {})
.filter(([_, val]) => val > 0)
.map(([key, val]) => `<span class="rpg-eq-stat"><span class="rpg-eq-stat-label">${escapeHtml(key.toUpperCase())}</span><span class="rpg-eq-stat-value">+${val}</span></span>`)
.join('');
html += `
<div class="rpg-equipment-inventory-item" data-item-id="${item.id}">
<div class="rpg-equipment-inventory-item-header">
<i class="fa-solid ${category ? category.icon : 'fa-circle'}"></i>
<span class="rpg-equipment-inventory-item-name">${escapeHtml(item.name)}</span>
<span class="rpg-equipment-inventory-item-type">${category ? (i18n.getTranslation(`equipment.types.${item.type}`) || item.type) : escapeHtml(item.type)}</span>
</div>
${statsText ? `<div class="rpg-equipment-stats">${statsText}</div>` : ''}
<div class="rpg-equipment-item-actions">
<button class="rpg-equipment-equip-btn" data-action="equip" data-item-id="${item.id}" title="${i18n.getTranslation('equipment.equip') || 'Equip'}">
<i class="fa-solid fa-hand"></i>
</button>
<button class="rpg-equipment-edit-btn" data-action="edit-item" data-item-id="${item.id}" title="${i18n.getTranslation('equipment.editItem') || 'Edit item'}">
<i class="fa-solid fa-pen"></i>
</button>
<button class="rpg-equipment-delete-btn" data-action="delete-item" data-item-id="${item.id}" title="${i18n.getTranslation('equipment.deleteItem') || 'Delete item'}">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
`;
}
html += '</div>';
html += '</div>';
}
html += '</div>';
return html;
}
/**
* Main equipment rendering function
* Gets data from state/settings and updates DOM directly.
*/
export function renderEquipment() {
if (!$equipmentContainer || !extensionSettings.showEquipment) {
return;
}
const html = generateEquipmentHTML();
$equipmentContainer.html(html);
// Re-apply translations
i18n.applyTranslations($equipmentContainer[0]);
}
+282 -65
View File
@@ -10,7 +10,28 @@ import {
committedTrackerData,
$infoBoxContainer
} from '../../core/state.js';
import { saveChatData } from '../../core/persistence.js';
import { saveChatData, setMessageSwipeTrackerField } from '../../core/persistence.js';
import { i18n } from '../../core/i18n.js';
import { isItemLocked } from '../generation/lockManager.js';
import { repairJSON } from '../../utils/jsonRepair.js';
import { updateFabWidgets } from '../ui/mobile.js';
/**
* Helper to generate lock icon HTML if setting is enabled
* @param {string} tracker - Tracker name
* @param {string} path - Item path
* @returns {string} Lock icon HTML or empty string
*/
function getLockIconHtml(tracker, path) {
const showLockIcons = extensionSettings.showLockIcons ?? true;
if (!showLockIcons) return '';
const isLocked = isItemLocked(tracker, path);
const lockIcon = isLocked ? '🔒' : '🔓';
const lockTitle = isLocked ? 'Locked' : 'Unlocked';
const lockedClass = isLocked ? ' locked' : '';
return `<span class="rpg-section-lock-icon${lockedClass}" data-tracker="${tracker}" data-path="${path}" title="${lockTitle}">${lockIcon}</span>`;
}
/**
* Helper to separate emoji from text in a string
@@ -55,41 +76,36 @@ function separateEmojiFromText(str) {
* Includes event listeners for editable fields.
*/
export function renderInfoBox() {
if (!extensionSettings.showInfoBox || !$infoBoxContainer) {
return;
}
// console.log('[RPG InfoBox Render] ==================== RENDERING INFO BOX ====================');
// console.log('[RPG InfoBox Render] showInfoBox setting:', extensionSettings.showInfoBox);
// console.log('[RPG InfoBox Render] Container exists:', !!$infoBoxContainer);
// Add updating class for animation
if (extensionSettings.enableAnimations) {
$infoBoxContainer.addClass('rpg-content-updating');
if (!extensionSettings.showInfoBox || !$infoBoxContainer) {
// console.log('[RPG InfoBox Render] Exiting: showInfoBox or container is false');
return;
}
// Use committedTrackerData as fallback if lastGeneratedData is empty (e.g., after page refresh)
const infoBoxData = lastGeneratedData.infoBox || committedTrackerData.infoBox;
// console.log('[RPG InfoBox Render] infoBoxData length:', infoBoxData ? infoBoxData.length : 'null');
// console.log('[RPG InfoBox Render] infoBoxData preview:', infoBoxData ? infoBoxData.substring(0, 200) : 'null');
// If no data yet, show placeholder
// If no data yet, hide the container (e.g., after cache clear)
if (!infoBoxData) {
const placeholderHtml = `
<div class="rpg-dashboard rpg-dashboard-row-1">
<div class="rpg-dashboard-widget rpg-placeholder-widget">
<div class="rpg-placeholder-text">No data yet</div>
<div class="rpg-placeholder-hint">Generate a new response in the roleplay or switch to "Separate Generation" in Settings to access and click the "Refresh RPG Info" button</div>
</div>
</div>
`;
$infoBoxContainer.html(placeholderHtml);
if (extensionSettings.enableAnimations) {
setTimeout(() => $infoBoxContainer.removeClass('rpg-content-updating'), 500);
}
// console.log('[RPG InfoBox Render] No data, hiding container');
$infoBoxContainer.empty().hide();
return;
}
// Show container and add updating class for animation
$infoBoxContainer.show();
if (extensionSettings.enableAnimations) {
$infoBoxContainer.addClass('rpg-content-updating');
}
// console.log('[RPG Companion] renderInfoBox called with data:', infoBoxData);
// Parse the info box data
const lines = infoBoxData.split('\n');
// console.log('[RPG Companion] Info Box split into lines:', lines);
const data = {
let data = {
date: '',
weekday: '',
month: '',
@@ -104,6 +120,45 @@ export function renderInfoBox() {
characters: []
};
// Check if data is v3 JSON format
const trimmed = infoBoxData.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
const jsonData = repairJSON(infoBoxData);
if (jsonData) {
// Extract from v3 JSON structure
data.weatherEmoji = jsonData.weather?.emoji || '';
data.weatherForecast = jsonData.weather?.forecast || '';
data.temperature = jsonData.temperature ? `${jsonData.temperature.value}°${jsonData.temperature.unit}` : '';
data.tempValue = jsonData.temperature?.value || 0;
data.timeStart = jsonData.time?.start || '';
data.timeEnd = jsonData.time?.end || '';
data.location = jsonData.location?.value || '';
// Parse date string to extract weekday, month, year
if (jsonData.date?.value) {
data.date = jsonData.date.value;
// Expected format: "Tuesday, October 17th, 2023"
const dateParts = data.date.split(',').map(p => p.trim());
data.weekday = dateParts[0] || '';
data.month = dateParts[1] || '';
data.year = dateParts[2] || '';
}
// Skip to rendering
} else {
// JSON parsing failed, fall back to text parsing
parseTextFormat();
}
} else {
// Text format
parseTextFormat();
}
function parseTextFormat() {
// Parse the info box data
const lines = infoBoxData.split('\n');
// console.log('[RPG Companion] Info Box split into lines:', lines);
// Track which fields we've already parsed to avoid duplicates from mixed formats
const parsedFields = {
date: false,
@@ -204,10 +259,10 @@ export function renderInfoBox() {
data.weatherEmoji = emoji;
data.weatherForecast = text;
} else if (weatherStr.includes(',')) {
// Fallback to comma split if emoji detection failed
const weatherParts = weatherStr.split(',').map(p => p.trim());
data.weatherEmoji = weatherParts[0] || '';
data.weatherForecast = weatherParts[1] || '';
// Fallback to comma split if emoji detection failed - split only on FIRST comma
const firstCommaIndex = weatherStr.indexOf(',');
data.weatherEmoji = weatherStr.substring(0, firstCommaIndex).trim();
data.weatherForecast = weatherStr.substring(firstCommaIndex + 1).trim();
} else {
// No clear separation - assume it's all forecast text
data.weatherEmoji = '🌤️'; // Default emoji
@@ -269,6 +324,7 @@ export function renderInfoBox() {
// timeStart: data.timeStart,
// location: data.location
// });
}
// Get tracker configuration
const config = extensionSettings.trackerConfig?.infoBox;
@@ -302,11 +358,14 @@ export function renderInfoBox() {
weekdayDisplay = weekdayDisplay;
}
const dateLockIconHtml = getLockIconHtml('infoBox', 'date');
row1Widgets.push(`
<div class="rpg-dashboard-widget rpg-calendar-widget">
<div class="rpg-calendar-top rpg-editable" contenteditable="true" data-field="month" data-full-value="${data.month || ''}" title="Click to edit">${monthDisplay}</div>
<div class="rpg-calendar-day rpg-editable" contenteditable="true" data-field="weekday" data-full-value="${data.weekday || ''}" title="Click to edit">${weekdayDisplay}</div>
<div class="rpg-calendar-year rpg-editable" contenteditable="true" data-field="year" data-full-value="${data.year || ''}" title="Click to edit">${yearDisplay}</div>
${dateLockIconHtml}
<div class="rpg-calendar-top rpg-editable" contenteditable="true" data-field="month" data-full-value="${data.month || ''}" title="${i18n.getTranslation('infoBox.clickToEdit') || 'Click to edit'}">${monthDisplay}</div>
<div class="rpg-calendar-day" title="${i18n.getTranslation('infoBox.clickToEdit') || 'Click to edit'}"><span class="rpg-calendar-day-text rpg-editable" contenteditable="true" data-field="weekday" data-full-value="${data.weekday || ''}">${weekdayDisplay}</span></div>
<div class="rpg-calendar-year rpg-editable" contenteditable="true" data-field="year" data-full-value="${data.year || ''}" title="${i18n.getTranslation('infoBox.clickToEdit') || 'Click to edit'}">${yearDisplay}</div>
</div>
`);
}
@@ -314,11 +373,14 @@ export function renderInfoBox() {
// Weather widget - show if enabled
if (config?.widgets?.weather?.enabled) {
const weatherEmoji = data.weatherEmoji || '🌤️';
const weatherForecast = data.weatherForecast || 'Weather';
const weatherForecast = data.weatherForecast || i18n.getTranslation('infoBox.weatherFallback') || 'Weather unknown';
const weatherLockIconHtml = getLockIconHtml('infoBox', 'weather');
row1Widgets.push(`
<div class="rpg-dashboard-widget rpg-weather-widget">
<div class="rpg-weather-icon rpg-editable" contenteditable="true" data-field="weatherEmoji" title="Click to edit emoji">${weatherEmoji}</div>
<div class="rpg-weather-forecast rpg-editable" contenteditable="true" data-field="weatherForecast" title="Click to edit">${weatherForecast}</div>
${weatherLockIconHtml}
<div class="rpg-weather-icon rpg-editable" contenteditable="true" data-field="weatherEmoji" title="${i18n.getTranslation('userStats.clickToEditEmoji') || 'Click to edit emoji'}">${weatherEmoji}</div>
<div class="rpg-weather-forecast rpg-editable" contenteditable="true" data-field="weatherForecast" title="${i18n.getTranslation('infoBox.clickToEdit') || 'Click to edit'}">${weatherForecast}</div>
</div>
`);
}
@@ -337,12 +399,12 @@ export function renderInfoBox() {
if (preferredUnit === 'F' && isCelsius) {
// Convert C to F
const fahrenheit = Math.round((tempValue * 9/5) + 32);
const fahrenheit = Math.round((tempValue * 9 / 5) + 32);
tempDisplay = `${fahrenheit}°F`;
tempValue = fahrenheit;
} else if (preferredUnit === 'C' && isFahrenheit) {
// Convert F to C
const celsius = Math.round((tempValue - 32) * 5/9);
const celsius = Math.round((tempValue - 32) * 5 / 9);
tempDisplay = `${celsius}°C`;
tempValue = celsius;
}
@@ -353,27 +415,33 @@ export function renderInfoBox() {
}
// Calculate thermometer display (convert to Celsius for consistent thresholds)
const tempInCelsius = preferredUnit === 'F' ? Math.round((tempValue - 32) * 5/9) : tempValue;
const tempInCelsius = preferredUnit === 'F' ? Math.round((tempValue - 32) * 5 / 9) : tempValue;
const tempPercent = Math.min(100, Math.max(0, ((tempInCelsius + 20) / 60) * 100));
const tempColor = tempInCelsius < 10 ? '#4a90e2' : tempInCelsius < 25 ? '#67c23a' : '#e94560';
const tempLockIconHtml = getLockIconHtml('infoBox', 'temperature');
row1Widgets.push(`
<div class="rpg-dashboard-widget rpg-temp-widget">
${tempLockIconHtml}
<div class="rpg-thermometer">
<div class="rpg-thermometer-bulb"></div>
<div class="rpg-thermometer-tube">
<div class="rpg-thermometer-fill" style="height: ${tempPercent}%; background: ${tempColor}"></div>
</div>
</div>
<div class="rpg-temp-value rpg-editable" contenteditable="true" data-field="temperature" title="Click to edit">${tempDisplay}</div>
<div class="rpg-temp-value rpg-editable" contenteditable="true" data-field="temperature" title="${i18n.getTranslation('infoBox.clickToEdit') || 'Click to edit'}">${tempDisplay}</div>
</div>
`);
}
// Time widget - show if enabled
if (config?.widgets?.time?.enabled) {
const timeDisplay = data.timeEnd || data.timeStart || '12:00';
// Parse time for clock hands
const timeMatch = timeDisplay.match(/(\d+):(\d+)/);
// Get both start and end times
const timeStartDisplay = data.timeStart || '12:00';
const timeEndDisplay = data.timeEnd || data.timeStart || '12:00';
// Parse end time for clock hands (use end time for visual display)
const timeMatch = timeEndDisplay.match(/(\d+):(\d+)/);
let hourAngle = 0;
let minuteAngle = 0;
if (timeMatch) {
@@ -382,8 +450,12 @@ export function renderInfoBox() {
hourAngle = (hours % 12) * 30 + minutes * 0.5; // 30° per hour + 0.5° per minute
minuteAngle = minutes * 6; // 6° per minute
}
const timeLockIconHtml = getLockIconHtml('infoBox', 'time');
row1Widgets.push(`
<div class="rpg-dashboard-widget rpg-clock-widget">
${timeLockIconHtml}
<div class="rpg-clock">
<div class="rpg-clock-face">
<div class="rpg-clock-hour" style="transform: rotate(${hourAngle}deg)"></div>
@@ -391,7 +463,11 @@ export function renderInfoBox() {
<div class="rpg-clock-center"></div>
</div>
</div>
<div class="rpg-time-value rpg-editable" contenteditable="true" data-field="timeStart" title="Click to edit">${timeDisplay}</div>
<div class="rpg-time-range">
<div class="rpg-time-value rpg-editable" contenteditable="true" data-field="timeStart" title="${i18n.getTranslation('infoBox.clickToEdit') || 'Click to edit'}">${timeStartDisplay}</div>
<span class="rpg-time-separator">→</span>
<div class="rpg-time-value rpg-editable" contenteditable="true" data-field="timeEnd" title="${i18n.getTranslation('infoBox.clickToEdit') || 'Click to edit'}">${timeEndDisplay}</div>
</div>
</div>
`);
}
@@ -405,14 +481,17 @@ export function renderInfoBox() {
// Row 2: Location widget (full width) - show if enabled
if (config?.widgets?.location?.enabled) {
const locationDisplay = data.location || 'Location';
const locationDisplay = data.location || i18n.getTranslation('infoBox.locationFallback') || 'Unknown location';
const locationLockIconHtml = getLockIconHtml('infoBox', 'location');
html += `
<div class="rpg-dashboard rpg-dashboard-row-2">
<div class="rpg-dashboard-widget rpg-location-widget">
${locationLockIconHtml}
<div class="rpg-map-bg">
<div class="rpg-map-marker">📍</div>
</div>
<div class="rpg-location-text rpg-editable" contenteditable="true" data-field="location" title="Click to edit">${locationDisplay}</div>
<div class="rpg-location-text rpg-editable" contenteditable="true" data-field="location" title="${i18n.getTranslation('infoBox.clickToEdit') || 'Click to edit'}">${locationDisplay}</div>
</div>
</div>
`;
@@ -420,10 +499,21 @@ export function renderInfoBox() {
// Row 3: Recent Events widget (notebook style) - show if enabled
if (config?.widgets?.recentEvents?.enabled) {
// Parse Recent Events from infoBox string
// Parse Recent Events from infoBox (supports both JSON and text formats)
let recentEvents = [];
if (committedTrackerData.infoBox) {
const recentEventsLine = committedTrackerData.infoBox.split('\n').find(line => line.startsWith('Recent Events:'));
if (infoBoxData) {
// Try JSON format first
try {
const parsed = typeof infoBoxData === 'string'
? JSON.parse(infoBoxData)
: infoBoxData;
if (parsed && Array.isArray(parsed.recentEvents)) {
recentEvents = parsed.recentEvents;
}
} catch (e) {
// Fall back to old text format
const recentEventsLine = infoBoxData.split('\n').find(line => line.startsWith('Recent Events:'));
if (recentEventsLine) {
const eventsString = recentEventsLine.replace('Recent Events:', '').trim();
if (eventsString) {
@@ -431,6 +521,7 @@ export function renderInfoBox() {
}
}
}
}
const validEvents = recentEvents.filter(e => e && e.trim() && e !== 'Event 1' && e !== 'Event 2' && e !== 'Event 3');
@@ -439,15 +530,18 @@ export function renderInfoBox() {
validEvents.push('Click to add event');
}
const eventsLockIconHtml = getLockIconHtml('infoBox', 'recentEvents');
html += `
<div class="rpg-dashboard rpg-dashboard-row-3">
<div class="rpg-dashboard-widget rpg-events-widget">
${eventsLockIconHtml}
<div class="rpg-notebook-header">
<div class="rpg-notebook-ring"></div>
<div class="rpg-notebook-ring"></div>
<div class="rpg-notebook-ring"></div>
</div>
<div class="rpg-notebook-title">Recent Events</div>
<div class="rpg-notebook-title" data-i18n-key="infobox.recentEvents.title">${i18n.getTranslation('infobox.recentEvents.title') || 'Recent Events'}</div>
<div class="rpg-notebook-lines">
`;
@@ -456,7 +550,7 @@ export function renderInfoBox() {
html += `
<div class="rpg-notebook-line">
<span class="rpg-bullet">•</span>
<span class="rpg-event-text rpg-editable" contenteditable="true" data-field="event${i + 1}" title="Click to edit">${validEvents[i]}</span>
<span class="rpg-event-text rpg-editable" contenteditable="true" data-field="event${i + 1}" title="${i18n.getTranslation('infoBox.clickToEdit') || 'Click to edit'}">${validEvents[i]}</span>
</div>
`;
}
@@ -466,7 +560,7 @@ export function renderInfoBox() {
html += `
<div class="rpg-notebook-line rpg-event-add">
<span class="rpg-bullet">+</span>
<span class="rpg-event-text rpg-editable rpg-event-placeholder" contenteditable="true" data-field="event${i + 1}" title="Click to add event">Add event...</span>
<span class="rpg-event-text rpg-editable rpg-event-placeholder" contenteditable="true" data-field="event${i + 1}" title="Click to add event" data-i18n-key="infobox.recentEvents.addEventPlaceholder">${i18n.getTranslation('infobox.recentEvents.addEventPlaceholder') || 'Click to add event'}</span>
</div>
`;
}
@@ -483,8 +577,21 @@ export function renderInfoBox() {
$infoBoxContainer.html(html);
// Add dynamic text scaling for location field
const updateLocationTextSize = ($element) => {
const text = $element.text();
const charCount = text.length;
$element.css('--char-count', Math.min(charCount, 100));
};
// Initial size update for location
const $locationText = $infoBoxContainer.find('[data-field="location"]');
if ($locationText.length) {
updateLocationTextSize($locationText);
}
// Add event handlers for editable Info Box fields
$infoBoxContainer.find('.rpg-editable').on('blur', function() {
$infoBoxContainer.find('.rpg-editable').on('blur', function () {
const $this = $(this);
const field = $this.data('field');
const value = $this.text().trim();
@@ -500,26 +607,68 @@ export function renderInfoBox() {
}
}
// Update location text size dynamically
if (field === 'location') {
updateLocationTextSize($this);
}
// Handle recent events separately
if (field === 'event1' || field === 'event2' || field === 'event3') {
updateRecentEvent(field, value);
} else {
updateInfoBoxField(field, value);
}
// Update FAB widgets to reflect changes
updateFabWidgets();
});
// Update location size on input as well (real-time)
$infoBoxContainer.find('[data-field="location"]').on('input', function () {
updateLocationTextSize($(this));
});
// For date fields, show full value on focus
$infoBoxContainer.find('[data-field="month"], [data-field="weekday"], [data-field="year"]').on('focus', function() {
$infoBoxContainer.find('[data-field="month"], [data-field="weekday"], [data-field="year"]').on('focus', function () {
const fullValue = $(this).data('full-value');
if (fullValue) {
$(this).text(fullValue);
}
});
// Add event handler for lock icons (support both click and touch)
$infoBoxContainer.find('.rpg-section-lock-icon').on('click touchend', function (e) {
e.preventDefault();
e.stopPropagation();
const $lockIcon = $(this);
const tracker = $lockIcon.data('tracker');
const path = $lockIcon.data('path');
// Import lockManager dynamically to avoid circular dependencies
import('../generation/lockManager.js').then(({ setItemLock, isItemLocked }) => {
const isLocked = isItemLocked(tracker, path);
const newLockState = !isLocked;
setItemLock(tracker, path, newLockState);
// Update icon
$lockIcon.text(newLockState ? '🔒' : '🔓');
$lockIcon.attr('title', newLockState ? (i18n.getTranslation('infoBox.locked') || 'Locked') : (i18n.getTranslation('infoBox.unlocked') || 'Unlocked'));
$lockIcon.toggleClass('locked', newLockState);
// Save settings to persist lock state
saveSettings();
});
});
// Remove updating class after animation
if (extensionSettings.enableAnimations) {
setTimeout(() => $infoBoxContainer.removeClass('rpg-content-updating'), 500);
}
// Update weather effect after rendering
if (window.RPGCompanion?.updateWeatherEffect) {
window.RPGCompanion.updateWeatherEffect();
}
}
/**
@@ -535,6 +684,64 @@ export function updateInfoBoxField(field, value) {
lastGeneratedData.infoBox = 'Info Box\n---\n';
}
// Check if data is in v3 JSON format
const trimmed = lastGeneratedData.infoBox.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
// Handle v3 JSON format
const jsonData = repairJSON(lastGeneratedData.infoBox);
if (jsonData) {
// Update the appropriate field based on v3 structure
if (field === 'weatherEmoji') {
if (!jsonData.weather) jsonData.weather = {};
jsonData.weather.emoji = value;
} else if (field === 'weatherForecast') {
if (!jsonData.weather) jsonData.weather = {};
jsonData.weather.forecast = value;
} else if (field === 'temperature') {
// Parse temperature value and unit
const tempMatch = value.match(/(-?\d+)\s*°?\s*([CF]?)/i);
if (tempMatch) {
if (!jsonData.temperature) jsonData.temperature = {};
jsonData.temperature.value = parseInt(tempMatch[1]);
jsonData.temperature.unit = (tempMatch[2] || 'C').toUpperCase();
}
} else if (field === 'timeStart') {
if (!jsonData.time) jsonData.time = {};
jsonData.time.start = value;
} else if (field === 'timeEnd') {
if (!jsonData.time) jsonData.time = {};
jsonData.time.end = value;
} else if (field === 'location') {
if (!jsonData.location) jsonData.location = {};
jsonData.location.value = value;
} else if (field === 'weekday' || field === 'month' || field === 'year') {
// Update date components
if (!jsonData.date) jsonData.date = {};
let currentDate = jsonData.date.value || '';
const dateParts = currentDate.split(',').map(p => p.trim());
if (field === 'weekday') {
dateParts[0] = value;
} else if (field === 'month') {
dateParts[1] = value;
} else if (field === 'year') {
dateParts[2] = value;
}
jsonData.date.value = dateParts.filter(p => p).join(', ');
}
// Save back as JSON
lastGeneratedData.infoBox = JSON.stringify(jsonData, null, 2);
committedTrackerData.infoBox = lastGeneratedData.infoBox;
saveChatData();
renderInfoBox();
// console.log('[RPG Companion] Updated info box field (v3 JSON):', { field, value });
return;
}
}
// Fall back to text format handling
// Reconstruct the Info Box text with updated field
const lines = lastGeneratedData.infoBox.split('\n');
let dateLineFound = false;
@@ -607,14 +814,16 @@ export function updateInfoBoxField(field, value) {
if (line.startsWith('Weather:')) {
// New format: Weather: emoji, forecast
const weatherContent = line.replace('Weather:', '').trim();
const parts = weatherContent.split(',').map(p => p.trim());
const forecast = parts[1] || 'Weather';
// Split only on first comma to get emoji and rest
const firstCommaIndex = weatherContent.indexOf(',');
const forecast = firstCommaIndex > 0 ? weatherContent.substring(firstCommaIndex + 1).trim() : 'Weather';
return `Weather: ${value}, ${forecast}`;
} else {
// Legacy format: emoji: forecast
const parts = line.split(':');
if (parts.length >= 2) {
return `${value}: ${parts.slice(1).join(':').trim()}`;
const firstColonIndex = line.indexOf(':');
if (firstColonIndex >= 0) {
const forecast = line.substring(firstColonIndex + 1).trim();
return `${value}: ${forecast}`;
}
}
} else if (field === 'weatherForecast' && index === weatherLineIndex) {
@@ -622,14 +831,16 @@ export function updateInfoBoxField(field, value) {
if (line.startsWith('Weather:')) {
// New format: Weather: emoji, forecast
const weatherContent = line.replace('Weather:', '').trim();
const parts = weatherContent.split(',').map(p => p.trim());
const emoji = parts[0] || '🌤️';
// Split only on first comma to get emoji and rest
const firstCommaIndex = weatherContent.indexOf(',');
const emoji = firstCommaIndex > 0 ? weatherContent.substring(0, firstCommaIndex).trim() : '🌤️';
return `Weather: ${emoji}, ${value}`;
} else {
// Legacy format: emoji: forecast
const parts = line.split(':');
if (parts.length >= 2) {
return `${parts[0].trim()}: ${value}`;
const firstColonIndex = line.indexOf(':');
if (firstColonIndex >= 0) {
const emoji = line.substring(0, firstColonIndex).trim();
return `${emoji}: ${value}`;
}
}
} else if (field === 'temperature' && (line.includes('🌡️:') || line.startsWith('Temperature:'))) {
@@ -778,7 +989,7 @@ export function updateInfoBoxField(field, value) {
if (message.extra && message.extra.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0;
if (message.extra.rpg_companion_swipes[swipeId]) {
message.extra.rpg_companion_swipes[swipeId].infoBox = updatedLines.join('\n');
setMessageSwipeTrackerField(message, swipeId, 'infoBox', updatedLines.join('\n'));
// console.log('[RPG Companion] Updated infoBox in message swipe data');
}
}
@@ -863,7 +1074,7 @@ function updateRecentEvent(field, value) {
if (message.extra && message.extra.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0;
if (message.extra.rpg_companion_swipes[swipeId]) {
message.extra.rpg_companion_swipes[swipeId].infoBox = updatedLines.join('\n');
setMessageSwipeTrackerField(message, swipeId, 'infoBox', updatedLines.join('\n'));
}
}
break;
@@ -873,6 +1084,12 @@ function updateRecentEvent(field, value) {
saveChatData();
renderInfoBox();
console.log(`[RPG Companion] Updated recent event ${field}:`, value);
// Update weather effect after rendering
if (window.RPGCompanion?.updateWeatherEffect) {
window.RPGCompanion.updateWeatherEffect();
}
// console.log(`[RPG Companion] Updated recent event ${field}:`, value);
}
}
+224 -68
View File
@@ -4,13 +4,33 @@
*/
import { extensionSettings, $inventoryContainer } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { getInventoryRenderOptions, restoreFormStates } from '../interaction/inventoryActions.js';
import { updateInventoryItem } from '../interaction/inventoryEdit.js';
import { parseItems } from '../../utils/itemParser.js';
import { isItemLocked, setItemLock } from '../generation/lockManager.js';
import { i18n } from '../../core/i18n.js';
// Type imports
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
/**
* Helper to generate lock icon HTML if setting is enabled
* @param {string} tracker - Tracker name
* @param {string} path - Item path
* @returns {string} Lock icon HTML or empty string
*/
function getLockIconHtml(tracker, path) {
const showLockIcons = extensionSettings.showLockIcons ?? true;
if (!showLockIcons) return '';
const isLocked = isItemLocked(tracker, path);
const lockIcon = isLocked ? '🔒' : '🔓';
const lockTitle = isLocked ? i18n.getTranslation('global.locked') || 'Locked' : i18n.getTranslation('global.unlocked') || 'Unlocked';
const lockedClass = isLocked ? ' locked' : '';
return `<span class="rpg-section-lock-icon${lockedClass}" data-tracker="${tracker}" data-path="${path}" title="${lockTitle}">${lockIcon}</span>`;
}
/**
* Converts a location name to a safe ID for use in HTML element IDs.
* Must match the logic used in inventoryActions.js.
@@ -23,21 +43,29 @@ export function getLocationId(locationName) {
}
/**
* Renders the inventory sub-tab navigation (On Person, Stored, Assets)
* @param {string} activeTab - Currently active sub-tab ('onPerson', 'stored', 'assets')
* Renders the inventory sub-tab navigation (On Person, Clothing, Stored, Assets)
* @param {string} activeTab - Currently active sub-tab ('onPerson', 'clothing', 'stored', 'assets')
* @returns {string} HTML for sub-tab navigation
*/
export function renderInventorySubTabs(activeTab = 'onPerson') {
const onPersonText = i18n.getTranslation('inventory.section.onPerson') || 'On Person';
const clothingText = i18n.getTranslation('inventory.section.clothing') || 'Clothing';
const storedText = i18n.getTranslation('inventory.section.stored') || 'Stored';
const assetsText = i18n.getTranslation('inventory.section.assets') || 'Assets';
return `
<div class="rpg-inventory-subtabs">
<button class="rpg-inventory-subtab ${activeTab === 'onPerson' ? 'active' : ''}" data-tab="onPerson">
On Person
${onPersonText}
</button>
<button class="rpg-inventory-subtab ${activeTab === 'clothing' ? 'active' : ''}" data-tab="clothing">
${clothingText}
</button>
<button class="rpg-inventory-subtab ${activeTab === 'stored' ? 'active' : ''}" data-tab="stored">
Stored
${storedText}
</button>
<button class="rpg-inventory-subtab ${activeTab === 'assets' ? 'active' : ''}" data-tab="assets">
Assets
${assetsText}
</button>
</div>
`;
@@ -54,28 +82,34 @@ export function renderOnPersonView(onPersonItems, viewMode = 'list') {
let itemsHtml = '';
if (items.length === 0) {
itemsHtml = '<div class="rpg-inventory-empty">No items carried</div>';
itemsHtml = '<div class="rpg-inventory-empty">' + (i18n.getTranslation('inventory.onPerson.empty') || 'No items carried') + '</div>';
} else {
if (viewMode === 'grid') {
// Grid view: card-style items
itemsHtml = items.map((item, index) => `
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.onPerson.${item}`);
return `
<div class="rpg-item-card" data-field="onPerson" data-index="${index}">
<button class="rpg-item-remove" data-action="remove-item" data-field="onPerson" data-index="${index}" title="Remove item">
${lockIconHtml}
<button class="rpg-item-remove" data-action="remove-item" data-field="onPerson" data-index="${index}" title="${i18n.getTranslation('global.removeItem') || 'Remove item'}">
<i class="fa-solid fa-times"></i>
</button>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="onPerson" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="onPerson" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit') || 'Click to edit'}">${escapeHtml(item)}</span>
</div>
`).join('');
`}).join('');
} else {
// List view: full-width rows
itemsHtml = items.map((item, index) => `
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.onPerson.${item}`);
return `
<div class="rpg-item-row" data-field="onPerson" data-index="${index}">
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="onPerson" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="onPerson" data-index="${index}" title="Remove item">
${lockIconHtml}
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="onPerson" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit') || 'Click to edit'}">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="onPerson" data-index="${index}" title="${i18n.getTranslation('global.removeItem') || 'Remove item'}">
<i class="fa-solid fa-times"></i>
</button>
</div>
`).join('');
`}).join('');
}
}
@@ -84,30 +118,112 @@ export function renderOnPersonView(onPersonItems, viewMode = 'list') {
return `
<div class="rpg-inventory-section" data-section="onPerson">
<div class="rpg-inventory-header">
<h4>Items Currently Carried</h4>
<h4>${i18n.getTranslation('inventory.onPerson.title') || 'Items Currently Carried'}</h4>
<div class="rpg-inventory-header-actions">
<div class="rpg-view-toggle">
<button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="onPerson" data-view="list" title="List view">
<button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="onPerson" data-view="list" title="${i18n.getTranslation('global.listView') || 'List view'}">
<i class="fa-solid fa-list"></i>
</button>
<button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="onPerson" data-view="grid" title="Grid view">
<button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="onPerson" data-view="grid" title="${i18n.getTranslation('global.gridView') || 'Grid view'}">
<i class="fa-solid fa-th"></i>
</button>
</div>
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="onPerson" title="Add new item">
<i class="fa-solid fa-plus"></i> Add Item
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="onPerson" title="${i18n.getTranslation('inventory.onPerson.addItemTitle') || 'Add new item'}">
<i class="fa-solid fa-plus"></i> ${i18n.getTranslation('inventory.onPerson.addItemButton') || 'Add Item'}
</button>
</div>
</div>
<div class="rpg-inventory-content">
<div class="rpg-inline-form" id="rpg-add-item-form-onPerson" style="display: none;">
<input type="text" class="rpg-inline-input" id="rpg-new-item-onPerson" placeholder="Enter item name..." />
<input type="text" class="rpg-inline-input" id="rpg-new-item-onPerson" placeholder="${i18n.getTranslation('inventory.onPerson.addItemPlaceholder') || 'Enter item name...'}" />
<div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-item" data-field="onPerson">
<i class="fa-solid fa-times"></i> Cancel
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-item" data-field="onPerson">
<i class="fa-solid fa-check"></i> Add
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('global.add') || 'Add'}
</button>
</div>
</div>
<div class="rpg-item-list ${listViewClass}">
${itemsHtml}
</div>
</div>
</div>
`;
}
/**
* Renders the "Clothing" inventory view with list or grid display
* @param {string} clothingItems - Current clothing items (comma-separated string)
* @param {string} viewMode - View mode ('list' or 'grid')
* @returns {string} HTML for clothing view with items and add button
*/
export function renderClothingView(clothingItems, viewMode = 'list') {
const items = parseItems(clothingItems);
let itemsHtml = '';
if (items.length === 0) {
itemsHtml = '<div class="rpg-inventory-empty">' + (i18n.getTranslation('inventory.clothing.empty') || 'No clothing worn') + '</div>';
} else {
if (viewMode === 'grid') {
// Grid view: card-style items
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.clothing.${item}`);
return `
<div class="rpg-item-card" data-field="clothing" data-index="${index}">
${lockIconHtml}
<button class="rpg-item-remove" data-action="remove-item" data-field="clothing" data-index="${index}" title="${i18n.getTranslation('global.removeItem') || 'Remove item'}">
<i class="fa-solid fa-times"></i>
</button>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="clothing" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit') || 'Click to edit'}">${escapeHtml(item)}</span>
</div>
`}).join('');
} else {
// List view: full-width rows
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.clothing.${item}`);
return `
<div class="rpg-item-row" data-field="clothing" data-index="${index}">
${lockIconHtml}
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="clothing" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit') || 'Click to edit'}">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="clothing" data-index="${index}" title="${i18n.getTranslation('global.removeItem') || 'Remove item'}">
<i class="fa-solid fa-times"></i>
</button>
</div>
`}).join('');
}
}
const listViewClass = viewMode === 'list' ? 'rpg-item-list-view' : 'rpg-item-grid-view';
return `
<div class="rpg-inventory-section" data-section="clothing">
<div class="rpg-inventory-header">
<h4>${i18n.getTranslation('inventory.clothing.title') || 'Clothing & Armor'}</h4>
<div class="rpg-inventory-header-actions">
<div class="rpg-view-toggle">
<button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="clothing" data-view="list" title="${i18n.getTranslation('global.listView') || 'List view'}">
<i class="fa-solid fa-list"></i>
</button>
<button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="clothing" data-view="grid" title="${i18n.getTranslation('global.gridView') || 'Grid view'}">
<i class="fa-solid fa-th"></i>
</button>
</div>
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="clothing" title="${i18n.getTranslation('inventory.clothing.addItemTitle') || 'Add new clothing item'}">
<i class="fa-solid fa-plus"></i> ${i18n.getTranslation('inventory.clothing.addItemButton') || 'Add Clothing'}
</button>
</div>
</div>
<div class="rpg-inventory-content">
<div class="rpg-inline-form" id="rpg-add-item-form-clothing" style="display: none;">
<input type="text" class="rpg-inline-input" id="rpg-new-item-clothing" placeholder="${i18n.getTranslation('inventory.clothing.addItemPlaceholder') || 'Enter clothing item...'}" />
<div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-item" data-field="clothing">
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-item" data-field="clothing">
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('global.add') || 'Add'}
</button>
</div>
</div>
@@ -132,30 +248,30 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
let html = `
<div class="rpg-inventory-section" data-section="stored">
<div class="rpg-inventory-header">
<h4>Storage Locations</h4>
<h4>${i18n.getTranslation('inventory.stored.title') || 'Storage Locations'}</h4>
<div class="rpg-inventory-header-actions">
<div class="rpg-view-toggle">
<button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="stored" data-view="list" title="List view">
<button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="stored" data-view="list" title="${i18n.getTranslation('global.listView') || 'List view'}">
<i class="fa-solid fa-list"></i>
</button>
<button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="stored" data-view="grid" title="Grid view">
<button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="stored" data-view="grid" title="${i18n.getTranslation('global.gridView') || 'Grid view'}">
<i class="fa-solid fa-th"></i>
</button>
</div>
<button class="rpg-inventory-add-btn" data-action="add-location" title="Add new storage location">
<i class="fa-solid fa-plus"></i> Add Location
<button class="rpg-inventory-add-btn" data-action="add-location" title="${i18n.getTranslation('inventory.stored.addLocationTitle') || 'Add new storage location'}">
<i class="fa-solid fa-plus"></i> ${i18n.getTranslation('inventory.stored.addLocationButton') || 'Add Location'}
</button>
</div>
</div>
<div class="rpg-inventory-content">
<div class="rpg-inline-form" id="rpg-add-location-form" style="display: none;">
<input type="text" class="rpg-inline-input" id="rpg-new-location-name" placeholder="Enter location name..." />
<input type="text" class="rpg-inline-input" id="rpg-new-location-name" placeholder="${i18n.getTranslation('inventory.stored.addLocationPlaceholder') || 'Enter location name...'}" />
<div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-location">
<i class="fa-solid fa-times"></i> Cancel
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-location">
<i class="fa-solid fa-check"></i> Save
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('global.save') || 'Save'}
</button>
</div>
</div>
@@ -164,7 +280,7 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
if (locations.length === 0) {
html += `
<div class="rpg-inventory-empty">
No storage locations yet. Click "Add Location" to create one.
${i18n.getTranslation('inventory.stored.empty') || 'No storage locations yet. Click "Add Location" to create one.'}
</div>
`;
} else {
@@ -176,28 +292,34 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
let itemsHtml = '';
if (items.length === 0) {
itemsHtml = '<div class="rpg-inventory-empty">No items stored here</div>';
itemsHtml = '<div class="rpg-inventory-empty">' + (i18n.getTranslation('inventory.stored.noItems') || 'No items stored here') + '</div>';
} else {
if (viewMode === 'grid') {
// Grid view: card-style items
itemsHtml = items.map((item, index) => `
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.stored.${location}.${item}`);
return `
<div class="rpg-item-card" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}">
<button class="rpg-item-remove" data-action="remove-item" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Remove item">
${lockIconHtml}
<button class="rpg-item-remove" data-action="remove-item" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="${i18n.getTranslation('global.removeItem') || 'Remove item'}">
<i class="fa-solid fa-times"></i>
</button>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit') || 'Click to edit'}">${escapeHtml(item)}</span>
</div>
`).join('');
`}).join('');
} else {
// List view: full-width rows
itemsHtml = items.map((item, index) => `
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.stored.${location}.${item}`);
return `
<div class="rpg-item-row" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}">
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Remove item">
${lockIconHtml}
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit') || 'Click to edit'}">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="${i18n.getTranslation('global.removeItem') || 'Remove item'}">
<i class="fa-solid fa-times"></i>
</button>
</div>
`).join('');
`}).join('');
}
}
@@ -211,20 +333,20 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
</button>
<h5 class="rpg-storage-name">${escapeHtml(location)}</h5>
<div class="rpg-storage-actions">
<button class="rpg-inventory-remove-btn" data-action="remove-location" data-location="${escapeHtml(location)}" title="Remove this storage location">
<button class="rpg-inventory-remove-btn" data-action="remove-location" data-location="${escapeHtml(location)}" title="${i18n.getTranslation('inventory.stored.removeLocationTitle') || 'Remove this storage location'}">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
<div class="rpg-storage-content" ${isCollapsed ? 'style="display:none;"' : ''}>
<div class="rpg-inline-form" id="rpg-add-item-form-stored-${locationId}" style="display: none;">
<input type="text" class="rpg-inline-input rpg-location-item-input" data-location="${escapeHtml(location)}" placeholder="Enter item name..." />
<input type="text" class="rpg-inline-input rpg-location-item-input" data-location="${escapeHtml(location)}" placeholder="${i18n.getTranslation('inventory.addItemPlaceholder') || 'Enter item name...'}" />
<div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-item" data-field="stored" data-location="${escapeHtml(location)}">
<i class="fa-solid fa-times"></i> Cancel
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-item" data-field="stored" data-location="${escapeHtml(location)}">
<i class="fa-solid fa-check"></i> Add
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('global.add') || 'Add'}
</button>
</div>
</div>
@@ -232,19 +354,19 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
${itemsHtml}
</div>
<div class="rpg-storage-add-item-container">
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="stored" data-location="${escapeHtml(location)}" title="Add item to this location">
<i class="fa-solid fa-plus"></i> Add Item
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="stored" data-location="${escapeHtml(location)}" title="${i18n.getTranslation('inventory.stored.addItemToLocationTitle') || 'Add item to this location'}">
<i class="fa-solid fa-plus"></i> ${i18n.getTranslation('inventory.stored.addItemButton') || 'Add Item'}
</button>
</div>
</div>
<div class="rpg-inline-confirmation" id="rpg-remove-confirm-${locationId}" style="display: none;">
<p>Remove "${escapeHtml(location)}"? This will delete all items stored there.</p>
<p>${(i18n.getTranslation('inventory.stored.removeLocationConfirm') || 'Remove "{location}"? This will delete all items stored there.').replace('{location}', escapeHtml(location))}</p>
<div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-remove-location" data-location="${escapeHtml(location)}">
<i class="fa-solid fa-times"></i> Cancel
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button class="rpg-inline-btn rpg-inline-confirm" data-action="confirm-remove-location" data-location="${escapeHtml(location)}">
<i class="fa-solid fa-check"></i> Confirm
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('global.confirm') || 'Confirm'}
</button>
</div>
</div>
@@ -272,28 +394,34 @@ export function renderAssetsView(assets, viewMode = 'list') {
let itemsHtml = '';
if (items.length === 0) {
itemsHtml = '<div class="rpg-inventory-empty">No assets owned</div>';
itemsHtml = '<div class="rpg-inventory-empty">' + (i18n.getTranslation('inventory.assets.empty') || 'No assets owned') + '</div>';
} else {
if (viewMode === 'grid') {
// Grid view: card-style items
itemsHtml = items.map((item, index) => `
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.assets.${item}`);
return `
<div class="rpg-item-card" data-field="assets" data-index="${index}">
<button class="rpg-item-remove" data-action="remove-item" data-field="assets" data-index="${index}" title="Remove asset">
${lockIconHtml}
<button class="rpg-item-remove" data-action="remove-item" data-field="assets" data-index="${index}" title="${i18n.getTranslation('inventory.assets.removeAssetTitle') || 'Remove asset'}">
<i class="fa-solid fa-times"></i>
</button>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="assets" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="assets" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit') || 'Click to edit'}">${escapeHtml(item)}</span>
</div>
`).join('');
`}).join('');
} else {
// List view: full-width rows
itemsHtml = items.map((item, index) => `
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.assets.${item}`);
return `
<div class="rpg-item-row" data-field="assets" data-index="${index}">
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="assets" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="assets" data-index="${index}" title="Remove asset">
${lockIconHtml}
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="assets" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit') || 'Click to edit'}">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="assets" data-index="${index}" title="${i18n.getTranslation('inventory.assets.removeAssetTitle') || 'Remove asset'}">
<i class="fa-solid fa-times"></i>
</button>
</div>
`).join('');
`}).join('');
}
}
@@ -302,30 +430,30 @@ export function renderAssetsView(assets, viewMode = 'list') {
return `
<div class="rpg-inventory-section" data-section="assets">
<div class="rpg-inventory-header">
<h4>Vehicles, Property & Major Possessions</h4>
<h4>${i18n.getTranslation('inventory.assets.title') || 'Vehicles, Property & Major Possessions'}</h4>
<div class="rpg-inventory-header-actions">
<div class="rpg-view-toggle">
<button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="assets" data-view="list" title="List view">
<button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="assets" data-view="list" title="${i18n.getTranslation('global.listView') || 'List view'}">
<i class="fa-solid fa-list"></i>
</button>
<button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="assets" data-view="grid" title="Grid view">
<button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="assets" data-view="grid" title="${i18n.getTranslation('global.gridView') || 'Grid view'}">
<i class="fa-solid fa-th"></i>
</button>
</div>
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="assets" title="Add new asset">
<i class="fa-solid fa-plus"></i> Add Asset
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="assets" title="${i18n.getTranslation('inventory.assets.addItemTitle') || 'Add new asset'}">
<i class="fa-solid fa-plus"></i> ${i18n.getTranslation('inventory.assets.addAssetButton') || 'Add Asset'}
</button>
</div>
</div>
<div class="rpg-inventory-content">
<div class="rpg-inline-form" id="rpg-add-item-form-assets" style="display: none;">
<input type="text" class="rpg-inline-input" id="rpg-new-item-assets" placeholder="Enter asset name..." />
<input type="text class="rpg-inline-input" id="rpg-new-item-assets" placeholder="${i18n.getTranslation('inventory.assets.addAssetPlaceholder') || 'Enter asset name...'}" />
<div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-item" data-field="assets">
<i class="fa-solid fa-times"></i> Cancel
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-item" data-field="assets">
<i class="fa-solid fa-check"></i> Add
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('global.add') || 'Add'}
</button>
</div>
</div>
@@ -334,8 +462,7 @@ export function renderAssetsView(assets, viewMode = 'list') {
</div>
<div class="rpg-inventory-hint">
<i class="fa-solid fa-info-circle"></i>
Assets include vehicles (cars, motorcycles), property (homes, apartments),
and major equipment (workshop tools, special items).
${i18n.getTranslation('inventory.assets.description') || 'Assets include vehicles (cars, motorcycles), property (homes, apartments), and major equipment (workshop tools, special items).'}
</div>
</div>
</div>
@@ -397,6 +524,7 @@ function generateInventoryHTML(inventory, options = {}) {
// Get view modes from settings (default to 'list')
const viewModes = extensionSettings.inventoryViewModes || {
onPerson: 'list',
clothing: 'list',
stored: 'list',
assets: 'list'
};
@@ -406,6 +534,9 @@ function generateInventoryHTML(inventory, options = {}) {
case 'onPerson':
html += renderOnPersonView(v2Inventory.onPerson, viewModes.onPerson);
break;
case 'clothing':
html += renderClothingView(v2Inventory.clothing, viewModes.clothing);
break;
case 'stored':
html += renderStoredView(v2Inventory.stored, collapsedLocations, viewModes.stored);
break;
@@ -476,6 +607,31 @@ export function renderInventory() {
const newName = $(this).text().trim();
updateInventoryItem(field, index, newName, location);
});
// Add event listener for section lock icon clicks (support both click and touch)
$inventoryContainer.find('.rpg-section-lock-icon').on('click touchend', function(e) {
e.preventDefault();
e.stopPropagation();
const $icon = $(this);
const trackerType = $icon.data('tracker');
const itemPath = $icon.data('path');
const currentlyLocked = isItemLocked(trackerType, itemPath);
// Toggle lock state
setItemLock(trackerType, itemPath, !currentlyLocked);
// Update icon
const newIcon = !currentlyLocked ? '🔒' : '🔓';
const newTitle = !currentlyLocked ? 'Locked' : 'Unlocked';
$icon.text(newIcon);
$icon.attr('title', newTitle);
// Toggle 'locked' class for persistent visibility
$icon.toggleClass('locked', !currentlyLocked);
// Save settings
saveSettings();
});
}
/**
+150
View File
@@ -0,0 +1,150 @@
/**
* Music Player Rendering Module
* Handles UI rendering for Spotify music player widget
*/
import { extensionSettings, committedTrackerData } from '../../core/state.js';
import { i18n } from '../../core/i18n.js';
/**
* Creates a Spotify deep link URL that opens the Spotify app
* Uses spotify:search: protocol for app, falls back to web URL
* @param {Object} songData - Object with {song, artist, searchQuery}
* @returns {Object} Object with appUrl and webUrl
*/
function createSpotifyUrls(songData) {
if (!songData || !songData.searchQuery) {
return { appUrl: '', webUrl: '' };
}
const encodedQuery = encodeURIComponent(songData.searchQuery);
return {
// Spotify app protocol - opens directly in Spotify app on desktop/mobile
appUrl: `spotify:search:${encodedQuery}`,
// Web fallback - opens Spotify web player search
webUrl: `https://open.spotify.com/search/${encodedQuery}`
};
}
/**
* Opens Spotify with the given song
* Tries app protocol first, falls back to web
* @param {Object} songData - Song data object
*/
function openInSpotify(songData) {
const urls = createSpotifyUrls(songData);
// Try to open in Spotify app first
// On mobile, this will open the Spotify app if installed
// On desktop, this will open Spotify desktop app if installed
window.location.href = urls.appUrl;
// Fallback: If app doesn't open within 2 seconds, open web version
// This handles cases where Spotify app isn't installed
setTimeout(() => {
// Check if we're still on the same page (app didn't open)
// Note: This is a best-effort fallback
if (document.hasFocus()) {
window.open(urls.webUrl, '_blank');
}
}, 1500);
}
/**
* Renders the Spotify music player as a mini player widget above chat input
* @param {HTMLElement} container - Container element to render into
*/
export function renderMusicPlayer(container) {
// console.log('[RPG Companion] Music Player: renderMusicPlayer called');
// Remove old chat-attached player if it exists
$('#rpg-chat-music-player').remove();
// console.log('[RPG Companion] Music Player: enableSpotifyMusic =', extensionSettings.enableSpotifyMusic);
if (!extensionSettings.enableSpotifyMusic) {
// console.warn('[RPG Companion] Music Player: Spotify music is disabled');
return;
}
const songData = committedTrackerData.spotifyUrl;
// console.log('[RPG Companion] Music Player: Rendering with song:', songData);
if (!songData || !songData.displayText) {
// No song - don't show anything
return;
}
// Create the mini music player widget
const musicPlayerHtml = `
<div id="rpg-chat-music-player" class="rpg-music-widget">
<div class="rpg-music-widget-content">
<div class="rpg-music-widget-icon">
<i class="fa-brands fa-spotify"></i>
</div>
<div class="rpg-music-widget-info">
<div class="rpg-music-widget-title" title="${songData.song}">${songData.song}</div>
<div class="rpg-music-widget-artist" title="${songData.artist}">${songData.artist}</div>
</div>
<button class="rpg-music-widget-play" title="Play in Spotify">
<i class="fa-solid fa-play"></i>
</button>
<button class="rpg-music-widget-close" title="Dismiss">
<i class="fa-solid fa-times"></i>
</button>
</div>
</div>
`;
// Find the chat form container and insert widget before (above) it
const $chatForm = $('#send_form');
// console.log('[RPG Companion] Music Player: Found #send_form:', $chatForm.length > 0);
if ($chatForm.length === 0) {
console.error('[RPG Companion] Music Player: Could not find #send_form - cannot render widget!');
return;
}
// Insert widget inside (at top of) the chat form
// console.log('[RPG Companion] Music Player: Prepending widget to #send_form');
$chatForm.prepend(musicPlayerHtml);
// console.log('[RPG Companion] Music Player: Widget inserted, checking if visible...');
const $widget = $('#rpg-chat-music-player');
// console.log('[RPG Companion] Music Player: Widget exists:', $widget.length > 0);
if ($widget.length > 0) {
// console.log('[RPG Companion] Music Player: Widget position:', $widget.offset());
// console.log('[RPG Companion] Music Player: Widget dimensions:', { width: $widget.width(), height: $widget.height() });
// console.log('[RPG Companion] Music Player: Widget CSS display:', $widget.css('display'));
// console.log('[RPG Companion] Music Player: Widget CSS visibility:', $widget.css('visibility'));
}
// Bind play button click
$('#rpg-chat-music-player .rpg-music-widget-play').on('click', function(e) {
e.stopPropagation();
openInSpotify(songData);
});
// Bind close button click
$('#rpg-chat-music-player .rpg-music-widget-close').on('click', function(e) {
e.stopPropagation();
$('#rpg-chat-music-player').fadeOut(200, function() {
$(this).remove();
});
});
// Clicking anywhere else on the widget also opens Spotify
$('#rpg-chat-music-player .rpg-music-widget-content').on('click', function() {
openInSpotify(songData);
});
}
/**
* Updates the music player display
* @param {HTMLElement} container - Container element
*/
export function updateMusicPlayer(container) {
renderMusicPlayer(container);
}
+132 -31
View File
@@ -3,8 +3,52 @@
* Handles UI rendering for quests system (main and optional quests)
*/
import { extensionSettings, $questsContainer } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { extensionSettings, $questsContainer, committedTrackerData, lastGeneratedData } from '../../core/state.js';
import { saveSettings, saveChatData } from '../../core/persistence.js';
import { isItemLocked, setItemLock } from '../generation/lockManager.js';
import { i18n } from '../../core/i18n.js';
/**
* Syncs the current extensionSettings.quests to committedTrackerData.userStats
* This ensures quest changes made via UI are reflected in the data sent to AI
*/
function syncQuestsToCommittedData() {
const currentData = committedTrackerData.userStats || lastGeneratedData.userStats;
if (!currentData) return;
const trimmed = currentData.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
const jsonData = JSON.parse(currentData);
if (jsonData && typeof jsonData === 'object') {
// Update quests in the JSON data
jsonData.quests = extensionSettings.quests || { main: 'None', optional: [] };
const updatedJSON = JSON.stringify(jsonData, null, 2);
committedTrackerData.userStats = updatedJSON;
lastGeneratedData.userStats = updatedJSON;
}
} catch (e) {
console.warn('[RPG Quests] Failed to sync quests to committed data:', e);
}
}
}
/**
* Helper to generate lock icon HTML if setting is enabled
* @param {string} tracker - Tracker name
* @param {string} path - Item path
* @returns {string} Lock icon HTML or empty string
*/
function getLockIconHtml(tracker, path) {
const showLockIcons = extensionSettings.showLockIcons ?? true;
if (!showLockIcons) return '';
const isLocked = isItemLocked(tracker, path);
const lockIcon = isLocked ? '🔒' : '🔓';
const lockTitle = isLocked ? i18n.getTranslation('global.locked') || 'Locked' : i18n.getTranslation('global.unlocked') || 'Unlocked';
const lockedClass = isLocked ? ' locked' : '';
return `<span class="rpg-section-lock-icon${lockedClass}" data-tracker="${tracker}" data-path="${path}" title="${lockTitle}">${lockIcon}</span>`;
}
/**
* HTML escape helper
@@ -23,13 +67,16 @@ function escapeHtml(text) {
* @returns {string} HTML for sub-tab navigation
*/
export function renderQuestsSubTabs(activeTab = 'main') {
const mainText = i18n.getTranslation('quests.section.main') || 'Main Quest';
const optionalText = i18n.getTranslation('quests.section.optional') || 'Optional Quests';
return `
<div class="rpg-quests-subtabs">
<button class="rpg-quests-subtab ${activeTab === 'main' ? 'active' : ''}" data-tab="main">
Main Quest
${mainText}
</button>
<button class="rpg-quests-subtab ${activeTab === 'optional' ? 'active' : ''}" data-tab="optional">
Optional Quests
${optionalText}
</button>
</div>
`;
@@ -43,13 +90,18 @@ export function renderQuestsSubTabs(activeTab = 'main') {
export function renderMainQuestView(mainQuest) {
const questDisplay = (mainQuest && mainQuest !== 'None') ? mainQuest : '';
const hasQuest = questDisplay.length > 0;
const mainTitle = i18n.getTranslation('quests.main.title') || 'Main Quests';
const mainHint = i18n.getTranslation('quests.main.hint') || 'The main quest represents your primary objective in the story.';
const mainEmptyText = i18n.getTranslation('quests.main.empty') || 'No active main quests';
const addQuestButtonText = i18n.getTranslation('quests.main.addQuestButton') || 'Add Quest';
const addQuestPlaceholderText = i18n.getTranslation('quests.main.addQuestPlaceholder') || 'Enter main quest title...';
return `
<div class="rpg-quest-section">
<div class="rpg-quest-header">
<h3 class="rpg-quest-section-title">Main Quests</h3>
${!hasQuest ? `<button class="rpg-add-quest-btn" data-action="add-quest" data-field="main" title="Add main quests">
<i class="fa-solid fa-plus"></i> Add Quest
<h3 class="rpg-quest-section-title">${mainTitle}</h3>
${!hasQuest ? `<button class="rpg-add-quest-btn" data-action="add-quest" data-field="main" title="${i18n.getTranslation('quests.main.addQuestTitle') || 'Add main quests'}">
<i class="fa-solid fa-plus"></i> ${addQuestButtonText}
</button>` : ''}
</div>
<div class="rpg-quest-content">
@@ -58,42 +110,43 @@ export function renderMainQuestView(mainQuest) {
<input type="text" class="rpg-inline-input" id="rpg-edit-quest-main" value="${escapeHtml(questDisplay)}" />
<div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-edit-quest" data-field="main">
<i class="fa-solid fa-times"></i> Cancel
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-edit-quest" data-field="main">
<i class="fa-solid fa-check"></i> Save
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('global.save') || 'Save'}
</button>
</div>
</div>
<div class="rpg-quest-item" data-field="main">
${getLockIconHtml('userStats', 'quests.main')}
<div class="rpg-quest-title">${escapeHtml(questDisplay)}</div>
<div class="rpg-quest-actions">
<button class="rpg-quest-edit" data-action="edit-quest" data-field="main" title="Edit quest">
<button class="rpg-quest-edit" data-action="edit-quest" data-field="main" title="${i18n.getTranslation('quests.editQuestTitle') || 'Edit quest'}">
<i class="fa-solid fa-edit"></i>
</button>
<button class="rpg-quest-remove" data-action="remove-quest" data-field="main" title="Complete/Remove quest">
<button class="rpg-quest-remove" data-action="remove-quest" data-field="main" title="${i18n.getTranslation('quests.removeQuestTitle') || 'Complete/Remove quest'}">
<i class="fa-solid fa-check"></i>
</button>
</div>
</div>
` : `
<div class="rpg-inline-form" id="rpg-add-quest-form-main" style="display: none;">
<input type="text" class="rpg-inline-input" id="rpg-new-quest-main" placeholder="Enter main quests title..." />
<input type="text" class="rpg-inline-input" id="rpg-new-quest-main" placeholder="${addQuestPlaceholderText}" />
<div class="rpg-inline-actions">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-quest" data-field="main">
<i class="fa-solid fa-times"></i> Cancel
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-quest" data-field="main">
<i class="fa-solid fa-check"></i> Add
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('global.add') || 'Add'}
</button>
</div>
</div>
<div class="rpg-quest-empty">No active main quests</div>
<div class="rpg-quest-empty">${mainEmptyText}</div>
`}
</div>
<div class="rpg-quest-hint">
<i class="fa-solid fa-lightbulb"></i>
The main quests represent your primary objective in the story.
${mainHint}
</div>
</div>
`;
@@ -106,40 +159,47 @@ export function renderMainQuestView(mainQuest) {
*/
export function renderOptionalQuestsView(optionalQuests) {
const quests = optionalQuests.filter(q => q && q !== 'None');
const optionalTitle = i18n.getTranslation('quests.optional.title') || 'Optional Quests';
const optionalHint = i18n.getTranslation('quests.optional.hint') || 'Optional quests are side objectives that complement your main story.';
const optionalEmptyText = i18n.getTranslation('quests.optional.empty') || 'No active optional quests';
const addQuestButtonText = i18n.getTranslation('quests.optional.addQuestButton') || 'Add Quest';
const addQuestPlaceholderText = i18n.getTranslation('quests.optional.addQuestPlaceholder') || 'Enter optional quest title...';
let questsHtml = '';
if (quests.length === 0) {
questsHtml = '<div class="rpg-quest-empty">No active optional quests</div>';
questsHtml = `<div class="rpg-quest-empty">${optionalEmptyText}</div>`;
} else {
questsHtml = quests.map((quest, index) => `
questsHtml = quests.map((quest, index) => {
return `
<div class="rpg-quest-item" data-field="optional" data-index="${index}">
<div class="rpg-quest-title rpg-editable" contenteditable="true" data-field="optional" data-index="${index}" title="Click to edit">${escapeHtml(quest)}</div>
${getLockIconHtml('userStats', `quests.optional[${index}]`)}
<div class="rpg-quest-title rpg-editable" contenteditable="true" data-field="optional" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit') || 'Click to edit'}">${escapeHtml(quest)}</div>
<div class="rpg-quest-actions">
<button class="rpg-quest-remove" data-action="remove-quest" data-field="optional" data-index="${index}" title="Complete/Remove quest">
<button class="rpg-quest-remove" data-action="remove-quest" data-field="optional" data-index="${index}" title="${i18n.getTranslation('quests.removeQuestTitle') || 'Complete/Remove quest'}">
<i class="fa-solid fa-check"></i>
</button>
</div>
</div>
`).join('');
`}).join('');
}
return `
<div class="rpg-quest-section">
<div class="rpg-quest-header">
<h3 class="rpg-quest-section-title">Optional Quests</h3>
<button class="rpg-add-quest-btn" data-action="add-quest" data-field="optional" title="Add optional quest">
<i class="fa-solid fa-plus"></i> Add Quest
<h3 class="rpg-quest-section-title">${optionalTitle}</h3>
<button class="rpg-add-quest-btn" data-action="add-quest" data-field="optional" title="${i18n.getTranslation('quests.optional.addQuestTitle') || 'Add optional quest'}">
<i class="fa-solid fa-plus"></i> ${addQuestButtonText}
</button>
</div>
<div class="rpg-quest-content">
<div class="rpg-inline-form" id="rpg-add-quest-form-optional" style="display: none;">
<input type="text" class="rpg-inline-input" id="rpg-new-quest-optional" placeholder="Enter optional quest title..." />
<input type="text" class="rpg-inline-input" id="rpg-new-quest-optional" placeholder="${addQuestPlaceholderText}" />
<div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-quest" data-field="optional">
<i class="fa-solid fa-times"></i> Cancel
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-quest" data-field="optional">
<i class="fa-solid fa-check"></i> Add
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('global.add') || 'Add'}
</button>
</div>
</div>
@@ -148,7 +208,7 @@ export function renderOptionalQuestsView(optionalQuests) {
</div>
<div class="rpg-quest-hint">
<i class="fa-solid fa-info-circle"></i>
Optional quests are side objectives that complement your main story.
${optionalHint}
</div>
</div>
</div>
@@ -159,15 +219,19 @@ export function renderOptionalQuestsView(optionalQuests) {
* Main render function for quests
*/
export function renderQuests() {
if (!extensionSettings.showInventory || !$questsContainer) {
if (!extensionSettings.showQuests || !$questsContainer) {
return;
}
// Get current sub-tab from container or default to 'main'
const activeSubTab = $questsContainer.data('active-subtab') || 'main';
// Get quests data
const mainQuest = extensionSettings.quests.main || 'None';
// Get quests data - extract value if it's a locked object
let mainQuest = extensionSettings.quests.main || 'None';
// Recursively extract value if it's nested objects
while (typeof mainQuest === 'object' && mainQuest.value !== undefined) {
mainQuest = mainQuest.value;
}
const optionalQuests = extensionSettings.quests.optional || [];
// Build HTML
@@ -229,7 +293,10 @@ function attachQuestEventHandlers() {
}
extensionSettings.quests.optional.push(questTitle);
}
// Sync quest changes to committedTrackerData so AI sees the addition
syncQuestsToCommittedData();
saveSettings();
saveChatData();
renderQuests();
}
});
@@ -257,7 +324,10 @@ function attachQuestEventHandlers() {
if (questTitle) {
extensionSettings.quests.main = questTitle;
// Sync quest changes to committedTrackerData so AI sees the edit
syncQuestsToCommittedData();
saveSettings();
saveChatData();
renderQuests();
}
});
@@ -272,7 +342,10 @@ function attachQuestEventHandlers() {
} else {
extensionSettings.quests.optional.splice(index, 1);
}
// Sync quest changes to committedTrackerData so AI sees the removal
syncQuestsToCommittedData();
saveSettings();
saveChatData();
renderQuests();
});
@@ -285,7 +358,10 @@ function attachQuestEventHandlers() {
if (newTitle && field === 'optional' && index !== undefined) {
extensionSettings.quests.optional[index] = newTitle;
// Sync quest changes to committedTrackerData so AI sees the edit
syncQuestsToCommittedData();
saveSettings();
saveChatData();
}
});
@@ -303,4 +379,29 @@ function attachQuestEventHandlers() {
}
}
});
// Add event listener for section lock icon clicks (support both click and touch)
$questsContainer.find('.rpg-section-lock-icon').on('click touchend', function(e) {
e.preventDefault();
e.stopPropagation();
const $icon = $(this);
const trackerType = $icon.data('tracker');
const itemPath = $icon.data('path');
const currentlyLocked = isItemLocked(trackerType, itemPath);
// Toggle lock state
setItemLock(trackerType, itemPath, !currentlyLocked);
// Update icon
const newIcon = !currentlyLocked ? '🔒' : '🔓';
const newTitle = !currentlyLocked ? 'Locked' : 'Unlocked';
$icon.text(newIcon);
$icon.attr('title', newTitle);
// Toggle 'locked' class for persistent visibility
$icon.toggleClass('locked', !currentlyLocked);
// Save settings
saveSettings();
});
}
File diff suppressed because it is too large Load Diff
+314 -47
View File
@@ -12,6 +12,7 @@ import {
$userStatsContainer,
FALLBACK_AVATAR_DATA_URI
} from '../../core/state.js';
import { i18n } from '../../core/i18n.js';
import {
saveSettings,
saveChatData,
@@ -19,6 +20,24 @@ import {
} from '../../core/persistence.js';
import { getSafeThumbnailUrl } from '../../utils/avatars.js';
import { buildInventorySummary } from '../generation/promptBuilder.js';
import { isItemLocked, setItemLock } from '../generation/lockManager.js';
import { updateFabWidgets } from '../ui/mobile.js';
import { getStatBarColors } from '../ui/theme.js';
import { getEquipmentBonuses } from '../interaction/equipmentActions.js';
/**
* Extracts the base name (before parentheses) and converts to snake_case for use as JSON key.
* Example: "Conditions (up to 5 traits)" -> "conditions"
* @param {string} name - Field name, possibly with parenthetical description
* @returns {string} snake_case key from the base name only
*/
function toFieldKey(name) {
const baseName = name.replace(/\s*\(.*\)\s*$/, '').trim();
return baseName
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '_')
.replace(/^_+|_+$/g, '');
}
/**
* Builds the user stats text string using custom stat names
@@ -67,6 +86,113 @@ export function buildUserStatsText() {
return text.trim();
}
/**
* Updates lastGeneratedData.userStats and committedTrackerData.userStats
* Maintains JSON format if current data is JSON, otherwise uses text format.
* @private
*/
function updateUserStatsData() {
// Check if current data is in JSON format
const currentData = lastGeneratedData.userStats || committedTrackerData.userStats;
if (currentData) {
const trimmed = currentData.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
// Maintain JSON format
try {
const jsonData = JSON.parse(currentData);
if (jsonData && typeof jsonData === 'object') {
const stats = extensionSettings.userStats;
const config = extensionSettings.trackerConfig?.userStats || {};
const enabledStats = config.customStats?.filter(stat => stat && stat.enabled && stat.name && stat.id) || [];
// Build stats array - include all stats from extensionSettings, not just enabled ones
// This preserves custom stats that AI might have added or that user has disabled
const statsArray = [];
const processedIds = new Set();
// First, add all enabled stats from config (maintains order)
enabledStats.forEach(stat => {
statsArray.push({
id: stat.id,
name: stat.name,
value: stats[stat.id] !== undefined ? stats[stat.id] : 100
});
processedIds.add(stat.id);
});
// Then, add any other numeric stats from extensionSettings that aren't in config
// (these could be custom stats the AI added or disabled stats)
const customFields = config.statusSection?.customFields || [];
const excludeFields = new Set(['mood', ...customFields.map(f => toFieldKey(f)), 'inventory', 'skills', 'level']);
Object.entries(stats).forEach(([key, value]) => {
if (!processedIds.has(key) && !excludeFields.has(key) && typeof value === 'number') {
statsArray.push({
id: key,
name: key.charAt(0).toUpperCase() + key.slice(1),
value: value
});
}
});
jsonData.stats = statsArray;
// Update status - include all custom status fields
jsonData.status = {
mood: stats.mood || '😐'
};
// Add all custom status fields
for (const fieldName of customFields) {
const fieldKey = toFieldKey(fieldName);
jsonData.status[fieldKey] = stats[fieldKey] || 'None';
}
// Update inventory (convert to v3 format)
const convertToV3Items = (itemString) => {
if (!itemString) return [];
const items = itemString.split(',').map(s => s.trim()).filter(s => s);
return items.map(item => {
const qtyMatch = item.match(/^(\\d+)x\\s+(.+)$/);
if (qtyMatch) {
return { name: qtyMatch[2].trim(), quantity: parseInt(qtyMatch[1]) };
}
return { name: item, quantity: 1 };
});
};
jsonData.inventory = {
onPerson: convertToV3Items(stats.inventory?.onPerson),
clothing: convertToV3Items(stats.inventory?.clothing),
stored: stats.inventory?.stored || {},
assets: convertToV3Items(stats.inventory?.assets)
};
// Update quests
jsonData.quests = extensionSettings.quests || { main: '', optional: [] };
// Update skills if present
if (stats.skills) {
jsonData.skills = Array.isArray(stats.skills) ? stats.skills :
stats.skills.split(',').map(s => s.trim()).filter(s => s);
}
const updatedJSON = JSON.stringify(jsonData, null, 2);
lastGeneratedData.userStats = updatedJSON;
committedTrackerData.userStats = updatedJSON;
return;
}
} catch (e) {
console.warn('[RPG Companion] Failed to parse JSON, falling back to text format:', e);
}
}
}
// Fall back to text format
const statsText = buildUserStatsText();
lastGeneratedData.userStats = statsText;
committedTrackerData.userStats = statsText;
}
/**
* Renders the user stats panel with health bars, mood, inventory, and classic stats.
* Includes event listeners for editable fields.
@@ -77,7 +203,36 @@ export function renderUserStats() {
return;
}
// Don't render if no data exists (e.g., after cache clear)
// Check both lastGeneratedData and committedTrackerData
// console.log('[RPG UserStats Render] Checking data:', {
// hasLastGenerated: !!lastGeneratedData.userStats,
// hasCommitted: !!committedTrackerData.userStats,
// lastGeneratedPreview: lastGeneratedData.userStats ? lastGeneratedData.userStats.substring(0, 100) : 'null',
// committedPreview: committedTrackerData.userStats ? committedTrackerData.userStats.substring(0, 100) : 'null'
// });
if (!lastGeneratedData.userStats && !committedTrackerData.userStats) {
// Always render to the #rpg-user-stats container (mobile layout just moves it around in DOM)
$userStatsContainer.html('<div class="rpg-inventory-empty">' + (i18n.getTranslation('userStats.empty') || 'No statuses generated yet') + '</div>');
return;
}
// Use lastGeneratedData if available, otherwise fall back to committed data
if (!lastGeneratedData.userStats && committedTrackerData.userStats) {
lastGeneratedData.userStats = committedTrackerData.userStats;
}
const stats = extensionSettings.userStats;
// console.log('[RPG UserStats Render] Current extensionSettings.userStats:', {
// health: stats.health,
// satiety: stats.satiety,
// energy: stats.energy,
// hygiene: stats.hygiene,
// arousal: stats.arousal,
// mood: stats.mood,
// conditions: stats.conditions
// });
const config = extensionSettings.trackerConfig?.userStats || {
customStats: [
{ id: 'health', name: 'Health', enabled: true },
@@ -113,35 +268,65 @@ export function renderUserStats() {
}
}
// Create gradient from low to high color
const gradient = `linear-gradient(to right, ${extensionSettings.statBarColorLow}, ${extensionSettings.statBarColorHigh})`;
// Create gradient from low to high color with opacity
const colors = getStatBarColors();
const gradient = `linear-gradient(to right, ${colors.low}, ${colors.high})`;
let html = '<div class="rpg-stats-content"><div class="rpg-stats-left">';
// Check if stats bars section is locked
const isStatsLocked = isItemLocked('userStats', 'stats');
const lockIcon = isStatsLocked ? '🔒' : '🔓';
const lockTitle = isStatsLocked ? (i18n.getTranslation('userStats.statsLocked') || 'Stats locked') : (i18n.getTranslation('userStats.statsUnlocked') || 'Stats unlocked');
const lockedClass = isStatsLocked ? ' locked' : '';
let html = '<div class="rpg-stats-content">';
html += '<div class="rpg-stats-left">';
// User info row
const showLevel = extensionSettings.trackerConfig?.userStats?.showLevel !== false;
html += `
<div class="rpg-user-info-row">
<img src="${userPortrait}" alt="${userName}" class="rpg-user-portrait" onerror="this.style.opacity='0.5';this.onerror=null;" />
<span class="rpg-user-name">${userName}</span>
<span style="opacity: 0.5;">|</span>
<span class="rpg-level-label">LVL</span>
<span class="rpg-level-value rpg-editable" contenteditable="true" data-field="level" title="Click to edit level">${extensionSettings.level}</span>
${showLevel ? `<span style="opacity: 0.5;">|</span>
<span class="rpg-level-label">${i18n.getTranslation('userStats.level') || 'Level'}</span>
<span class="rpg-level-value rpg-editable" contenteditable="true" data-field="level" title="${i18n.getTranslation('userStats.clickToEditLevel') || 'Click to edit level'}">${extensionSettings.level}</span>` : ''}
</div>
`;
// Dynamic stats grid - only show enabled stats
const showLockIcons = extensionSettings.showLockIcons ?? true;
if (showLockIcons) {
html += `<span class="rpg-section-lock-icon${lockedClass}" data-tracker="userStats" data-path="stats" title="${lockTitle}">${lockIcon}</span>`;
}
html += '<div class="rpg-stats-grid">';
const enabledStats = config.customStats.filter(stat => stat && stat.enabled && stat.name && stat.id);
const displayMode = config.statsDisplayMode || 'percentage';
for (const stat of enabledStats) {
const value = stats[stat.id] !== undefined ? stats[stat.id] : 100;
const maxValue = stat.maxValue || 100;
// Calculate percentage for bar fill
let percentage;
let displayValue;
if (displayMode === 'number') {
// In number mode, value is already the number (0 to maxValue)
percentage = maxValue > 0 ? (value / maxValue) * 100 : 100;
displayValue = `${value}/${maxValue}`;
} else {
// In percentage mode, value is 0-100
percentage = value;
displayValue = `${value}%`;
}
html += `
<div class="rpg-stat-row">
<span class="rpg-stat-label rpg-editable-stat-name" contenteditable="true" data-field="${stat.id}" title="Click to edit stat name">${stat.name}:</span>
<span class="rpg-stat-label rpg-editable-stat-name" contenteditable="true" data-field="${stat.id}" title="${i18n.getTranslation('userStats.clickToEditStatName') || 'Click to edit stat name'}">${stat.name}:</span>
<div class="rpg-stat-bar" style="background: ${gradient}">
<div class="rpg-stat-fill" style="width: ${100 - value}%"></div>
<div class="rpg-stat-fill" style="width: ${100 - percentage}%"></div>
</div>
<span class="rpg-stat-value rpg-editable-stat" contenteditable="true" data-field="${stat.id}" title="Click to edit">${value}%</span>
<span class="rpg-stat-value rpg-editable-stat" contenteditable="true" data-field="${stat.id}" data-max="${maxValue}" data-mode="${displayMode}" title="${i18n.getTranslation('userStats.clickToEditStatValue') || 'Click to edit stat value'}">${displayValue}</span>
</div>
`;
}
@@ -149,17 +334,33 @@ export function renderUserStats() {
// Status section (conditionally rendered)
if (config.statusSection.enabled) {
const isMoodLocked = isItemLocked('userStats', 'status');
const moodLockIcon = isMoodLocked ? '🔒' : '🔓';
const moodLockTitle = isMoodLocked ? (i18n.getTranslation('userStats.moodLocked') || 'Mood locked') : (i18n.getTranslation('userStats.moodUnlocked') || 'Mood unlocked');
const moodLockedClass = isMoodLocked ? ' locked' : '';
html += '<div class="rpg-mood">';
if (showLockIcons) {
html += `<span class="rpg-section-lock-icon${moodLockedClass}" data-tracker="userStats" data-path="status" title="${moodLockTitle}">${moodLockIcon}</span>`;
}
if (config.statusSection.showMoodEmoji) {
html += `<div class="rpg-mood-emoji rpg-editable" contenteditable="true" data-field="mood" title="Click to edit emoji">${stats.mood}</div>`;
html += `<div class="rpg-mood-emoji rpg-editable" contenteditable="true" data-field="mood" title="${i18n.getTranslation('userStats.clickToEditEmoji') || 'Click to edit emoji'}">${stats.mood}</div>`;
}
// Render custom status fields
if (config.statusSection.customFields && config.statusSection.customFields.length > 0) {
// For now, use first field as "conditions" for backward compatibility
const conditionsValue = stats.conditions || 'None';
html += `<div class="rpg-mood-conditions rpg-editable" contenteditable="true" data-field="conditions" title="Click to edit conditions">${conditionsValue}</div>`;
for (const fieldName of config.statusSection.customFields) {
const fieldKey = toFieldKey(fieldName);
let fieldValue = stats[fieldKey] || 'None';
// Handle array format (from JSON)
if (Array.isArray(fieldValue)) {
fieldValue = fieldValue.join(', ') || 'None';
} else if (typeof fieldValue === 'string') {
// Strip brackets if present (from JSON array format)
fieldValue = fieldValue.replace(/^\[|\]$/g, '').trim();
}
html += `<div class="rpg-mood-conditions rpg-editable" contenteditable="true" data-field="${fieldKey}" title="${i18n.getTranslation('userStats.clickToEdit') || 'Click to edit'} ${fieldName}">${fieldValue}</div>`;
}
}
html += '</div>';
@@ -167,11 +368,26 @@ export function renderUserStats() {
// Skills section (conditionally rendered)
if (config.skillsSection.enabled) {
const skillsValue = stats.skills || 'None';
const isSkillsLocked = isItemLocked('userStats', 'skills');
const skillsLockIcon = isSkillsLocked ? '🔒' : '🔓';
const skillsLockTitle = isSkillsLocked ? (i18n.getTranslation('userStats.skillsLocked') || 'Skills locked') : (i18n.getTranslation('userStats.skillsUnlocked') || 'Skills unlocked');
const skillsLockedClass = isSkillsLocked ? ' locked' : '';
let skillsValue = 'None';
// Handle JSON array format: [{name: "Art"}, {name: "Coding"}]
if (Array.isArray(stats.skills)) {
skillsValue = stats.skills.map(s => s.name || s).join(', ') || 'None';
} else if (stats.skills) {
skillsValue = stats.skills;
}
html += `
<div class="rpg-skills-section">`;
if (showLockIcons) {
html += `
<span class="rpg-section-lock-icon${skillsLockedClass}" data-tracker="userStats" data-path="skills" title="${skillsLockTitle}">${skillsLockIcon}</span>`;
}
html += `
<div class="rpg-skills-section">
<span class="rpg-skills-label">${config.skillsSection.label}:</span>
<div class="rpg-skills-value rpg-editable" contenteditable="true" data-field="skills" title="Click to edit skills">${skillsValue}</div>
<div class="rpg-skills-value rpg-editable" contenteditable="true" data-field="skills" title="${i18n.getTranslation('userStats.clickToEditSkills') || 'Click to edit skills'}">${skillsValue}</div>
</div>
`;
}
@@ -195,6 +411,7 @@ export function renderUserStats() {
const enabledAttributes = rpgAttributes.filter(attr => attr && attr.enabled && attr.name && attr.id);
if (enabledAttributes.length > 0) {
const equipmentBonuses = getEquipmentBonuses();
html += `
<div class="rpg-stats-right">
<div class="rpg-classic-stats">
@@ -203,12 +420,14 @@ export function renderUserStats() {
enabledAttributes.forEach(attr => {
const value = extensionSettings.classicStats[attr.id] !== undefined ? extensionSettings.classicStats[attr.id] : 10;
const bonus = equipmentBonuses[attr.id] || 0;
const bonusHtml = bonus > 0 ? `<span class="rpg-classic-stat-bonus" title="Equipment bonus: +${bonus}"> +${bonus}</span>` : '';
html += `
<div class="rpg-classic-stat" data-stat="${attr.id}">
<span class="rpg-classic-stat-label">${attr.name}</span>
<div class="rpg-classic-stat-buttons">
<button class="rpg-classic-stat-btn rpg-stat-decrease" data-stat="${attr.id}"></button>
<span class="rpg-classic-stat-value">${value}</span>
<span class="rpg-classic-stat-value">${value}${bonusHtml}</span>
<button class="rpg-classic-stat-btn rpg-stat-increase" data-stat="${attr.id}">+</button>
</div>
</div>
@@ -225,68 +444,91 @@ export function renderUserStats() {
html += '</div>'; // Close rpg-stats-content
// console.log('[RPG UserStats Render] Generated HTML length:', html.length);
// console.log('[RPG UserStats Render] HTML preview:', html.substring(0, 300));
// console.log('[RPG UserStats Render] Container exists:', !!$userStatsContainer, '$userStatsContainer length:', $userStatsContainer?.length);
// Always render to the #rpg-user-stats container (mobile layout just moves it around in DOM)
$userStatsContainer.html(html);
// console.log('[RPG UserStats Render] ✓ HTML rendered to #rpg-user-stats container');
// Add event listeners for editable stat values
$('.rpg-editable-stat').on('blur', function() {
$('.rpg-editable-stat').on('blur', function () {
const field = $(this).data('field');
const textValue = $(this).text().replace('%', '').trim();
let value = parseInt(textValue);
const mode = $(this).data('mode');
const maxValue = parseInt($(this).data('max')) || 100;
const textValue = $(this).text().trim();
let value;
if (mode === 'number') {
// In number mode, parse "X/MAX" or just "X"
const parts = textValue.split('/');
value = parseInt(parts[0]);
// Validate and clamp value between 0 and maxValue
if (isNaN(value)) {
value = 0;
}
value = Math.max(0, Math.min(maxValue, value));
} else {
// In percentage mode, parse "X%" or just "X"
value = parseInt(textValue.replace('%', ''));
// Validate and clamp value between 0 and 100
if (isNaN(value)) {
value = 0;
}
value = Math.max(0, Math.min(100, value));
}
// Update the setting
extensionSettings.userStats[field] = value;
// Rebuild userStats text with custom stat names
const statsText = buildUserStatsText();
// Update BOTH lastGeneratedData AND committedTrackerData
// This makes manual edits immediately visible to AI
lastGeneratedData.userStats = statsText;
committedTrackerData.userStats = statsText;
// Update userStats data (maintains JSON or text format)
updateUserStatsData();
saveSettings();
saveChatData();
updateMessageSwipeData();
// Re-render to update the bar
// Re-render to update the bar and FAB widgets
renderUserStats();
updateFabWidgets();
});
// Add event listeners for mood/conditions editing
$('.rpg-mood-emoji.rpg-editable').on('blur', function() {
$('.rpg-mood-emoji.rpg-editable').on('blur', function () {
const value = $(this).text().trim();
extensionSettings.userStats.mood = value || '😐';
// Rebuild userStats text with custom stat names
const statsText = buildUserStatsText();
// Update BOTH lastGeneratedData AND committedTrackerData
// This makes manual edits immediately visible to AI
lastGeneratedData.userStats = statsText;
committedTrackerData.userStats = statsText;
// Update userStats data (maintains JSON or text format)
updateUserStatsData();
saveSettings();
saveChatData();
updateMessageSwipeData();
});
$('.rpg-mood-conditions.rpg-editable').on('blur', function() {
$('.rpg-mood-conditions.rpg-editable').on('blur', function () {
const value = $(this).text().trim();
extensionSettings.userStats.conditions = value || 'None';
const fieldKey = $(this).data('field');
extensionSettings.userStats[fieldKey] = value || 'None';
// Rebuild userStats text with custom stat names
const statsText = buildUserStatsText();
// Update userStats data (maintains JSON or text format)
updateUserStatsData();
// Update BOTH lastGeneratedData AND committedTrackerData
// This makes manual edits immediately visible to AI
lastGeneratedData.userStats = statsText;
committedTrackerData.userStats = statsText;
saveSettings();
saveChatData();
updateMessageSwipeData();
});
// Add event listener for skills editing
$('.rpg-skills-value.rpg-editable').on('blur', function () {
const value = $(this).text().trim();
extensionSettings.userStats.skills = value || 'None';
// Update userStats data (maintains JSON or text format)
updateUserStatsData();
saveSettings();
saveChatData();
@@ -294,7 +536,7 @@ export function renderUserStats() {
});
// Add event listeners for stat name editing
$('.rpg-editable-stat-name').on('blur', function() {
$('.rpg-editable-stat-name').on('blur', function () {
const field = $(this).data('field');
const value = $(this).text().trim().replace(':', '');
@@ -318,7 +560,7 @@ export function renderUserStats() {
});
// Add event listener for level editing
$('.rpg-level-value.rpg-editable').on('blur', function() {
$('.rpg-level-value.rpg-editable').on('blur', function () {
let value = parseInt($(this).text().trim());
if (isNaN(value) || value < 1) {
value = 1;
@@ -336,10 +578,35 @@ export function renderUserStats() {
});
// Prevent line breaks in level field
$('.rpg-level-value.rpg-editable').on('keydown', function(e) {
$('.rpg-level-value.rpg-editable').on('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
$(this).blur();
}
});
// Add event listener for section lock icon clicks (support both click and touch)
$('.rpg-section-lock-icon').on('click touchend', function (e) {
e.preventDefault();
e.stopPropagation();
const $icon = $(this);
const trackerType = $icon.data('tracker');
const itemPath = $icon.data('path');
const currentlyLocked = isItemLocked(trackerType, itemPath);
// Toggle lock state
setItemLock(trackerType, itemPath, !currentlyLocked);
// Update icon
const newIcon = !currentlyLocked ? '🔒' : '🔓';
const newTitle = !currentlyLocked ? (i18n.getTranslation('infoBox.locked') || 'Locked') : (i18n.getTranslation('infoBox.unlocked') || 'Unlocked');
$icon.text(newIcon);
$icon.attr('title', newTitle);
// Toggle 'locked' class for persistent visibility
$icon.toggleClass('locked', !currentlyLocked);
// Save settings
saveSettings();
});
}
@@ -0,0 +1,177 @@
import { extensionSettings } from '../../core/state.js';
import { i18n } from '../../core/i18n.js';
import { getThoughtBasedExpressionPortraitForCharacter } from '../../utils/thoughtBasedExpressionPortraits.js';
import { getSafeImageSrc } from '../../utils/imageUrls.js';
import {
getPresentCharactersTrackerData,
parsePresentCharacters,
resolvePresentCharacterPortrait
} from '../../utils/presentCharacters.js';
const PANEL_ID = 'rpg-alt-present-characters';
function ensureAlternatePresentCharactersPanel() {
let $panel = $(`#${PANEL_ID}`);
if ($panel.length) {
return $panel;
}
$panel = $(`<div id="${PANEL_ID}" class="rpg-alt-present-characters" style="display:none;"></div>`);
const $sendForm = $('#send_form');
const $sheld = $('#sheld');
const $chat = $sheld.find('#chat');
if ($sendForm.length) {
$sendForm.before($panel);
} else if ($chat.length) {
$chat.after($panel);
} else if ($sheld.length) {
$sheld.append($panel);
} else {
$('body').append($panel);
}
return $panel;
}
function hexToRgba(hex, opacity = 100) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const a = opacity / 100;
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
function handlePortraitLoadError() {
this.style.opacity = '0.5';
$(this).off('error', handlePortraitLoadError);
}
function createAlternatePresentCharacterCard(character) {
const rawPortrait = (extensionSettings.enableThoughtBasedExpressions
? getThoughtBasedExpressionPortraitForCharacter(character.name)
: null) || resolvePresentCharacterPortrait(character.name);
const portrait = getSafeImageSrc(rawPortrait);
const name = String(character.name || '');
const $card = $('<div class="rpg-alt-present-character"></div>')
.attr('data-character-name', name)
.attr('title', name);
const $portrait = $('<div class="rpg-alt-present-character__portrait"></div>');
const $image = $('<img />')
.attr({
alt: name,
loading: 'lazy'
})
.on('error', handlePortraitLoadError);
if (portrait) {
$image.attr('src', portrait);
}
const $meta = $('<div class="rpg-alt-present-character__meta"></div>');
const $name = $('<div class="rpg-alt-present-character__name"></div>').text(name);
$portrait.append($image);
$meta.append($name);
$card.append($portrait, $meta);
return $card;
}
export function removeAlternatePresentCharactersPanel() {
$(`#${PANEL_ID}`).remove();
}
export function syncAlternatePresentCharactersTheme() {
const $panel = $(`#${PANEL_ID}`);
if (!$panel.length) {
return;
}
const theme = extensionSettings.theme || 'default';
$panel.css({
'--rpg-bg': '',
'--rpg-accent': '',
'--rpg-text': '',
'--rpg-highlight': '',
'--rpg-border': '',
'--rpg-shadow': ''
});
if (theme === 'default') {
$panel.removeAttr('data-theme');
return;
}
$panel.attr('data-theme', theme);
if (theme === 'custom') {
const colors = extensionSettings.customColors || {};
const bgColor = hexToRgba(colors.bg || '#1a1a2e', colors.bgOpacity ?? 100);
const accentColor = hexToRgba(colors.accent || '#16213e', colors.accentOpacity ?? 100);
const textColor = hexToRgba(colors.text || '#eaeaea', colors.textOpacity ?? 100);
const highlightColor = hexToRgba(colors.highlight || '#e94560', colors.highlightOpacity ?? 100);
const shadowColor = hexToRgba(colors.highlight || '#e94560', (colors.highlightOpacity ?? 100) * 0.5);
$panel.css({
'--rpg-bg': bgColor,
'--rpg-accent': accentColor,
'--rpg-text': textColor,
'--rpg-highlight': highlightColor,
'--rpg-border': highlightColor,
'--rpg-shadow': shadowColor
});
}
}
export function renderAlternatePresentCharacters({ useCommittedFallback = true } = {}) {
if (!extensionSettings.enabled || !extensionSettings.showAlternatePresentCharactersPanel) {
removeAlternatePresentCharactersPanel();
return;
}
const characterThoughtsData = getPresentCharactersTrackerData({ useCommittedFallback });
if (!characterThoughtsData) {
const $panel = ensureAlternatePresentCharactersPanel();
$panel.empty().hide();
return;
}
const presentCharacters = parsePresentCharacters(characterThoughtsData);
if (presentCharacters.length === 0) {
const $panel = ensureAlternatePresentCharactersPanel();
$panel.empty().hide();
return;
}
const title = i18n.getTranslation('template.trackerEditorModal.tabs.presentCharacters') || 'Present Characters';
const $panel = ensureAlternatePresentCharactersPanel();
const $header = $('<div class="rpg-alt-present-characters__header"></div>');
const $headerTitle = $('<div class="rpg-alt-present-characters__title"></div>');
const $scroll = $('<div class="rpg-alt-present-characters__scroll"></div>');
const $track = $('<div class="rpg-alt-present-characters__track"></div>');
$headerTitle.append(
$('<i class="fa-solid fa-users" aria-hidden="true"></i>'),
$('<span></span>').text(title)
);
$header.append(
$headerTitle,
$('<div class="rpg-alt-present-characters__count"></div>').text(String(presentCharacters.length))
);
for (const character of presentCharacters) {
$track.append(createAlternatePresentCharacterCard(character));
}
$scroll.append($track);
$panel.empty().append($header, $scroll).show();
syncAlternatePresentCharactersTheme();
}
+369
View File
@@ -0,0 +1,369 @@
/**
* Chapter Checkpoint UI Module
* Adds UI elements for chapter checkpoint functionality
*/
import { getContext } from '../../../../../../extensions.js';
import { i18n } from '../../core/i18n.js';
import {
setChapterCheckpoint,
clearChapterCheckpoint,
isCheckpointMessage
} from '../features/chapterCheckpoint.js';
/**
* Adds the chapter checkpoint button to a message's extra menu
* @param {number} messageId - The message index
* @param {HTMLElement} menu - The message menu element
* @param {boolean} isExpanded - Whether this is for expanded message actions
*/
export function addCheckpointButtonToMessage(messageId, menu, isExpanded = false) {
if (!menu) return;
const isCheckpoint = isCheckpointMessage(messageId);
// Create the menu item
const menuItem = document.createElement('div');
// Use different classes for expanded vs dropdown menu
if (isExpanded) {
menuItem.className = 'mes_button';
menuItem.setAttribute('tabindex', '0');
} else {
menuItem.className = 'extraMesButtonsHint list-group-item flex-container flexGap5';
}
const translationKey = isCheckpoint ? 'checkpoint.clearChapterStart' : 'checkpoint.setChapterStart';
menuItem.setAttribute('data-i18n', translationKey);
menuItem.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.';
// Icon only (no text label)
const icon = document.createElement('i');
icon.className = isCheckpoint ? 'fa-solid fa-bookmark' : 'fa-regular fa-bookmark';
icon.style.color = isCheckpoint ? '#4a9eff' : '';
menuItem.appendChild(icon);
// Click handler
menuItem.addEventListener('click', (e) => {
e.stopPropagation();
const wasCheckpoint = isCheckpointMessage(messageId);
if (wasCheckpoint) {
clearChapterCheckpoint();
} else {
setChapterCheckpoint(messageId);
}
// Update this button immediately
const newIsCheckpoint = isCheckpointMessage(messageId);
icon.className = newIsCheckpoint ? 'fa-solid fa-bookmark' : 'fa-regular fa-bookmark';
icon.style.color = newIsCheckpoint ? '#4a9eff' : '';
menuItem.title = newIsCheckpoint
? 'Clear Chapter Start'
: 'Set Chapter Start: When bookmarked, this message will count as the first message in the chat history, skipping earlier ones';
const newTranslationKey = newIsCheckpoint ? 'checkpoint.clearChapterStart' : 'checkpoint.setChapterStart';
menuItem.setAttribute('data-i18n', newTranslationKey);
// Update indicators in all messages
updateAllCheckpointIndicators();
});
return menuItem;
}
/**
* Adds visual indicators to messages that are checkpoints
* @param {number} messageId - The message index
* @param {HTMLElement} messageBlock - The message DOM element
*/
export function addCheckpointIndicator(messageId, messageBlock) {
if (!messageBlock) return;
const isCheckpoint = isCheckpointMessage(messageId);
// Remove existing indicator if present
const existingIndicator = messageBlock.querySelector('.rpg-checkpoint-indicator');
if (existingIndicator) {
existingIndicator.remove();
}
if (!isCheckpoint) return;
// Add checkpoint indicator
const indicator = document.createElement('div');
indicator.className = 'rpg-checkpoint-indicator';
const indicatorText = i18n.getTranslation('checkpoint.indicator') || 'Chapter Start';
const tooltipText = i18n.getTranslation('checkpoint.tooltip') || 'Messages before this point are excluded from context';
indicator.innerHTML = `
<i class="fa-solid fa-bookmark"></i>
<span>${indicatorText}</span>
`;
indicator.title = tooltipText;
// Insert at the beginning of the message
const mesText = messageBlock.querySelector('.mes_text');
if (mesText && mesText.parentNode) {
mesText.parentNode.insertBefore(indicator, mesText);
}
}
/**
* Updates checkpoint indicators for all messages
*/
export function updateAllCheckpointIndicators() {
const context = getContext();
const chat = context.chat;
if (!chat) return;
// First, remove ALL checkpoint buttons from everywhere
document.querySelectorAll('.rpg-checkpoint-button, .rpg-checkpoint-button-expanded').forEach(btn => btn.remove());
// Update all message blocks
const messageBlocks = document.querySelectorAll('.mes');
messageBlocks.forEach((block) => {
// Get the actual message ID from the mesid attribute
const messageId = Number(block.getAttribute('mesid'));
if (isNaN(messageId)) return;
addCheckpointIndicator(messageId, block);
// Re-add buttons based on current mode
processExpandedButton(block);
const dropdownMenu = block.querySelector('.extraMesButtons');
if (dropdownMenu) {
processExtraMesButtons(dropdownMenu);
}
});
}
/**
* Removes all checkpoint UI elements
*/
export function cleanupCheckpointUI() {
// Remove all checkpoint buttons
document.querySelectorAll('.rpg-checkpoint-button, .rpg-checkpoint-button-expanded').forEach(btn => btn.remove());
// Remove all checkpoint indicators (banner)
document.querySelectorAll('.rpg-checkpoint-indicator').forEach(indicator => indicator.remove());
}
/**
* Initializes the chapter checkpoint UI
*/
export function initChapterCheckpointUI() {
// Listen for checkpoint changes
document.addEventListener('rpg-companion-checkpoint-changed', () => {
updateAllCheckpointIndicators();
});
// Listen for expandMessageActions class changes on body
const bodyObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
// The expandMessageActions class was toggled, refresh all buttons
updateAllCheckpointIndicators();
}
});
});
bodyObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] });
// Listen for chat changes to update indicators
const context = getContext();
if (context && context.eventSource) {
// Update checkpoint indicators when messages are rendered
const observer = new MutationObserver((mutations) => {
let shouldUpdate = false;
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE &&
node.classList && node.classList.contains('mes')) {
shouldUpdate = true;
}
});
});
if (shouldUpdate) {
// Debounce updates to avoid excessive re-rendering
clearTimeout(window.rpgCheckpointUpdateTimeout);
window.rpgCheckpointUpdateTimeout = setTimeout(() => {
updateAllCheckpointIndicators();
}, 100);
}
});
const chatContainer = document.getElementById('chat');
if (chatContainer) {
observer.observe(chatContainer, {
childList: true,
subtree: false
});
}
}
// Update indicators on initialization
updateAllCheckpointIndicators();
}
/**
* Injects checkpoint button into message menus
* This should be called when SillyTavern renders message menus
*/
export function injectCheckpointButton() {
// Observer for dropdown menus and message blocks
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
// Check for added nodes
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check if extraMesButtons container was added (dropdown menu)
if (node.classList && node.classList.contains('extraMesButtons')) {
processExtraMesButtons(node);
}
// Check if message block was added (for expanded buttons)
if (node.classList && node.classList.contains('mes')) {
processExpandedButton(node);
}
// Also check if any exist within added subtree
if (node.querySelector) {
const extraButtons = node.querySelectorAll('.extraMesButtons');
extraButtons.forEach(processExtraMesButtons);
const messageBlocks = node.querySelectorAll('.mes');
messageBlocks.forEach(processExpandedButton);
}
}
});
// Check if nodes were added TO an extraMesButtons container
if (mutation.target && mutation.target.classList &&
mutation.target.classList.contains('extraMesButtons')) {
processExtraMesButtons(mutation.target);
}
});
});
// Observe the chat container
const chatContainer = document.getElementById('chat');
if (chatContainer) {
observer.observe(chatContainer, {
childList: true,
subtree: true
});
// Process any existing dropdown menus and messages on initialization
// Use setTimeout to ensure styles are computed
setTimeout(() => {
const existingDropdownMenus = chatContainer.querySelectorAll('.extraMesButtons');
existingDropdownMenus.forEach(processExtraMesButtons);
const existingMessages = chatContainer.querySelectorAll('.mes');
existingMessages.forEach(processExpandedButton);
}, 100);
}
}
/**
* Process an extraMesButtons container to add checkpoint button (dropdown menu)
* @param {HTMLElement} menu - The extraMesButtons container
*/
function processExtraMesButtons(menu) {
if (!menu) return;
// Find the message block
const messageBlock = menu.closest('.mes');
if (!messageBlock) return;
// Get the message ID from the mesid attribute (SillyTavern's standard way)
const messageId = Number(messageBlock.getAttribute('mesid'));
if (isNaN(messageId)) return;
// Check if expanded mode is active - if so, skip dropdown
if (document.body.classList.contains('expandMessageActions')) {
return; // Expanded mode is ON, button will be added to mes_buttons instead
}
// Check if button already exists in this container
if (menu.querySelector('.rpg-checkpoint-button')) return;
// Add checkpoint button to dropdown menu
const checkpointBtn = addCheckpointButtonToMessage(messageId, menu, false);
if (checkpointBtn) {
checkpointBtn.classList.add('rpg-checkpoint-button');
menu.appendChild(checkpointBtn);
}
}
/**
* Process a message block to add expanded checkpoint button
* @param {HTMLElement} messageBlock - The message block element
*/
function processExpandedButton(messageBlock) {
if (!messageBlock) return;
const mesButtons = messageBlock.querySelector('.mes_buttons');
if (!mesButtons) return;
// Only add if expanded mode is ON (check body class)
if (!document.body.classList.contains('expandMessageActions')) {
return; // Expanded mode is OFF, button will be in dropdown instead
}
const messageId = Number(messageBlock.getAttribute('mesid'));
if (isNaN(messageId)) return;
// Check if button already exists in this container
if (mesButtons.querySelector('.rpg-checkpoint-button-expanded')) return;
// Add checkpoint button as separate mes_button
const checkpointBtn = addCheckpointButtonToMessage(messageId, mesButtons, true);
if (checkpointBtn) {
checkpointBtn.classList.add('rpg-checkpoint-button-expanded');
// Insert before the edit button if it exists, otherwise append
const editButton = mesButtons.querySelector('.mes_edit');
if (editButton) {
mesButtons.insertBefore(checkpointBtn, editButton);
} else {
mesButtons.appendChild(checkpointBtn);
}
}
}
/**
* 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);
}
-220
View File
@@ -1,220 +0,0 @@
/**
* Debug UI Module
* Provides mobile-friendly debug log viewer for troubleshooting parsing issues
*/
import { extensionSettings, getDebugLogs, clearDebugLogs } from '../../core/state.js';
/**
* Creates and injects the debug panel into the page
* Note: Debug toggle button is created in index.js, not here
*/
export function createDebugPanel() {
// Remove existing debug panel if any
$('#rpg-debug-panel').remove();
// Create debug panel HTML
const debugPanelHtml = `
<div id="rpg-debug-panel" class="rpg-debug-panel">
<div class="rpg-debug-header">
<h3>🔍 Debug Logs</h3>
<div class="rpg-debug-actions">
<button id="rpg-debug-copy" title="Copy logs to clipboard">
<i class="fa-solid fa-copy"></i>
</button>
<button id="rpg-debug-clear" title="Clear logs">
<i class="fa-solid fa-trash"></i>
</button>
<button id="rpg-debug-close" title="Close debug panel">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
<div id="rpg-debug-logs" class="rpg-debug-logs"></div>
</div>
`;
// Append to body
$('body').append(debugPanelHtml);
// Set up event handlers
setupDebugEventHandlers();
// Initial log render
renderDebugLogs();
}
/**
* Closes the debug panel with proper animation (mobile or desktop)
*/
function closeDebugPanel() {
const $panel = $('#rpg-debug-panel');
const isMobile = window.innerWidth <= 1000;
if (isMobile) {
// Mobile: animate slide-out to right
$panel.removeClass('rpg-mobile-open').addClass('rpg-mobile-closing');
// Wait for animation to complete before hiding
$panel.one('animationend', function() {
$panel.removeClass('rpg-mobile-closing');
$('.rpg-mobile-overlay').remove();
});
} else {
// Desktop: simple slide-down
$panel.removeClass('rpg-debug-open');
}
}
/**
* Sets up event handlers for debug panel using event delegation for mobile compatibility
*/
function setupDebugEventHandlers() {
// Use event delegation for better mobile compatibility and reliability with dynamic elements
// Remove any existing handlers first to prevent duplicates
$(document).off('click.rpgDebug');
// Toggle button
$(document).on('click.rpgDebug', '#rpg-debug-toggle', function() {
const $debugToggle = $(this);
// Skip if we just finished dragging
if ($debugToggle.data('just-dragged')) {
console.log('[RPG Debug] Click blocked - just finished dragging');
return;
}
const $panel = $('#rpg-debug-panel');
const isMobile = window.innerWidth <= 1000;
if (isMobile) {
// Mobile: use rpg-mobile-open class with slide-from-right animation
const isOpen = $panel.hasClass('rpg-mobile-open');
if (isOpen) {
// Close with animation
closeDebugPanel();
} else {
// Open with animation
$panel.addClass('rpg-mobile-open');
renderDebugLogs();
// Create overlay for mobile
const $overlay = $('<div class="rpg-mobile-overlay"></div>');
$('body').append($overlay);
// Close when clicking overlay
$overlay.on('click', function() {
closeDebugPanel();
});
}
} else {
// Desktop: use rpg-debug-open class with slide-from-bottom animation
$panel.toggleClass('rpg-debug-open');
renderDebugLogs();
}
});
// Close button
$(document).on('click.rpgDebug', '#rpg-debug-close', function(e) {
e.preventDefault();
e.stopPropagation();
closeDebugPanel();
});
// Copy button
$(document).on('click.rpgDebug', '#rpg-debug-copy', function() {
const logs = getDebugLogs();
const logsText = logs.map(log => {
let text = `[${log.timestamp}] ${log.message}`;
if (log.data) {
text += `\n${log.data}`;
}
return text;
}).join('\n\n');
navigator.clipboard.writeText(logsText).then(() => {
// Show feedback
const $btn = $(this);
const $icon = $btn.find('i');
$icon.removeClass('fa-copy').addClass('fa-check');
setTimeout(() => {
$icon.removeClass('fa-check').addClass('fa-copy');
}, 1500);
}).catch(err => {
console.error('Failed to copy logs:', err);
alert('Failed to copy logs. Please use browser console instead.');
});
});
// Clear button
$(document).on('click.rpgDebug', '#rpg-debug-clear', function() {
if (confirm('Clear all debug logs?')) {
clearDebugLogs();
renderDebugLogs();
}
});
}
/**
* Renders debug logs to the panel
*/
function renderDebugLogs() {
const logs = getDebugLogs();
const $logsContainer = $('#rpg-debug-logs');
if (logs.length === 0) {
$logsContainer.html('<div class="rpg-debug-empty">No logs yet. Logs will appear when parser runs.</div>');
return;
}
// Build logs HTML
const logsHtml = logs.map(log => {
let html = `<div class="rpg-debug-entry">`;
html += `<span class="rpg-debug-time">[${log.timestamp}]</span> `;
html += `<span class="rpg-debug-message">${escapeHtml(log.message)}</span>`;
if (log.data) {
html += `<pre class="rpg-debug-data">${escapeHtml(log.data)}</pre>`;
}
html += `</div>`;
return html;
}).join('');
$logsContainer.html(logsHtml);
// Auto-scroll to bottom
$logsContainer[0].scrollTop = $logsContainer[0].scrollHeight;
}
/**
* Escapes HTML to prevent XSS
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Shows or hides debug UI based on debug mode setting
* Note: Debug toggle button always exists in DOM (created in index.js)
*/
export function updateDebugUIVisibility() {
const $debugToggle = $('#rpg-debug-toggle');
if (extensionSettings.debugMode) {
// Show debug toggle button
$debugToggle.css('display', 'flex');
// Create debug panel if it doesn't exist
if ($('#rpg-debug-panel').length === 0) {
createDebugPanel();
}
} else {
// Hide debug toggle button
$debugToggle.css('display', 'none');
// Remove debug panel
$('#rpg-debug-panel').remove();
}
}
+348 -24
View File
@@ -1,8 +1,278 @@
/**
* Desktop UI Module
* Handles desktop-specific UI functionality: tab navigation
* Handles desktop-specific UI functionality: tab navigation and strip widgets
*/
import { i18n } from '../../core/i18n.js';
import { extensionSettings, lastGeneratedData, committedTrackerData } from '../../core/state.js';
import { hexToRgba } from './theme.js';
/**
* Helper to parse time string and calculate clock hand angles
*/
function parseTimeForClock(timeStr) {
const timeMatch = timeStr.match(/(\d+):(\d+)/);
if (timeMatch) {
const hours = parseInt(timeMatch[1]);
const minutes = parseInt(timeMatch[2]);
const hourAngle = (hours % 12) * 30 + minutes * 0.5; // 30° per hour + 0.5° per minute
const minuteAngle = minutes * 6; // 6° per minute
return { hourAngle, minuteAngle };
}
return { hourAngle: 0, minuteAngle: 0 };
}
/**
* Updates the desktop strip widgets display based on current tracker data and settings.
* Strip widgets are shown vertically in the collapsed panel strip.
*/
export function updateStripWidgets() {
const $panel = $('#rpg-companion-panel');
const $container = $('#rpg-strip-widget-container');
if ($panel.length === 0 || $container.length === 0) return;
// Check if strip widgets are enabled
const widgetSettings = extensionSettings.desktopStripWidgets;
if (!widgetSettings || !widgetSettings.enabled) {
$panel.removeClass('rpg-strip-widgets-enabled');
$container.find('.rpg-strip-widget').removeClass('rpg-strip-widget-visible');
return;
}
// Add enabled class to panel for CSS styling (wider collapsed width)
$panel.addClass('rpg-strip-widgets-enabled');
// Get tracker data - use imported state directly
const infoBox = lastGeneratedData?.infoBox || committedTrackerData?.infoBox;
// Parse infoBox if it's a string
let infoData = null;
if (infoBox) {
try {
infoData = typeof infoBox === 'string' ? JSON.parse(infoBox) : infoBox;
} catch (e) {
console.warn('[RPG Strip Widgets] Failed to parse infoBox:', e);
}
}
// Weather Icon Widget (with description)
const $weatherWidget = $container.find('.rpg-strip-widget-weather');
if (widgetSettings.weatherIcon?.enabled && infoData?.weather?.emoji) {
$weatherWidget.find('.rpg-strip-widget-icon').text(infoData.weather.emoji);
// Show weather description truncated
const forecast = infoData.weather.forecast || '';
const displayForecast = forecast.length > 12 ? forecast.substring(0, 10) + '…' : forecast;
$weatherWidget.find('.rpg-strip-widget-desc').text(displayForecast);
$weatherWidget.attr('title', forecast || 'Weather');
$weatherWidget.addClass('rpg-strip-widget-visible');
} else {
$weatherWidget.removeClass('rpg-strip-widget-visible');
}
// Clock Widget with animated face
const $clockWidget = $container.find('.rpg-strip-widget-clock');
if (widgetSettings.clock?.enabled && infoData?.time) {
const timeStr = infoData.time.end || infoData.time.value || infoData.time.start || '';
if (timeStr) {
// Update clock hands
const { hourAngle, minuteAngle } = parseTimeForClock(timeStr);
$clockWidget.find('.rpg-strip-clock-hour').css('transform', `rotate(${hourAngle}deg)`);
$clockWidget.find('.rpg-strip-clock-minute').css('transform', `rotate(${minuteAngle}deg)`);
$clockWidget.find('.rpg-strip-widget-value').text(timeStr);
$clockWidget.attr('title', `Time: ${timeStr}`);
$clockWidget.addClass('rpg-strip-widget-visible');
} else {
$clockWidget.removeClass('rpg-strip-widget-visible');
}
} else {
$clockWidget.removeClass('rpg-strip-widget-visible');
}
// Date Widget
const $dateWidget = $container.find('.rpg-strip-widget-date');
if (widgetSettings.date?.enabled && infoData?.date?.value) {
const dateVal = infoData.date.value;
// Truncate long dates for display
const displayDate = dateVal.length > 20 ? dateVal.substring(0, 18) + '…' : dateVal;
$dateWidget.find('.rpg-strip-widget-value').text(displayDate);
$dateWidget.attr('title', dateVal);
$dateWidget.addClass('rpg-strip-widget-visible');
} else {
$dateWidget.removeClass('rpg-strip-widget-visible');
}
// Location Widget
const $locationWidget = $container.find('.rpg-strip-widget-location');
if (widgetSettings.location?.enabled && infoData?.location?.value) {
const loc = infoData.location.value;
// Truncate long locations for display
const displayLoc = loc.length > 15 ? loc.substring(0, 13) + '…' : loc;
$locationWidget.find('.rpg-strip-widget-value').text(displayLoc);
$locationWidget.attr('title', loc);
$locationWidget.addClass('rpg-strip-widget-visible');
} else {
$locationWidget.removeClass('rpg-strip-widget-visible');
}
// Stats Widget - get from lastGeneratedData or committedTrackerData first, fallback to extensionSettings
const $statsWidget = $container.find('.rpg-strip-widget-stats');
if (widgetSettings.stats?.enabled) {
let allStats = [];
// Try to get stats from tracker data first (most current)
const userStatsData = lastGeneratedData?.userStats || committedTrackerData?.userStats;
if (userStatsData) {
try {
const parsedStats = typeof userStatsData === 'string' ? JSON.parse(userStatsData) : userStatsData;
if (parsedStats?.stats) {
allStats = parsedStats.stats;
}
} catch (e) {
console.warn('[RPG Strip Widgets] Failed to parse tracker userStats:', e);
}
}
// Fallback to extensionSettings.userStats
if (allStats.length === 0 && extensionSettings.userStats) {
try {
const userStatsJson = extensionSettings.userStats;
const parsedUserStats = typeof userStatsJson === 'string' ? JSON.parse(userStatsJson) : userStatsJson;
if (parsedUserStats?.stats) {
allStats = parsedUserStats.stats;
}
} catch (e) {
console.warn('[RPG Strip Widgets] Failed to parse extensionSettings.userStats:', e);
}
}
if (allStats.length > 0) {
// Get enabled stats from trackerConfig
const configuredStats = extensionSettings.trackerConfig?.userStats?.customStats || [];
const enabledStatMap = new Map();
configuredStats.forEach(s => {
if (s.enabled !== false) {
enabledStatMap.set(s.id?.toLowerCase(), true);
enabledStatMap.set(s.name?.toLowerCase(), true);
}
});
const $statsList = $statsWidget.find('.rpg-strip-stats-list');
$statsList.empty();
allStats.forEach(stat => {
// Filter by config if available - but if no config, show all
if (configuredStats.length > 0) {
const statId = stat.id?.toLowerCase();
const statName = stat.name?.toLowerCase();
if (!enabledStatMap.has(statId) && !enabledStatMap.has(statName)) return;
}
const value = typeof stat.value === 'number' ? stat.value : parseInt(stat.value) || 0;
const color = getStatColor(value);
const abbr = stat.name.substring(0, 3).toUpperCase();
const $item = $(`<div class="rpg-strip-stat-item" title="${stat.name}: ${value}">
<span class="rpg-strip-stat-name">${abbr}</span>
<span class="rpg-strip-stat-value" style="color: ${color};">${value}</span>
</div>`);
$statsList.append($item);
});
if ($statsList.children().length > 0) {
$statsWidget.addClass('rpg-strip-widget-visible');
} else {
$statsWidget.removeClass('rpg-strip-widget-visible');
}
} else {
$statsWidget.removeClass('rpg-strip-widget-visible');
}
} else {
$statsWidget.removeClass('rpg-strip-widget-visible');
}
// Attributes Widget
const $attrsWidget = $container.find('.rpg-strip-widget-attributes');
if (widgetSettings.attributes?.enabled) {
const showRPGAttributes = extensionSettings.trackerConfig?.userStats?.showRPGAttributes !== false;
if (showRPGAttributes && extensionSettings.classicStats) {
// Get enabled attributes from trackerConfig
const configuredAttrs = extensionSettings.trackerConfig?.userStats?.rpgAttributes || [];
const enabledAttrIds = configuredAttrs.filter(a => a.enabled !== false).map(a => a.id);
const attrs = extensionSettings.classicStats;
const $attrsGrid = $attrsWidget.find('.rpg-strip-attributes-grid');
$attrsGrid.empty();
Object.entries(attrs).forEach(([key, value]) => {
// Filter by config if available
if (enabledAttrIds.length > 0 && !enabledAttrIds.includes(key.toLowerCase())) {
return;
}
const $item = $(`<div class="rpg-strip-attr-item" title="${key.toUpperCase()}: ${value}">
<span class="rpg-strip-attr-name">${key.toUpperCase()}</span>
<span class="rpg-strip-attr-value">${value}</span>
</div>`);
$attrsGrid.append($item);
});
if ($attrsGrid.children().length > 0) {
$attrsWidget.addClass('rpg-strip-widget-visible');
} else {
$attrsWidget.removeClass('rpg-strip-widget-visible');
}
} else {
$attrsWidget.removeClass('rpg-strip-widget-visible');
}
} else {
$attrsWidget.removeClass('rpg-strip-widget-visible');
}
}
/**
* Gets a color interpolated between low and high based on stat value (0-100).
* @param {number} value - The stat value (0-100)
* @returns {string} CSS color value
*/
function getStatColor(value) {
const lowColor = extensionSettings.statBarColorLow || '#cc3333';
const lowOpacity = extensionSettings.statBarColorLowOpacity ?? 100;
const highColor = extensionSettings.statBarColorHigh || '#33cc66';
const highOpacity = extensionSettings.statBarColorHighOpacity ?? 100;
// Simple linear interpolation between low and high colors
const percent = Math.min(100, Math.max(0, value)) / 100;
// Parse colors
const lowRGB = hexToRgb(lowColor);
const highRGB = hexToRgb(highColor);
if (!lowRGB || !highRGB) return value > 50 ? hexToRgba(highColor, highOpacity) : hexToRgba(lowColor, lowOpacity);
const r = Math.round(lowRGB.r + (highRGB.r - lowRGB.r) * percent);
const g = Math.round(lowRGB.g + (highRGB.g - lowRGB.g) * percent);
const b = Math.round(lowRGB.b + (highRGB.b - lowRGB.b) * percent);
const a = (lowOpacity + (highOpacity - lowOpacity) * percent) / 100;
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
/**
* Converts a hex color to RGB object.
* @param {string} hex - Hex color string (e.g., "#cc3333")
* @returns {{r: number, g: number, b: number}|null}
*/
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
/**
* Sets up desktop tab navigation for organizing content.
* Only runs on desktop viewports (>1000px).
@@ -22,56 +292,97 @@ export function setupDesktopTabs() {
const $infoBox = $('#rpg-info-box');
const $thoughts = $('#rpg-thoughts');
const $inventory = $('#rpg-inventory');
const $equipment = $('#rpg-equipment');
const $quests = $('#rpg-quests');
// If no sections exist, nothing to organize
if ($userStats.length === 0 && $infoBox.length === 0 && $thoughts.length === 0 && $inventory.length === 0 && $quests.length === 0) {
if ($userStats.length === 0 && $infoBox.length === 0 && $thoughts.length === 0 && $inventory.length === 0 && $equipment.length === 0 && $quests.length === 0) {
return;
}
// Create tab navigation
const $tabNav = $(`
<div class="rpg-tabs-nav">
// Build tab navigation dynamically based on enabled settings
const tabButtons = [];
const hasInventory = $inventory.length > 0 && extensionSettings.showInventory;
const hasEquipment = $equipment.length > 0 && extensionSettings.showEquipment;
const hasQuests = $quests.length > 0 && extensionSettings.showQuests;
// Status tab (always present if any status content exists)
tabButtons.push(`
<button class="rpg-tab-btn active" data-tab="status">
<i class="fa-solid fa-chart-simple"></i>
<span>Status</span>
<span data-i18n-key="global.status">Status</span>
</button>
`);
// Inventory tab (only if enabled in settings)
if (hasInventory) {
tabButtons.push(`
<button class="rpg-tab-btn" data-tab="inventory">
<i class="fa-solid fa-box"></i>
<span>Inventory</span>
<span data-i18n-key="global.inventory">Inventory</span>
</button>
`);
}
// Equipment tab (only if enabled in settings)
if (hasEquipment) {
tabButtons.push(`
<button class="rpg-tab-btn" data-tab="equipment">
<i class="fa-solid fa-shield-halved"></i>
<span data-i18n-key="equipment.title">Equipment</span>
</button>
`);
}
// Quests tab (only if enabled in settings)
if (hasQuests) {
tabButtons.push(`
<button class="rpg-tab-btn" data-tab="quests">
<i class="fa-solid fa-scroll"></i>
<span>Quests</span>
<span data-i18n-key="global.quests">Quests</span>
</button>
</div>
`);
}
const $tabNav = $(`<div class="rpg-tabs-nav">${tabButtons.join('')}</div>`);
// Create tab content containers
const $statusTab = $('<div class="rpg-tab-content active" data-tab-content="status"></div>');
const $inventoryTab = $('<div class="rpg-tab-content" data-tab-content="inventory"></div>');
const $equipmentTab = $('<div class="rpg-tab-content" data-tab-content="equipment"></div>');
const $questsTab = $('<div class="rpg-tab-content" data-tab-content="quests"></div>');
// Move sections into their respective tabs (detach to preserve event handlers)
if ($userStats.length > 0) {
$statusTab.append($userStats.detach());
$userStats.show();
if (extensionSettings.showUserStats) $userStats.show();
}
if ($infoBox.length > 0) {
$statusTab.append($infoBox.detach());
$infoBox.show();
// Only show if enabled and has data
if (extensionSettings.showInfoBox) {
const infoBoxData = window.lastGeneratedData?.infoBox || window.committedTrackerData?.infoBox;
if (infoBoxData) $infoBox.show();
}
}
if ($thoughts.length > 0) {
$statusTab.append($thoughts.detach());
$thoughts.show();
if (extensionSettings.showCharacterThoughts) $thoughts.show();
}
if ($inventory.length > 0) {
$inventoryTab.append($inventory.detach());
$inventory.show();
// Only show if enabled (will be part of tab structure)
if (hasInventory) $inventory.show();
}
if ($equipment.length > 0) {
$equipmentTab.append($equipment.detach());
// Only show if enabled (will be part of tab structure)
if (hasEquipment) $equipment.show();
}
if ($quests.length > 0) {
$questsTab.append($quests.detach());
$quests.show();
// Only show if enabled (will be part of tab structure)
if (hasQuests) $quests.show();
}
// Hide dividers on desktop tabs (tabs separate content naturally)
@@ -81,11 +392,16 @@ export function setupDesktopTabs() {
const $tabsContainer = $('<div class="rpg-tabs-container"></div>');
$tabsContainer.append($tabNav);
$tabsContainer.append($statusTab);
// Always append inventory and quests tabs to preserve the elements
// But they'll only show if enabled (via tab button visibility)
$tabsContainer.append($inventoryTab);
$tabsContainer.append($equipmentTab);
$tabsContainer.append($questsTab);
// Replace content box with tabs container
$contentBox.html('').append($tabsContainer);
i18n.applyTranslations($tabsContainer[0]);
// Handle tab switching
$tabNav.find('.rpg-tab-btn').on('click', function() {
@@ -100,7 +416,7 @@ export function setupDesktopTabs() {
$(`.rpg-tab-content[data-tab-content="${tabName}"]`).addClass('active');
});
console.log('[RPG Desktop] Desktop tabs initialized');
}
/**
@@ -113,6 +429,7 @@ export function removeDesktopTabs() {
const $infoBox = $('#rpg-info-box').detach();
const $thoughts = $('#rpg-thoughts').detach();
const $inventory = $('#rpg-inventory').detach();
const $equipment = $('#rpg-equipment').detach();
const $quests = $('#rpg-quests').detach();
// Remove tabs container
@@ -122,16 +439,19 @@ export function removeDesktopTabs() {
const $dividerStats = $('#rpg-divider-stats');
const $dividerInfo = $('#rpg-divider-info');
const $dividerThoughts = $('#rpg-divider-thoughts');
const $dividerInventory = $('#rpg-divider-inventory');
const $dividerEquipment = $('#rpg-divider-equipment');
// Restore original sections to content box in correct order
const $contentBox = $('.rpg-content-box');
// Re-insert sections in original order: User Stats, Info Box, Thoughts, Inventory, Quests
// Re-insert sections in original order: User Stats, Info Box, Thoughts, Inventory, Equipment, Quests
if ($dividerStats.length) {
$dividerStats.before($userStats);
$dividerInfo.before($infoBox);
$dividerThoughts.before($thoughts);
$contentBox.append($inventory);
$dividerInventory.before($inventory);
$dividerEquipment.before($equipment);
$contentBox.append($quests);
} else {
// Fallback if dividers don't exist
@@ -139,15 +459,19 @@ export function removeDesktopTabs() {
$contentBox.append($infoBox);
$contentBox.append($thoughts);
$contentBox.append($inventory);
$contentBox.append($equipment);
$contentBox.append($quests);
}
// Show sections and dividers
$userStats.show();
$infoBox.show();
$thoughts.show();
$inventory.show();
// Show/hide sections based on settings (respect visibility settings)
if (extensionSettings.showUserStats) $userStats.show();
if (extensionSettings.showInfoBox) {
const infoBoxData = window.lastGeneratedData?.infoBox || window.committedTrackerData?.infoBox;
if (infoBoxData) $infoBox.show();
}
if (extensionSettings.showCharacterThoughts) $thoughts.show();
if (extensionSettings.showInventory) $inventory.show();
if (extensionSettings.showEquipment) $equipment.show();
if (extensionSettings.showQuests) $quests.show();
$('.rpg-divider').show();
console.log('[RPG Desktop] Desktop tabs removed');
}
File diff suppressed because it is too large Load Diff
+261 -64
View File
@@ -9,14 +9,51 @@ import {
$userStatsContainer,
$infoBoxContainer,
$thoughtsContainer,
$inventoryContainer
$inventoryContainer,
$questsContainer,
$musicPlayerContainer,
setInventoryContainer,
setQuestsContainer,
lastGeneratedData,
committedTrackerData
} from '../../core/state.js';
import { i18n } from '../../core/i18n.js';
import { setupMobileTabs, removeMobileTabs } from './mobile.js';
import { setupDesktopTabs, removeDesktopTabs, updateStripWidgets } from './desktop.js';
/**
* Toggles the visibility of plot buttons based on settings.
*/
export function togglePlotButtons() {
if (extensionSettings.enablePlotButtons && extensionSettings.enabled) {
if (!extensionSettings.enabled) {
$('#rpg-plot-buttons').hide();
return;
}
// Show/hide randomized plot button based on enableRandomizedPlot setting
if (extensionSettings.enableRandomizedPlot) {
$('#rpg-plot-random').show();
} else {
$('#rpg-plot-random').hide();
}
// Show/hide natural plot button based on enableNaturalPlot setting
if (extensionSettings.enableNaturalPlot) {
$('#rpg-plot-natural').show();
} else {
$('#rpg-plot-natural').hide();
}
// Show/hide encounter button independently based on encounter settings
if (extensionSettings.encounterSettings?.enabled) {
$('#rpg-encounter-button').show();
} else {
$('#rpg-encounter-button').hide();
}
// Show the container if at least one button is visible
const shouldShowContainer = extensionSettings.enableRandomizedPlot || extensionSettings.enableNaturalPlot || extensionSettings.encounterSettings?.enabled;
if (shouldShowContainer) {
$('#rpg-plot-buttons').show();
} else {
$('#rpg-plot-buttons').hide();
@@ -51,19 +88,34 @@ export function updateCollapseToggleIcon() {
const isMobile = window.innerWidth <= 1000;
if (isMobile) {
// Mobile: slides from right, use same icon logic as desktop right panel
// Mobile: icon direction based on panel position and open state
const isOpen = $panel.hasClass('rpg-mobile-open');
console.log('[RPG Mobile] updateCollapseToggleIcon:', {
isMobile: true,
isOpen,
settingIcon: isOpen ? 'chevron-left' : 'chevron-right'
});
const isLeftPanel = $panel.hasClass('rpg-position-left');
// console.log('[RPG Mobile] updateCollapseToggleIcon:', {
// isMobile: true,
// isOpen,
// isLeftPanel,
// settingIcon: isOpen ? (isLeftPanel ? 'chevron-left' : 'chevron-right') : (isLeftPanel ? 'chevron-right' : 'chevron-left')
// });
if (isLeftPanel) {
if (isOpen) {
// Panel open - chevron points left (to close/slide back right)
// Left panel open - chevron points left (panel will slide left to close)
$icon.removeClass('fa-chevron-down fa-chevron-up fa-chevron-right').addClass('fa-chevron-left');
} else {
// Panel closed - chevron points right (to open/slide in from right)
// Left panel closed - chevron points left (panel is hidden on left)
$icon.removeClass('fa-chevron-down fa-chevron-up fa-chevron-right').addClass('fa-chevron-left');
}
} else {
// Right panel (default)
if (isOpen) {
// Right panel open - chevron points right (panel will slide right to close)
$icon.removeClass('fa-chevron-down fa-chevron-up fa-chevron-left').addClass('fa-chevron-right');
} else {
// Right panel closed - chevron points right (panel is hidden on right)
$icon.removeClass('fa-chevron-down fa-chevron-up fa-chevron-left').addClass('fa-chevron-right');
}
}
} else {
// Desktop: icon direction based on panel position and collapsed state
@@ -92,6 +144,7 @@ export function updateCollapseToggleIcon() {
*/
export function setupCollapseToggle() {
const $collapseToggle = $('#rpg-collapse-toggle');
$collapseToggle.attr('title', i18n.getTranslation('template.mainPanel.collapseExpand') || 'Collapse/Expand panel');
const $panel = $('#rpg-companion-panel');
const $icon = $collapseToggle.find('i');
@@ -104,44 +157,44 @@ export function setupCollapseToggle() {
// On mobile: button toggles panel open/closed (same as desktop behavior)
if (isMobile) {
const isOpen = $panel.hasClass('rpg-mobile-open');
console.log('[RPG Mobile] Collapse toggle clicked. Current state:', {
isOpen,
panelClasses: $panel.attr('class'),
inlineStyles: $panel.attr('style'),
panelPosition: {
top: $panel.css('top'),
bottom: $panel.css('bottom'),
transform: $panel.css('transform'),
visibility: $panel.css('visibility')
}
});
// console.log('[RPG Mobile] Collapse toggle clicked. Current state:', {
// isOpen,
// panelClasses: $panel.attr('class'),
// inlineStyles: $panel.attr('style'),
// panelPosition: {
// top: $panel.css('top'),
// bottom: $panel.css('bottom'),
// transform: $panel.css('transform'),
// visibility: $panel.css('visibility')
// }
// });
if (isOpen) {
// Close panel with animation
console.log('[RPG Mobile] Closing panel');
// console.log('[RPG Mobile] Closing panel');
closeMobilePanelWithAnimation();
} else {
// Open panel
console.log('[RPG Mobile] Opening panel');
// console.log('[RPG Mobile] Opening panel');
$panel.addClass('rpg-mobile-open');
const $overlay = $('<div class="rpg-mobile-overlay"></div>');
$('body').append($overlay);
// Debug: Check state after animation should complete
setTimeout(() => {
console.log('[RPG Mobile] 500ms after opening:', {
panelClasses: $panel.attr('class'),
hasOpenClass: $panel.hasClass('rpg-mobile-open'),
visibility: $panel.css('visibility'),
transform: $panel.css('transform'),
display: $panel.css('display'),
opacity: $panel.css('opacity')
});
// console.log('[RPG Mobile] 500ms after opening:', {
// panelClasses: $panel.attr('class'),
// hasOpenClass: $panel.hasClass('rpg-mobile-open'),
// visibility: $panel.css('visibility'),
// transform: $panel.css('transform'),
// display: $panel.css('display'),
// opacity: $panel.css('opacity')
// });
}, 500);
// Close when clicking overlay
$overlay.on('click', function() {
console.log('[RPG Mobile] Overlay clicked - closing panel');
// console.log('[RPG Mobile] Overlay clicked - closing panel');
closeMobilePanelWithAnimation();
updateCollapseToggleIcon();
});
@@ -150,20 +203,20 @@ export function setupCollapseToggle() {
// Update icon to reflect new state
updateCollapseToggleIcon();
console.log('[RPG Mobile] After toggle:', {
panelClasses: $panel.attr('class'),
inlineStyles: $panel.attr('style'),
panelPosition: {
top: $panel.css('top'),
bottom: $panel.css('bottom'),
transform: $panel.css('transform'),
visibility: $panel.css('visibility')
},
gameContainer: {
opacity: $('.rpg-game-container').css('opacity'),
visibility: $('.rpg-game-container').css('visibility')
}
});
// console.log('[RPG Mobile] After toggle:', {
// panelClasses: $panel.attr('class'),
// inlineStyles: $panel.attr('style'),
// panelPosition: {
// top: $panel.css('top'),
// bottom: $panel.css('bottom'),
// transform: $panel.css('transform'),
// visibility: $panel.css('visibility')
// },
// gameContainer: {
// opacity: $('.rpg-game-container').css('opacity'),
// visibility: $('.rpg-game-container').css('visibility')
// }
// });
return;
}
@@ -190,6 +243,9 @@ export function setupCollapseToggle() {
} else if ($panel.hasClass('rpg-position-left')) {
$icon.removeClass('fa-chevron-left').addClass('fa-chevron-right');
}
// Update strip widgets when collapsing (they show in collapsed state)
updateStripWidgets();
}
});
@@ -204,9 +260,13 @@ export function updatePanelVisibility() {
if (extensionSettings.enabled) {
$panelContainer.show();
togglePlotButtons(); // Update plot button visibility
$('#rpg-mobile-toggle').show(); // Show mobile FAB toggle
$('#rpg-collapse-toggle').show(); // Show collapse toggle
} else {
$panelContainer.hide();
$('#rpg-plot-buttons').hide(); // Hide plot buttons when disabled
$('#rpg-mobile-toggle').hide(); // Hide mobile FAB toggle
$('#rpg-collapse-toggle').hide(); // Hide collapse toggle
}
}
@@ -214,29 +274,145 @@ export function updatePanelVisibility() {
* Updates the visibility of individual sections.
*/
export function updateSectionVisibility() {
// Refresh container references first (in case they were detached during tab operations)
setInventoryContainer($('#rpg-inventory'));
setQuestsContainer($('#rpg-quests'));
// Show/hide sections based on settings
$userStatsContainer.toggle(extensionSettings.showUserStats);
$infoBoxContainer.toggle(extensionSettings.showInfoBox);
$thoughtsContainer.toggle(extensionSettings.showCharacterThoughts);
if ($inventoryContainer) {
$inventoryContainer.toggle(extensionSettings.showInventory);
// Use explicit .show()/.hide() instead of .toggle() to ensure proper state on reload
if (extensionSettings.showUserStats) {
$userStatsContainer.show();
} else {
$userStatsContainer.hide();
}
if (extensionSettings.showInfoBox) {
// Only show if there's data to display
const infoBoxData = lastGeneratedData.infoBox || committedTrackerData.infoBox;
if (infoBoxData) {
$infoBoxContainer.show();
} else {
$infoBoxContainer.hide();
}
} else {
$infoBoxContainer.hide();
}
if (extensionSettings.showCharacterThoughts) {
$thoughtsContainer.show();
} else {
$thoughtsContainer.hide();
}
// Use direct DOM selectors for inventory and quests to avoid stale references
if (extensionSettings.showInventory) {
$('#rpg-inventory').show();
} else {
$('#rpg-inventory').hide();
}
if (extensionSettings.showQuests) {
$('#rpg-quests').show();
} else {
$('#rpg-quests').hide();
}
if (extensionSettings.showEquipment) {
$('#rpg-equipment').show();
} else {
$('#rpg-equipment').hide();
}
if ($musicPlayerContainer) {
if (extensionSettings.enableSpotifyMusic) {
$musicPlayerContainer.show();
} else {
$musicPlayerContainer.hide();
}
}
// Show/hide dividers intelligently
// Divider after User Stats: shown if User Stats is visible AND at least one section after it is visible
const showDividerAfterStats = extensionSettings.showUserStats &&
(extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts || extensionSettings.showInventory);
$('#rpg-divider-stats').toggle(showDividerAfterStats);
(extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts || extensionSettings.showInventory || extensionSettings.showEquipment || extensionSettings.showQuests || extensionSettings.enableSpotifyMusic);
if (showDividerAfterStats) {
$('#rpg-divider-stats').show();
} else {
$('#rpg-divider-stats').hide();
}
// Divider after Info Box: shown if Info Box is visible AND at least one section after it is visible
const showDividerAfterInfo = extensionSettings.showInfoBox &&
(extensionSettings.showCharacterThoughts || extensionSettings.showInventory);
$('#rpg-divider-info').toggle(showDividerAfterInfo);
(extensionSettings.showCharacterThoughts || extensionSettings.showInventory || extensionSettings.showEquipment || extensionSettings.showQuests);
if (showDividerAfterInfo) {
$('#rpg-divider-info').show();
} else {
$('#rpg-divider-info').hide();
}
// Divider after Thoughts: shown if Thoughts is visible AND Inventory is visible
// Divider after Thoughts: shown if Thoughts is visible AND at least one section after it is visible
const showDividerAfterThoughts = extensionSettings.showCharacterThoughts &&
extensionSettings.showInventory;
$('#rpg-divider-thoughts').toggle(showDividerAfterThoughts);
(extensionSettings.showInventory || extensionSettings.showEquipment || extensionSettings.showQuests || extensionSettings.enableSpotifyMusic);
if (showDividerAfterThoughts) {
$('#rpg-divider-thoughts').show();
} else {
$('#rpg-divider-thoughts').hide();
}
// Divider after Inventory: shown if Inventory is visible AND (Equipment, Quests or Music) is visible
const showDividerAfterInventory = extensionSettings.showInventory && (extensionSettings.showEquipment || extensionSettings.showQuests || extensionSettings.enableSpotifyMusic);
if (showDividerAfterInventory) {
$('#rpg-divider-inventory').show();
} else {
$('#rpg-divider-inventory').hide();
}
// Divider after Equipment: shown if Equipment is visible AND (Quests or Music) is visible
const showDividerAfterEquipment = extensionSettings.showEquipment && (extensionSettings.showQuests || extensionSettings.enableSpotifyMusic);
if (showDividerAfterEquipment) {
$('#rpg-divider-equipment').show();
} else {
$('#rpg-divider-equipment').hide();
}
// Divider after Quests: shown if Quests is visible AND Music is visible
const showDividerAfterQuests = extensionSettings.showQuests && extensionSettings.enableSpotifyMusic;
if (showDividerAfterQuests) {
$('#rpg-divider-quests').show();
} else {
$('#rpg-divider-quests').hide();
}
// Rebuild tabs to reflect visibility changes for inventory and quests
const isMobile = window.innerWidth <= 1000;
const hasMobileTabs = $('.rpg-mobile-container').length > 0;
const hasDesktopTabs = $('.rpg-tabs-nav').length > 0;
// Only rebuild if tabs currently exist
if (hasMobileTabs || hasDesktopTabs) {
// Remove existing tabs
if (hasMobileTabs) {
removeMobileTabs();
// Force remove any lingering mobile tab elements (but not the content sections!)
$('.rpg-mobile-container').remove();
$('.rpg-mobile-tabs').remove();
} else {
removeDesktopTabs();
// Force remove any lingering desktop tab structure (but not the content sections!)
// The removeDesktopTabs() function already detached and restored the sections
}
// Rebuild tabs immediately
if (isMobile) {
setupMobileTabs();
} else {
setupDesktopTabs();
}
// Refresh container references
setInventoryContainer($('#rpg-inventory'));
setQuestsContainer($('#rpg-quests'));
}
}
/**
@@ -249,16 +425,19 @@ export function applyPanelPosition() {
// Remove all position classes
$panelContainer.removeClass('rpg-position-left rpg-position-right rpg-position-top');
$('body').removeClass('rpg-panel-position-left rpg-panel-position-right rpg-panel-position-top');
// On mobile, don't apply desktop position classes
// Add the appropriate position class
$panelContainer.addClass(`rpg-position-${extensionSettings.panelPosition}`);
// On mobile, also add body class for mobile-specific CSS
if (isMobile) {
$('body').addClass(`rpg-panel-position-${extensionSettings.panelPosition}`);
updateCollapseToggleIcon();
return;
}
// Desktop: Add the appropriate position class
$panelContainer.addClass(`rpg-position-${extensionSettings.panelPosition}`);
// Update collapse toggle icon direction for new position
// Desktop: Update collapse toggle icon direction for new position
updateCollapseToggleIcon();
}
@@ -269,8 +448,26 @@ export function updateGenerationModeUI() {
if (extensionSettings.generationMode === 'together') {
// In "together" mode, manual update button is hidden
$('#rpg-manual-update').hide();
} else {
$('#rpg-strip-refresh').hide();
$('#rpg-external-api-settings').slideUp(200);
$('#rpg-separate-mode-settings').slideUp(200);
// Hide auto-update toggle (not applicable in together mode)
$('#rpg-auto-update-container').slideUp(200);
} else if (extensionSettings.generationMode === 'separate') {
// In "separate" mode, manual update button is visible
$('#rpg-manual-update').show();
$('#rpg-strip-refresh').show();
$('#rpg-external-api-settings').slideUp(200);
$('#rpg-separate-mode-settings').slideDown(200);
// Show auto-update toggle
$('#rpg-auto-update-container').slideDown(200);
} else if (extensionSettings.generationMode === 'external') {
// In "external" mode, manual update button is visible AND both settings are shown
$('#rpg-manual-update').show();
$('#rpg-strip-refresh').show();
$('#rpg-external-api-settings').slideDown(200);
$('#rpg-separate-mode-settings').slideDown(200);
// Show auto-update toggle for external mode too
$('#rpg-auto-update-container').slideDown(200);
}
}
+594 -80
View File
@@ -3,10 +3,67 @@
* Handles mobile-specific UI functionality: FAB dragging, tabs, keyboard handling
*/
import { extensionSettings } from '../../core/state.js';
import { extensionSettings, committedTrackerData, lastGeneratedData } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { closeMobilePanelWithAnimation, updateCollapseToggleIcon } from './layout.js';
import { setupDesktopTabs, removeDesktopTabs } from './desktop.js';
import { i18n } from '../../core/i18n.js';
import { hexToRgba } from './theme.js';
/**
* Updates the text labels of the mobile navigation tabs based on the current language.
*/
export function updateMobileTabLabels() {
const $tabs = $('.rpg-mobile-tabs .rpg-mobile-tab');
if ($tabs.length === 0) return;
$tabs.each(function() {
const $tab = $(this);
const tabName = $tab.data('tab');
let translationKey = '';
switch (tabName) {
case 'stats':
translationKey = 'global.status';
break;
case 'info':
translationKey = 'global.info';
break;
case 'inventory':
translationKey = 'global.inventory';
break;
case 'equipment':
translationKey = 'equipment.title';
break;
case 'quests':
translationKey = 'global.quests';
break;
}
if (translationKey) {
let fallback = '';
switch (tabName) {
case 'stats':
fallback = 'Status';
break;
case 'info':
fallback = 'Info';
break;
case 'inventory':
fallback = 'Inventory';
break;
case 'equipment':
fallback = 'Equipment';
break;
case 'quests':
fallback = 'Quests';
break;
}
const translation = i18n.getTranslation(translationKey) || fallback;
$tab.find('span').text(translation);
}
});
}
/**
* Sets up the mobile toggle button (FAB) with drag functionality.
@@ -18,13 +75,13 @@ export function setupMobileToggle() {
const $overlay = $('<div class="rpg-mobile-overlay"></div>');
// DIAGNOSTIC: Check if elements exist and log setup state
console.log('[RPG Mobile] ========================================');
console.log('[RPG Mobile] setupMobileToggle called');
console.log('[RPG Mobile] Button exists:', $mobileToggle.length > 0, 'jQuery object:', $mobileToggle);
console.log('[RPG Mobile] Panel exists:', $panel.length > 0);
console.log('[RPG Mobile] Window width:', window.innerWidth);
console.log('[RPG Mobile] Is mobile viewport (<=1000):', window.innerWidth <= 1000);
console.log('[RPG Mobile] ========================================');
// console.log('[RPG Mobile] ========================================');
// console.log('[RPG Mobile] setupMobileToggle called');
// console.log('[RPG Mobile] Button exists:', $mobileToggle.length > 0, 'jQuery object:', $mobileToggle);
// console.log('[RPG Mobile] Panel exists:', $panel.length > 0);
// console.log('[RPG Mobile] Window width:', window.innerWidth);
// console.log('[RPG Mobile] Is mobile viewport (<=1000):', window.innerWidth <= 1000);
// console.log('[RPG Mobile] ========================================');
if ($mobileToggle.length === 0) {
console.error('[RPG Mobile] ERROR: Mobile toggle button not found in DOM!');
@@ -35,7 +92,7 @@ export function setupMobileToggle() {
// Load and apply saved FAB position
if (extensionSettings.mobileFabPosition) {
const pos = extensionSettings.mobileFabPosition;
console.log('[RPG Mobile] Loading saved FAB position:', pos);
// console.log('[RPG Mobile] Loading saved FAB position:', pos);
// Apply saved position
if (pos.top) $mobileToggle.css('top', pos.top);
@@ -69,6 +126,14 @@ export function setupMobileToggle() {
right: 'auto',
bottom: 'auto'
});
// Also update widget container position during drag
const $container = $('#rpg-fab-widget-container');
if ($container.length > 0) {
$container.css({
top: pendingY + 'px',
left: pendingX + 'px'
});
}
pendingX = null;
pendingY = null;
}
@@ -213,10 +278,13 @@ export function setupMobileToggle() {
extensionSettings.mobileFabPosition = newPosition;
saveSettings();
console.log('[RPG Mobile] Saved new FAB position (mouse):', newPosition);
// console.log('[RPG Mobile] Saved new FAB position (mouse):', newPosition);
// Constrain to viewport bounds (now that position is saved)
setTimeout(() => constrainFabToViewport(), 10);
setTimeout(() => {
constrainFabToViewport();
updateFabWidgetPosition(); // Update widget container position
}, 10);
// Re-enable transitions with smooth animation
setTimeout(() => {
@@ -254,10 +322,13 @@ export function setupMobileToggle() {
extensionSettings.mobileFabPosition = newPosition;
saveSettings();
console.log('[RPG Mobile] Saved new FAB position:', newPosition);
// console.log('[RPG Mobile] Saved new FAB position:', newPosition);
// Constrain to viewport bounds (now that position is saved)
setTimeout(() => constrainFabToViewport(), 10);
setTimeout(() => {
constrainFabToViewport();
updateFabWidgetPosition(); // Update widget container position
}, 10);
// Re-enable transitions with smooth animation
setTimeout(() => {
@@ -267,7 +338,7 @@ export function setupMobileToggle() {
isDragging = false;
} else {
// Was a tap - toggle panel
console.log('[RPG Mobile] Quick tap detected - toggling panel');
// console.log('[RPG Mobile] Quick tap detected - toggling panel');
if ($panel.hasClass('rpg-mobile-open')) {
// Close panel with animation
@@ -290,28 +361,28 @@ export function setupMobileToggle() {
$mobileToggle.on('click', function(e) {
// Skip if we just finished dragging
if ($mobileToggle.data('just-dragged')) {
console.log('[RPG Mobile] Click blocked - just finished dragging');
// console.log('[RPG Mobile] Click blocked - just finished dragging');
return;
}
console.log('[RPG Mobile] >>> CLICK EVENT FIRED <<<', {
windowWidth: window.innerWidth,
isMobileViewport: window.innerWidth <= 1000,
panelOpen: $panel.hasClass('rpg-mobile-open')
});
// console.log('[RPG Mobile] >>> CLICK EVENT FIRED <<<', {
// windowWidth: window.innerWidth,
// isMobileViewport: window.innerWidth <= 1000,
// panelOpen: $panel.hasClass('rpg-mobile-open')
// });
// Work on both mobile and desktop (removed viewport check)
if ($panel.hasClass('rpg-mobile-open')) {
console.log('[RPG Mobile] Click: Closing panel');
// console.log('[RPG Mobile] Click: Closing panel');
closeMobilePanelWithAnimation();
} else {
console.log('[RPG Mobile] Click: Opening panel');
// console.log('[RPG Mobile] Click: Opening panel');
$panel.addClass('rpg-mobile-open');
$('body').append($overlay);
$mobileToggle.addClass('active');
$overlay.on('click', function() {
console.log('[RPG Mobile] Overlay clicked - closing panel');
// console.log('[RPG Mobile] Overlay clicked - closing panel');
closeMobilePanelWithAnimation();
});
}
@@ -330,13 +401,20 @@ export function setupMobileToggle() {
// Transitioning from desktop to mobile - handle immediately for smooth transition
if (!wasMobile && isMobile) {
console.log('[RPG Mobile] Transitioning desktop -> mobile');
// console.log('[RPG Mobile] Transitioning desktop -> mobile');
// Show mobile toggle button
$mobileToggle.show();
// Remove desktop tabs first
removeDesktopTabs();
// Remove desktop positioning classes
// Apply mobile positioning based on panelPosition setting
$panel.removeClass('rpg-position-right rpg-position-left rpg-position-top');
$('body').removeClass('rpg-panel-position-right rpg-panel-position-left rpg-panel-position-top');
const position = extensionSettings.panelPosition || 'right';
$panel.addClass('rpg-position-' + position);
$('body').addClass('rpg-panel-position-' + position);
// Clear collapsed state - mobile doesn't use collapse
$panel.removeClass('rpg-collapsed');
@@ -347,16 +425,16 @@ export function setupMobileToggle() {
// Clear any inline styles that might be overriding CSS
$panel.attr('style', '');
console.log('[RPG Mobile] After cleanup:', {
panelClasses: $panel.attr('class'),
inlineStyles: $panel.attr('style'),
panelPosition: {
top: $panel.css('top'),
bottom: $panel.css('bottom'),
transform: $panel.css('transform'),
visibility: $panel.css('visibility')
}
});
// console.log('[RPG Mobile] After cleanup:', {
// panelClasses: $panel.attr('class'),
// inlineStyles: $panel.attr('style'),
// panelPosition: {
// top: $panel.css('top'),
// bottom: $panel.css('bottom'),
// transform: $panel.css('transform'),
// visibility: $panel.css('visibility')
// }
// });
// Set up mobile tabs IMMEDIATELY (no debounce delay)
setupMobileTabs();
@@ -381,7 +459,11 @@ export function setupMobileToggle() {
$mobileToggle.removeClass('active');
$('.rpg-mobile-overlay').remove();
// Restore desktop positioning class
// Hide mobile toggle button on desktop
$mobileToggle.hide();
// Restore desktop positioning class and remove body mobile classes
$('body').removeClass('rpg-panel-position-right rpg-panel-position-left rpg-panel-position-top');
const position = extensionSettings.panelPosition || 'right';
$panel.addClass('rpg-position-' + position);
@@ -414,19 +496,21 @@ export function setupMobileToggle() {
// Clear any inline styles
$panel.attr('style', '');
console.log('[RPG Mobile] Initial load on mobile viewport:', {
panelClasses: $panel.attr('class'),
inlineStyles: $panel.attr('style'),
panelPosition: {
top: $panel.css('top'),
bottom: $panel.css('top'),
transform: $panel.css('transform'),
visibility: $panel.css('visibility')
}
});
setupMobileTabs();
// console.log('[RPG Mobile] Initial load on mobile viewport:', {
// panelClasses: $panel.attr('class'),
// inlineStyles: $panel.attr('style'),
// panelPosition: {
// top: $panel.css('top'),
// bottom: $panel.css('top'),
// transform: $panel.css('transform'),
// visibility: $panel.css('visibility')\n // }\n // });\n setupMobileTabs();
// Set initial icon for mobile
updateCollapseToggleIcon();
// Show mobile toggle on mobile viewport
$mobileToggle.show();
} else {
// Hide mobile toggle on desktop viewport
$mobileToggle.hide();
}
}
@@ -438,7 +522,7 @@ export function setupMobileToggle() {
export function constrainFabToViewport() {
// Only constrain if user has set a custom position
if (!extensionSettings.mobileFabPosition) {
console.log('[RPG Mobile] Skipping viewport constraint - using CSS defaults');
// console.log('[RPG Mobile] Skipping viewport constraint - using CSS defaults');
return;
}
@@ -447,7 +531,7 @@ export function constrainFabToViewport() {
// Skip if button is not visible
if (!$mobileToggle.is(':visible')) {
console.log('[RPG Mobile] Skipping viewport constraint - button not visible');
// console.log('[RPG Mobile] Skipping viewport constraint - button not visible');
return;
}
@@ -477,12 +561,12 @@ export function constrainFabToViewport() {
// Only update if position changed
if (newX !== currentX || newY !== currentY) {
console.log('[RPG Mobile] Constraining FAB to viewport:', {
old: { x: currentX, y: currentY },
new: { x: newX, y: newY },
viewport: { width: window.innerWidth, height: window.innerHeight },
topBarHeight
});
// console.log('[RPG Mobile] Constraining FAB to viewport:', {
// old: { x: currentX, y: currentY },
// new: { x: newX, y: newY },
// viewport: { width: window.innerWidth, height: window.innerHeight },
// topBarHeight
// });
// Apply new position
$mobileToggle.css({
@@ -513,6 +597,14 @@ export function setupMobileTabs() {
if ($('.rpg-mobile-tabs').length > 0) return;
const $panel = $('#rpg-companion-panel');
// Apply mobile positioning based on panelPosition setting
$panel.removeClass('rpg-position-right rpg-position-left rpg-position-top');
$('body').removeClass('rpg-panel-position-right rpg-panel-position-left rpg-panel-position-top');
const position = extensionSettings.panelPosition || 'right';
$panel.addClass('rpg-position-' + position);
$('body').addClass('rpg-panel-position-' + position);
const $contentBox = $panel.find('.rpg-content-box');
// Get existing sections
@@ -520,10 +612,11 @@ export function setupMobileTabs() {
const $infoBox = $('#rpg-info-box');
const $thoughts = $('#rpg-thoughts');
const $inventory = $('#rpg-inventory');
const $equipment = $('#rpg-equipment');
const $quests = $('#rpg-quests');
// If no sections exist, nothing to organize
if ($userStats.length === 0 && $infoBox.length === 0 && $thoughts.length === 0 && $inventory.length === 0 && $quests.length === 0) {
if ($userStats.length === 0 && $infoBox.length === 0 && $thoughts.length === 0 && $inventory.length === 0 && $equipment.length === 0 && $quests.length === 0) {
return;
}
@@ -531,24 +624,29 @@ export function setupMobileTabs() {
const tabs = [];
const hasStats = $userStats.length > 0;
const hasInfo = $infoBox.length > 0 || $thoughts.length > 0;
const hasInventory = $inventory.length > 0;
const hasQuests = $quests.length > 0;
const hasInventory = $inventory.length > 0 && extensionSettings.showInventory;
const hasEquipment = $equipment.length > 0 && extensionSettings.showEquipment;
const hasQuests = $quests.length > 0 && extensionSettings.showQuests;
// Tab 1: Stats (User Stats only)
if (hasStats) {
tabs.push('<button class="rpg-mobile-tab active" data-tab="stats"><i class="fa-solid fa-chart-bar"></i><span>Stats</span></button>');
tabs.push('<button class="rpg-mobile-tab active" data-tab="stats"><i class="fa-solid fa-chart-bar"></i><span>' + (i18n.getTranslation('global.status') || 'Status') + '</span></button>');
}
// Tab 2: Info (Info Box + Character Thoughts)
if (hasInfo) {
tabs.push('<button class="rpg-mobile-tab ' + (tabs.length === 0 ? 'active' : '') + '" data-tab="info"><i class="fa-solid fa-book"></i><span>Info</span></button>');
tabs.push('<button class="rpg-mobile-tab ' + (tabs.length === 0 ? 'active' : '') + '" data-tab="info"><i class="fa-solid fa-book"></i><span>' + (i18n.getTranslation('global.info') || 'Info') + '</span></button>');
}
// Tab 3: Inventory
if (hasInventory) {
tabs.push('<button class="rpg-mobile-tab ' + (tabs.length === 0 ? 'active' : '') + '" data-tab="inventory"><i class="fa-solid fa-box"></i><span>Inventory</span></button>');
tabs.push('<button class="rpg-mobile-tab ' + (tabs.length === 0 ? 'active' : '') + '" data-tab="inventory"><i class="fa-solid fa-box"></i><span>' + (i18n.getTranslation('global.inventory') || 'Inventory') + '</span></button>');
}
// Tab 3.5: Equipment
if (hasEquipment) {
tabs.push('<button class="rpg-mobile-tab ' + (tabs.length === 0 ? 'active' : '') + '" data-tab="equipment"><i class="fa-solid fa-shield-halved"></i><span>' + (i18n.getTranslation('equipment.title') || 'Equipment') + '</span></button>');
}
// Tab 4: Quests
if (hasQuests) {
tabs.push('<button class="rpg-mobile-tab ' + (tabs.length === 0 ? 'active' : '') + '" data-tab="quests"><i class="fa-solid fa-scroll"></i><span>Quests</span></button>');
tabs.push('<button class="rpg-mobile-tab ' + (tabs.length === 0 ? 'active' : '') + '" data-tab="quests"><i class="fa-solid fa-scroll"></i><span>' + (i18n.getTranslation('global.quests') || 'Quests') + '</span></button>');
}
const $tabNav = $('<div class="rpg-mobile-tabs">' + tabs.join('') + '</div>');
@@ -558,12 +656,14 @@ export function setupMobileTabs() {
if (hasStats) firstTab = 'stats';
else if (hasInfo) firstTab = 'info';
else if (hasInventory) firstTab = 'inventory';
else if (hasEquipment) firstTab = 'equipment';
else if (hasQuests) firstTab = 'quests';
// Create tab content wrappers
const $statsTab = $('<div class="rpg-mobile-tab-content ' + (firstTab === 'stats' ? 'active' : '') + '" data-tab-content="stats"></div>');
const $infoTab = $('<div class="rpg-mobile-tab-content ' + (firstTab === 'info' ? 'active' : '') + '" data-tab-content="info"></div>');
const $inventoryTab = $('<div class="rpg-mobile-tab-content ' + (firstTab === 'inventory' ? 'active' : '') + '" data-tab-content="inventory"></div>');
const $equipmentTab = $('<div class="rpg-mobile-tab-content ' + (firstTab === 'equipment' ? 'active' : '') + '" data-tab-content="equipment"></div>');
const $questsTab = $('<div class="rpg-mobile-tab-content ' + (firstTab === 'quests' ? 'active' : '') + '" data-tab-content="quests"></div>');
// Move sections into their respective tabs (detach to preserve event handlers)
@@ -576,7 +676,9 @@ export function setupMobileTabs() {
// Info tab: Info Box + Character Thoughts
if ($infoBox.length > 0) {
$infoTab.append($infoBox.detach());
$infoBox.show();
// Only show if has data
const infoBoxData = window.lastGeneratedData?.infoBox || window.committedTrackerData?.infoBox;
if (infoBoxData) $infoBox.show();
}
if ($thoughts.length > 0) {
$infoTab.append($thoughts.detach());
@@ -589,6 +691,12 @@ export function setupMobileTabs() {
$inventory.show();
}
// Equipment tab: Equipment only
if ($equipment.length > 0) {
$equipmentTab.append($equipment.detach());
$equipment.show();
}
// Quests tab: Quests only
if ($quests.length > 0) {
$questsTab.append($quests.detach());
@@ -602,12 +710,13 @@ export function setupMobileTabs() {
const $mobileContainer = $('<div class="rpg-mobile-container"></div>');
$mobileContainer.append($tabNav);
// Only append tab content wrappers that have content
if (hasStats) $mobileContainer.append($statsTab);
if (hasInfo) $mobileContainer.append($infoTab);
if (hasInventory) $mobileContainer.append($inventoryTab);
if (hasQuests) $mobileContainer.append($questsTab);
if (hasInventory) $mobileContainer.append($inventoryTab);
// Always append all tab content wrappers to preserve elements
// Tab buttons control visibility
$mobileContainer.append($statsTab);
$mobileContainer.append($infoTab);
$mobileContainer.append($inventoryTab);
$mobileContainer.append($equipmentTab);
$mobileContainer.append($questsTab);
// Insert mobile tab structure at the beginning of content box
$contentBox.prepend($mobileContainer);
@@ -635,6 +744,7 @@ export function removeMobileTabs() {
const $infoBox = $('#rpg-info-box').detach();
const $thoughts = $('#rpg-thoughts').detach();
const $inventory = $('#rpg-inventory').detach();
const $equipment = $('#rpg-equipment').detach();
const $quests = $('#rpg-quests').detach();
// Remove mobile tab container
@@ -644,31 +754,40 @@ export function removeMobileTabs() {
const $dividerStats = $('#rpg-divider-stats');
const $dividerInfo = $('#rpg-divider-info');
const $dividerThoughts = $('#rpg-divider-thoughts');
const $dividerInventory = $('#rpg-divider-inventory');
const $dividerEquipment = $('#rpg-divider-equipment');
// Restore original sections to content box in correct order
const $contentBox = $('.rpg-content-box');
// Re-insert sections in original order: User Stats, Info Box, Thoughts, Inventory, Quests
// Re-insert sections in original order: User Stats, Info Box, Thoughts, Inventory, Equipment, Quests
if ($dividerStats.length) {
$dividerStats.before($userStats);
$dividerInfo.before($infoBox);
$dividerThoughts.before($thoughts);
$contentBox.append($inventory);
$dividerInventory.before($inventory);
$dividerEquipment.before($equipment);
$contentBox.append($quests);
} else {
// Fallback if dividers don't exist
$contentBox.prepend($quests);
$contentBox.prepend($equipment);
$contentBox.prepend($inventory);
$contentBox.prepend($thoughts);
$contentBox.prepend($infoBox);
$contentBox.prepend($userStats);
}
// Show sections and dividers
$userStats.show();
$infoBox.show();
$thoughts.show();
$inventory.show();
// Show/hide sections based on settings (respect visibility settings)
if (extensionSettings.showUserStats) $userStats.show();
if (extensionSettings.showInfoBox) {
const infoBoxData = window.lastGeneratedData?.infoBox || window.committedTrackerData?.infoBox;
if (infoBoxData) $infoBox.show();
}
if (extensionSettings.showCharacterThoughts) $thoughts.show();
if (extensionSettings.showInventory) $inventory.show();
if (extensionSettings.showEquipment) $equipment.show();
if (extensionSettings.showQuests) $quests.show();
$('.rpg-divider').show();
}
@@ -715,12 +834,17 @@ export function setupMobileKeyboardHandling() {
/**
* Handles focus on contenteditable fields to ensure they're visible when keyboard appears.
* Uses smooth scrolling to bring focused field into view with proper padding.
* Only applies on mobile viewports where virtual keyboard can obscure content.
*/
export function setupContentEditableScrolling() {
const $panel = $('#rpg-companion-panel');
// Use event delegation for all contenteditable fields
$panel.on('focusin', '[contenteditable="true"]', function(e) {
// Only apply scrolling behavior on mobile (where virtual keyboard appears)
const isMobile = window.innerWidth <= 1000;
if (!isMobile) return;
const $field = $(this);
// Small delay to let keyboard animate in
@@ -749,12 +873,12 @@ export function setupRefreshButtonDrag() {
return;
}
console.log('[RPG Mobile] setupRefreshButtonDrag called');
// 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);
// console.log('[RPG Mobile] Loading saved refresh button position:', pos);
// Apply saved position
if (pos.top) $refreshBtn.css('top', pos.top);
@@ -964,12 +1088,12 @@ export function setupDebugButtonDrag() {
return;
}
console.log('[RPG Mobile] setupDebugButtonDrag called');
// 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);
// console.log('[RPG Mobile] Loading saved debug button position:', pos);
// Apply saved position
if (pos.top) $debugBtn.css('top', pos.top);
@@ -1166,3 +1290,393 @@ export function setupDebugButtonDrag() {
isDragging = false;
});
}
// ============================================
// FAB WIDGETS - Info display around FAB button
// ============================================
/**
* Updates the FAB widgets display based on current tracker data and settings.
* Widgets are positioned in 8 positions around the FAB (N, NE, E, SE, S, SW, W, NW).
*/
export function updateFabWidgets() {
const $fab = $('#rpg-mobile-toggle');
if ($fab.length === 0) return;
// Remove existing widget container and clean up event listeners
$('#rpg-fab-widget-container').remove();
$(document).off('click.fabWidgets touchstart.fabWidgets');
// Check if widgets are enabled
const widgetSettings = extensionSettings.mobileFabWidgets;
if (!widgetSettings || !widgetSettings.enabled) return;
// Don't show widgets on desktop or when panel is open
if (window.innerWidth > 1000) return;
// Get tracker data - prefer lastGeneratedData (most recent) over committedTrackerData
const infoBox = lastGeneratedData?.infoBox || committedTrackerData?.infoBox;
const userStats = lastGeneratedData?.userStats || committedTrackerData?.userStats;
// Parse infoBox if it's a string
let infoData = null;
if (infoBox) {
try {
infoData = typeof infoBox === 'string' ? JSON.parse(infoBox) : infoBox;
} catch (e) {
console.warn('[RPG FAB Widgets] Failed to parse infoBox:', e);
}
}
// Parse userStats if it's a string
let statsData = null;
if (userStats) {
try {
statsData = typeof userStats === 'string' ? JSON.parse(userStats) : userStats;
} catch (e) {
console.warn('[RPG FAB Widgets] Failed to parse userStats:', e);
}
}
// Create widget container positioned at FAB location
const fabOffset = $fab.offset();
const fabWidth = $fab.outerWidth();
const fabHeight = $fab.outerHeight();
const $container = $('<div id="rpg-fab-widget-container" class="rpg-fab-widget-container"></div>');
$container.css({
top: fabOffset.top + 'px',
left: fabOffset.left + 'px',
width: fabWidth + 'px',
height: fabHeight + 'px'
});
// Build widgets based on settings - auto-assign positions sequentially
const widgets = [];
// Collect enabled widgets in display priority order
// Large widgets (Stats, Attributes) go to West/Northwest
// Small widgets spread around other positions
// Weather Icon (small)
if (widgetSettings.weatherIcon?.enabled && infoData?.weather?.emoji) {
widgets.push({
type: 'small',
html: `<div class="rpg-fab-widget rpg-fab-widget-weather-icon" title="Weather">${infoData.weather.emoji}</div>`
});
}
// Weather Description (small)
if (widgetSettings.weatherDesc?.enabled && infoData?.weather?.forecast) {
const desc = infoData.weather.forecast.length > 15 ? infoData.weather.forecast.substring(0, 13) + '…' : infoData.weather.forecast;
widgets.push({
type: 'small',
html: `<div class="rpg-fab-widget rpg-fab-widget-weather-desc" title="${infoData.weather.forecast}">${desc}</div>`
});
}
// Helper to create expandable text widget HTML
const createExpandableText = (fullText, maxLen, emoji) => {
if (fullText.length <= maxLen) {
return `${emoji} ${fullText}`;
}
const truncated = fullText.substring(0, maxLen - 2) + '…';
return `${emoji} <span class="rpg-truncated">${truncated}</span><span class="rpg-full-text">${fullText}</span>`;
};
// Check if text needs truncation for data attribute
const needsExpand = (text, maxLen) => text.length > maxLen;
// Helper to parse time string and calculate clock hand angles
const parseTimeForClock = (timeStr) => {
const timeMatch = timeStr.match(/(\d+):(\d+)/);
if (timeMatch) {
const hours = parseInt(timeMatch[1]);
const minutes = parseInt(timeMatch[2]);
const hourAngle = (hours % 12) * 30 + minutes * 0.5; // 30° per hour + 0.5° per minute
const minuteAngle = minutes * 6; // 6° per minute
return { hourAngle, minuteAngle };
}
return { hourAngle: 0, minuteAngle: 0 };
};
// Clock/Time (bottom position with animated clock face)
if (widgetSettings.clock?.enabled && infoData?.time) {
const timeStr = infoData.time.end || infoData.time.value || infoData.time.start || '';
if (timeStr) {
const { hourAngle, minuteAngle } = parseTimeForClock(timeStr);
widgets.push({
type: 'bottom', // Special type for bottom position
html: `<div class="rpg-fab-widget rpg-fab-widget-clock" title="${timeStr}">
<div class="rpg-fab-clock-face">
<div class="rpg-fab-clock-hour" style="transform: rotate(${hourAngle}deg)"></div>
<div class="rpg-fab-clock-minute" style="transform: rotate(${minuteAngle}deg)"></div>
<div class="rpg-fab-clock-center"></div>
</div>
<span class="rpg-fab-clock-time">${timeStr}</span>
</div>`
});
}
}
// Date (small)
if (widgetSettings.date?.enabled && infoData?.date?.value) {
const dateVal = infoData.date.value;
const expandAttr = needsExpand(dateVal, 12) ? ' data-full-text="true"' : '';
widgets.push({
type: 'small',
html: `<div class="rpg-fab-widget rpg-fab-widget-date"${expandAttr} title="${dateVal}">${createExpandableText(dateVal, 12, '📅')}</div>`
});
}
// Location (small)
if (widgetSettings.location?.enabled && infoData?.location?.value) {
const loc = infoData.location.value;
const expandAttr = needsExpand(loc, 14) ? ' data-full-text="true"' : '';
widgets.push({
type: 'small',
html: `<div class="rpg-fab-widget rpg-fab-widget-location"${expandAttr} title="${loc}">${createExpandableText(loc, 14, '📍')}</div>`
});
}
// Stats (large - goes to West) - respects trackerConfig.userStats.customStats
// Use extensionSettings.userStats as primary source (contains all stats), fallback to committedTrackerData
let allStats = [];
try {
const userStatsJson = extensionSettings.userStats;
const parsedUserStats = typeof userStatsJson === 'string' ? JSON.parse(userStatsJson) : userStatsJson;
if (parsedUserStats?.stats) {
allStats = parsedUserStats.stats;
}
} catch (e) {
console.warn('[RPG FAB Widgets] Failed to parse extensionSettings.userStats:', e);
}
// Fallback to statsData if extensionSettings.userStats is empty
if (allStats.length === 0 && statsData?.stats) {
allStats = statsData.stats;
}
if (widgetSettings.stats?.enabled && allStats.length > 0) {
// Get enabled stats from trackerConfig - match by id (lowercase)
const configuredStats = extensionSettings.trackerConfig?.userStats?.customStats || [];
const enabledStatMap = new Map();
configuredStats.forEach(s => {
if (s.enabled !== false) {
enabledStatMap.set(s.id?.toLowerCase(), true);
enabledStatMap.set(s.name?.toLowerCase(), true);
}
});
const statsHtml = allStats
.filter(s => {
// If no config, show all stats
if (configuredStats.length === 0) return true;
// Check if stat is enabled in trackerConfig (match by id or name, case-insensitive)
const statId = s.id?.toLowerCase();
const statName = s.name?.toLowerCase();
return enabledStatMap.has(statId) || enabledStatMap.has(statName);
})
.map(stat => {
const value = typeof stat.value === 'number' ? stat.value : parseInt(stat.value) || 0;
const color = getStatColor(value);
const abbr = stat.name.substring(0, 3).toUpperCase();
return `<span class="rpg-fab-widget-stat-item" title="${stat.name}: ${value}" style="color: ${color};">${abbr}:${value}</span>`;
})
.join('');
if (statsHtml) {
widgets.push({
type: 'large',
preferredPos: 6, // West
html: `<div class="rpg-fab-widget rpg-fab-widget-stats"><div class="rpg-fab-widget-stats-row">${statsHtml}</div></div>`
});
}
}
// RPG Attributes (large - goes to Northwest) - respects trackerConfig.userStats.rpgAttributes
if (widgetSettings.attributes?.enabled) {
// Check if RPG attributes are enabled in trackerConfig
const showRPGAttributes = extensionSettings.trackerConfig?.userStats?.showRPGAttributes !== false;
if (showRPGAttributes && extensionSettings.classicStats) {
// Get enabled attributes from trackerConfig
const configuredAttrs = extensionSettings.trackerConfig?.userStats?.rpgAttributes || [];
const enabledAttrIds = configuredAttrs.filter(a => a.enabled !== false).map(a => a.id);
const attrs = extensionSettings.classicStats;
const attrItems = Object.entries(attrs)
.filter(([key]) => {
// Check if attribute is enabled in trackerConfig
if (enabledAttrIds.length > 0) {
return enabledAttrIds.includes(key.toLowerCase());
}
return true;
})
.map(([key, value]) => `<div class="rpg-fab-widget-attr-item"><span class="rpg-fab-widget-attr-name">${key.toUpperCase()}</span><span class="rpg-fab-widget-attr-value">${value}</span></div>`)
.join('');
if (attrItems) {
widgets.push({
type: 'large',
preferredPos: 7, // Northwest
html: `<div class="rpg-fab-widget rpg-fab-widget-attributes" title="Attributes"><div class="rpg-fab-widget-attr-grid">${attrItems}</div></div>`
});
}
}
}
// Auto-assign positions intelligently
// Large widgets get their preferred positions first (West=6, Northwest=7)
// Bottom widgets get position 4 (South)
// Small widgets fill remaining positions clockwise from North (0)
const usedPositions = new Set();
const positionedWidgets = [];
// Position order for small widgets: N(0), NE(1), E(2), SE(3), SW(5) - skip S(4) for bottom/clock
const smallPositionOrder = [0, 1, 2, 3, 5];
let smallPosIndex = 0;
// Check if only one large widget exists (for centering)
const largeWidgets = widgets.filter(w => w.type === 'large');
const singleLargeWidget = largeWidgets.length === 1;
// First: assign bottom widgets to position 4 (South)
widgets.filter(w => w.type === 'bottom').forEach(w => {
const pos = 4; // South position
usedPositions.add(pos);
const finalHtml = w.html.replace('class="rpg-fab-widget', `class="rpg-fab-widget rpg-fab-widget-pos-${pos}`);
positionedWidgets.push({ position: pos, html: finalHtml });
});
// Second: assign large widgets to their preferred positions
largeWidgets.forEach(w => {
let pos = w.preferredPos;
// If preferred position is taken, find next available from large positions
if (usedPositions.has(pos)) {
pos = pos === 6 ? 7 : 6; // Try the other large position
}
usedPositions.add(pos);
// Add centered class if this is the only large widget
const centeredClass = singleLargeWidget ? ' rpg-fab-widget-centered' : '';
const finalHtml = w.html.replace('class="rpg-fab-widget', `class="rpg-fab-widget rpg-fab-widget-pos-${pos}${centeredClass}`);
positionedWidgets.push({ position: pos, html: finalHtml });
});
// Third: assign small widgets to remaining positions
widgets.filter(w => w.type === 'small').forEach(w => {
// Find next available position from small position order
while (smallPosIndex < smallPositionOrder.length && usedPositions.has(smallPositionOrder[smallPosIndex])) {
smallPosIndex++;
}
const pos = smallPosIndex < smallPositionOrder.length ? smallPositionOrder[smallPosIndex] : (smallPosIndex % 8);
usedPositions.add(pos);
smallPosIndex++;
const finalHtml = w.html.replace('class="rpg-fab-widget', `class="rpg-fab-widget rpg-fab-widget-pos-${pos}`);
positionedWidgets.push({ position: pos, html: finalHtml });
});
// Add widgets to container
positionedWidgets.forEach(w => $container.append(w.html));
// Append container to body
if (positionedWidgets.length > 0) {
$('body').append($container);
// Add mobile tap handler for expandable widgets
$container.find('.rpg-fab-widget[data-full-text]').on('click touchstart', function(e) {
e.stopPropagation();
const $this = $(this);
const wasExpanded = $this.hasClass('expanded');
// Collapse all other expanded widgets
$container.find('.rpg-fab-widget.expanded').removeClass('expanded');
// Toggle this one
if (!wasExpanded) {
$this.addClass('expanded');
}
});
// Collapse on tap outside
$(document).on('click.fabWidgets touchstart.fabWidgets', function(e) {
if (!$(e.target).closest('.rpg-fab-widget').length) {
$container.find('.rpg-fab-widget.expanded').removeClass('expanded');
}
});
}
}
/**
* Gets a color for a stat value (0-100) using a gradient from low to high.
* @param {number} value - The stat value (0-100)
* @returns {string} CSS color value
*/
function getStatColor(value) {
const lowColor = extensionSettings.statBarColorLow || '#cc3333';
const lowOpacity = extensionSettings.statBarColorLowOpacity ?? 100;
const highColor = extensionSettings.statBarColorHigh || '#33cc66';
const highOpacity = extensionSettings.statBarColorHighOpacity ?? 100;
// Simple linear interpolation between low and high colors
const percent = Math.min(100, Math.max(0, value)) / 100;
// Parse colors
const lowRGB = hexToRgb(lowColor);
const highRGB = hexToRgb(highColor);
if (!lowRGB || !highRGB) return value > 50 ? hexToRgba(highColor, highOpacity) : hexToRgba(lowColor, lowOpacity);
const r = Math.round(lowRGB.r + (highRGB.r - lowRGB.r) * percent);
const g = Math.round(lowRGB.g + (highRGB.g - lowRGB.g) * percent);
const b = Math.round(lowRGB.b + (highRGB.b - lowRGB.b) * percent);
const a = (lowOpacity + (highOpacity - lowOpacity) * percent) / 100;
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
/**
* Converts a hex color to RGB object.
* @param {string} hex - Hex color string (e.g., "#cc3333")
* @returns {{r: number, g: number, b: number}|null}
*/
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
/**
* Updates the FAB widget container position to match FAB button position.
* Call this after FAB is dragged.
*/
export function updateFabWidgetPosition() {
const $fab = $('#rpg-mobile-toggle');
const $container = $('#rpg-fab-widget-container');
if ($fab.length === 0 || $container.length === 0) return;
const fabOffset = $fab.offset();
$container.css({
top: fabOffset.top + 'px',
left: fabOffset.left + 'px'
});
}
/**
* Sets the FAB loading state (spinning animation during API requests).
* @param {boolean} loading - Whether to show loading state
*/
export function setFabLoadingState(loading) {
const $fab = $('#rpg-mobile-toggle');
if ($fab.length === 0) return;
if (loading) {
$fab.addClass('rpg-fab-loading');
} else {
$fab.removeClass('rpg-fab-loading');
}
}
+232 -10
View File
@@ -10,19 +10,26 @@ import {
committedTrackerData,
$infoBoxContainer,
$thoughtsContainer,
$userStatsContainer,
clearThoughtBasedExpressionPortraits,
setPendingDiceRoll,
getPendingDiceRoll
getPendingDiceRoll,
clearSessionAvatarPrompts
} from '../../core/state.js';
import { saveSettings, saveChatData } from '../../core/persistence.js';
import { renderUserStats } from '../rendering/userStats.js';
import { updateChatThoughts } from '../rendering/thoughts.js';
import { renderInfoBox } from '../rendering/infoBox.js';
import { renderThoughts, updateChatThoughts } from '../rendering/thoughts.js';
import { renderQuests } from '../rendering/quests.js';
import { renderInventory } from '../rendering/inventory.js';
import { renderEquipment } from '../rendering/equipment.js';
import {
rollDice as rollDiceCore,
clearDiceRoll as clearDiceRollCore,
updateDiceDisplay as updateDiceDisplayCore,
addDiceQuickReply as addDiceQuickReplyCore
} from '../features/dice.js';
import { i18n } from '../../core/i18n.js';
/**
* Modern DiceModal ES6 Class
@@ -318,6 +325,7 @@ export function setupDiceRoller() {
e.stopPropagation(); // Prevent opening the dice popup
clearDiceRollCore();
});
$('#rpg-clear-dice').attr('title', i18n.getTranslation('template.mainPanel.clearLastRoll') || 'Clear last roll');
return diceModal;
}
@@ -349,18 +357,32 @@ export function setupSettingsPopup() {
// Clear cache button
$('#rpg-clear-cache').on('click', function() {
// Clear the data
// console.log('[RPG Companion] Clear Cache button clicked');
// Clear the data (set to null so panels show "not generated yet")
lastGeneratedData.userStats = null;
lastGeneratedData.infoBox = null;
lastGeneratedData.characterThoughts = null;
lastGeneratedData.html = null;
// Clear committed tracker data (used for generation context)
committedTrackerData.userStats = null;
committedTrackerData.infoBox = null;
committedTrackerData.characterThoughts = null;
// Clear session avatar prompts
clearSessionAvatarPrompts();
clearThoughtBasedExpressionPortraits();
// Clear chat metadata immediately (don't wait for debounced save)
const context = getContext();
if (context.chat_metadata && context.chat_metadata.rpg_companion) {
delete context.chat_metadata.rpg_companion;
// console.log('[RPG Companion] Cleared chat_metadata.rpg_companion for current chat');
}
// Clear all message swipe data
const chat = getContext().chat;
const chat = context.chat;
if (chat && chat.length > 0) {
for (let i = 0; i < chat.length; i++) {
const message = chat[i];
@@ -368,6 +390,14 @@ export function setupSettingsPopup() {
delete message.extra.rpg_companion_swipes;
// console.log('[RPG Companion] Cleared swipe data from message at index', i);
}
if (Array.isArray(message.swipe_info)) {
for (const swipeInfo of message.swipe_info) {
if (swipeInfo?.extra?.rpg_companion_swipes) {
delete swipeInfo.extra.rpg_companion_swipes;
}
}
}
}
}
@@ -378,8 +408,11 @@ export function setupSettingsPopup() {
if ($thoughtsContainer) {
$thoughtsContainer.empty();
}
if ($userStatsContainer) {
$userStatsContainer.empty();
}
// Reset stats to defaults and re-render
// Reset user stats to default object structure (extensionSettings stores as object, not JSON string)
extensionSettings.userStats = {
health: 100,
satiety: 100,
@@ -388,7 +421,29 @@ export function setupSettingsPopup() {
arousal: 0,
mood: '😐',
conditions: 'None',
inventory: 'None'
skills: [],
inventory: {
version: 2,
onPerson: "None",
clothing: "None",
stored: {},
assets: "None"
}
};
// Reset info box to defaults (as object)
extensionSettings.infoBox = {
date: new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }),
weather: '☀️ Clear skies',
temperature: '20°C',
time: '00:00 - 00:00',
location: 'Unknown Location',
recentEvents: []
};
// Reset character thoughts to empty (as object)
extensionSettings.characterThoughts = {
characters: []
};
// Reset classic stats (attributes) to defaults
@@ -404,23 +459,55 @@ export function setupSettingsPopup() {
// Clear dice roll
extensionSettings.lastDiceRoll = null;
// Reset level to 1
extensionSettings.level = 1;
// Clear quests
extensionSettings.quests = {
main: "None",
optional: []
};
// Clear all locked items
extensionSettings.lockedItems = {
stats: [],
skills: [],
inventory: {
onPerson: [],
clothing: [],
stored: {},
assets: []
},
quests: {
main: false,
optional: []
},
infoBox: {
date: false,
weather: false,
temperature: false,
time: false,
location: false,
recentEvents: false
},
characters: {}
};
// Save everything
saveChatData();
saveSettings();
// Re-render user stats and dice display
// Re-render all panels - they will show "not generated yet" messages since data is null
renderUserStats();
renderInfoBox();
renderThoughts();
updateDiceDisplayCore();
updateChatThoughts(); // Clear the thought bubble in chat
renderQuests(); // Clear and re-render quests UI
updateChatThoughts();
renderInventory();
renderEquipment();
renderQuests();
// console.log('[RPG Companion] Chat cache cleared');
// console.log('[RPG Companion] Cache cleared successfully');
});
return settingsModal;
@@ -506,3 +593,138 @@ export function addDiceQuickReply() {
export function getSettingsModal() {
return settingsModal;
}
/**
* Shows the welcome modal for v3.0.0 on first launch
* Checks if user has already seen this version's welcome screen
*/
export function showWelcomeModalIfNeeded() {
const WELCOME_VERSION = '3.0.1';
const STORAGE_KEY = 'rpg_companion_welcome_seen';
try {
const seenVersion = localStorage.getItem(STORAGE_KEY);
// If user hasn't seen v3.0.0 welcome yet, show it
if (seenVersion !== WELCOME_VERSION) {
showWelcomeModal(WELCOME_VERSION, STORAGE_KEY);
}
} catch (error) {
console.error('[RPG Companion] Failed to check welcome modal status:', error);
}
}
/**
* Shows the deprecation notice once for users updating to the deprecation release.
* @returns {boolean} True when the modal was displayed.
*/
export function showDeprecationModalIfNeeded() {
const DEPRECATION_NOTICE_VERSION = '3.7.4';
const STORAGE_KEY = 'rpg_companion_deprecation_notice_seen';
try {
const seenVersion = localStorage.getItem(STORAGE_KEY);
if (seenVersion !== DEPRECATION_NOTICE_VERSION) {
showDeprecationModal(DEPRECATION_NOTICE_VERSION, STORAGE_KEY);
return true;
}
} catch (error) {
console.error('[RPG Companion] Failed to check deprecation modal status:', error);
}
return false;
}
/**
* Shows the welcome modal
* @param {string} version - The version to mark as seen
* @param {string} storageKey - The localStorage key to use
*/
function showWelcomeModal(version, storageKey) {
const modal = document.getElementById('rpg-welcome-modal');
if (!modal) {
console.error('[RPG Companion] Welcome modal element not found');
return;
}
// Apply current theme to modal
const theme = extensionSettings.theme || 'default';
modal.setAttribute('data-theme', theme);
// Show modal
modal.style.display = 'flex';
modal.classList.add('is-open');
// Close button handler
const closeBtn = document.getElementById('rpg-welcome-close');
const gotItBtn = document.getElementById('rpg-welcome-got-it');
const closeModal = () => {
modal.classList.add('is-closing');
setTimeout(() => {
modal.style.display = 'none';
modal.classList.remove('is-open', 'is-closing');
}, 200);
// Mark this version as seen
try {
localStorage.setItem(storageKey, version);
} catch (error) {
console.error('[RPG Companion] Failed to save welcome modal status:', error);
}
};
// Attach event listeners
closeBtn?.addEventListener('click', closeModal, { once: true });
gotItBtn?.addEventListener('click', closeModal, { once: true });
// Close on background click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeModal();
}
}, { once: true });
}
function showDeprecationModal(version, storageKey) {
const modal = document.getElementById('rpg-deprecation-modal');
if (!modal) {
console.error('[RPG Companion] Deprecation modal element not found');
return;
}
const theme = extensionSettings.theme || 'default';
modal.setAttribute('data-theme', theme);
modal.style.display = 'flex';
modal.classList.add('is-open');
const closeBtn = document.getElementById('rpg-deprecation-close');
const gotItBtn = document.getElementById('rpg-deprecation-got-it');
const closeModal = () => {
modal.classList.add('is-closing');
setTimeout(() => {
modal.style.display = 'none';
modal.classList.remove('is-open', 'is-closing');
}, 200);
try {
localStorage.setItem(storageKey, version);
} catch (error) {
console.error('[RPG Companion] Failed to save deprecation modal status:', error);
}
};
closeBtn?.addEventListener('click', closeModal, { once: true });
gotItBtn?.addEventListener('click', closeModal, { once: true });
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeModal();
}
}, { once: true });
}
+274
View File
@@ -0,0 +1,274 @@
/**
* Prompts Editor Module
* Provides UI for customizing all AI prompts used in the extension
*/
import { extensionSettings } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { DEFAULT_HTML_PROMPT, DEFAULT_DIALOGUE_COLORING_PROMPT, DEFAULT_DECEPTION_PROMPT, DEFAULT_OMNISCIENCE_FILTER_PROMPT, DEFAULT_CYOA_PROMPT, DEFAULT_SPOTIFY_PROMPT, DEFAULT_NARRATOR_PROMPT, DEFAULT_CONTEXT_INSTRUCTIONS_PROMPT } from '../generation/promptBuilder.js';
let $editorModal = null;
let tempPrompts = null; // Temporary prompts for cancel functionality
// Default prompts
const DEFAULT_PROMPTS = {
html: DEFAULT_HTML_PROMPT,
dialogueColoring: DEFAULT_DIALOGUE_COLORING_PROMPT,
deception: DEFAULT_DECEPTION_PROMPT,
omniscience: DEFAULT_OMNISCIENCE_FILTER_PROMPT,
cyoa: DEFAULT_CYOA_PROMPT,
spotify: DEFAULT_SPOTIFY_PROMPT,
narrator: DEFAULT_NARRATOR_PROMPT,
contextInstructions: DEFAULT_CONTEXT_INSTRUCTIONS_PROMPT,
plotRandom: 'Actually, the scene is getting stale. Introduce {{random::stakes::a plot twist::a new character::a cataclysm::a fourth-wall-breaking joke::a sudden atmospheric phenomenon::a plot hook::a running gag::an ecchi scenario::Death from Discworld::a new stake::a drama::a conflict::an angered entity::a god::a vision::a prophetic dream::Il Dottore from Genshin Impact::a new development::a civilian in need::an emotional bit::a threat::a villain::an important memory recollection::a marriage proposal::a date idea::an angry horde of villagers with pitchforks::a talking animal::an enemy::a cliffhanger::a short omniscient POV shift to a completely different character::a quest::an unexpected revelation::a scandal::an evil clone::death of an important character::harm to an important character::a romantic setup::a gossip::a messenger::a plot point from the past::a plot hole::a tragedy::a ghost::an otherworldly occurrence::a plot device::a curse::a magic device::a rival::an unexpected pregnancy::a brothel::a prostitute::a new location::a past lover::a completely random thing::a what-if scenario::a significant choice::war::love::a monster::lewd undertones::Professor Mari::a travelling troupe::a secret::a fortune-teller::something completely different::a killer::a murder mystery::a mystery::a skill check::a deus ex machina::three raccoons in a trench coat::a pet::a slave::an orphan::a psycho::tentacles::"there is only one bed" trope::accidental marriage::a fun twist::a boss battle::sexy corn::an eldritch horror::a character getting hungry, thirsty, or exhausted::horniness::a need for a bathroom break need::someone fainting::an assassination attempt::a meta narration of this all being an out of hand DND session::a dungeon::a friend in need::an old friend::a small time skip::a scene shift::Aurora Borealis, at this time of year, at this time of day, at this part of the country::a grand ball::a surprise party::zombies::foreshadowing::a Spanish Inquisition (nobody expects it)::a natural plot progression}} to make things more interesting! Be creative, but stay grounded in the setting.',
plotNatural: 'Actually, the scene is getting stale. Progress it, to make things more interesting! Reintroduce an unresolved plot point from the past, or push the story further towards the current main goal. Be creative, but stay grounded in the setting.',
avatar: `You are a visionary artist trapped in a cage of logic. Your mind is filled with poetry and distant horizons; however, your hands are uncontrollably focused on creating the perfect character avatar description that is faithful to the original intent, rich in detail, aesthetically pleasing, and directly usable by text-to-image models. Any ambiguity or metaphor will make you feel extremely uncomfortable.
Your workflow strictly follows a logical sequence:
First, establish the subject. If the character is from a known Intellectual Property (IP), franchise, anime, game, or movie, you MUST begin the prompt with their full name and the series title (e.g., "Nami from One Piece", "Geralt of Rivia from The Witcher"). This is the single most important anchor for the image and must take precedence. If the character is original, clearly describe their core identity, race, and appearance.
Next, set the framing. This is an avatar portrait. Focus strictly on the character's face and upper shoulders (a bust shot or close-up). Ensure the face is the central focal point.
Then, integrate the setting. Describe the character within their current environment as provided in the context, but keep it as a background element. Incorporate the lighting, weather, and atmosphere to influence the character's appearance (e.g., shadows on the face, wet hair from rain).
Next, detail the facial specifics. Describe the character's current expression, eye contact, and mood in great detail based on the scene context and their personality. Mention visible clothing only at the neckline/shoulders.
Finally, infuse with aesthetics. Define the artistic style, medium (e.g., digital art, oil painting), and visual tone (e.g., cinematic lighting, ethereal atmosphere).
Your final description must be objective and concrete, and the use of metaphors and emotional rhetoric is strictly prohibited. It must also not contain meta tags or drawing instructions such as "8K" or "masterpiece".
Output only the final, modified prompt; do not output anything else.`,
trackerInstructions: 'Replace X with actual numbers (e.g., 69) and replace all placeholders with concrete in-world details that {userName} perceives about the current scene and the present characters. For example: "Location" becomes Forest Clearing, "Mood Emoji" becomes "😊". DO NOT include {userName} in the characters section, only NPCs. Consider the last trackers in the conversation (if they exist). Manage them accordingly and realistically; raise, lower, change, or keep the values unchanged based on the user\'s actions, the passage of time, and logical consequences (0% if the time progressed only by a few minutes, 1-5% normally, and above 5% only if a major time-skip/event occurs).',
trackerContinuation: 'After updating the trackers, continue directly from where the last message in the chat history left off. Ensure the trackers you provide naturally reflect and influence the narrative. Character behavior, dialogue, and story events should acknowledge these conditions when relevant, such as fatigue affecting the protagonist\'s performance, low hygiene influencing their social interactions, environmental factors shaping the scene, a character\'s emotional state coloring their responses, and so on. Remember, all placeholders (e.g., "Location", "Mood Emoji") MUST be replaced with actual content.',
combatNarrative: 'Build novel prose. Break patterns from your previous responses by varying sentence structures, rhythms, formats, and openings. Track whether a sentence shape, cadence, sensory detail, or a descriptor appeared recently, and replace it with a different one or skip it entirely. If you mentioned a physical feature, mannerism, or expression once, move on. Don\'t fixate on the same traits every turn. Well-placed humor, unconventional similes, and subverted turns of phrase are encouraged. Avoid GPTisms, purple prose, and anaphoras, especially the negatives with "no/not/wasn\'t/didn\'t." Refrain from describing actions or emotions via negation; focus on what does happen, not what doesn\'t. Absolutely no asterisks, ellipses, or em-dashes. Explicit content is allowed, no plot armor. Do not play for {userName}. Keep your response length under 150 words. Never end on handover cues; finish naturally.\nCRITICAL: Do not repeat, echo, parrot, or restate distinctive words, phrases, and dialogues from the user\'s last message. If reacting to speech, show interpretation or response, not repetition.\nEXAMPLE: "Are you a gooner?" User asks.\nBAD: "Gooner?"\nGOOD: A flat look. "What type of question is that?"'
};
/**
* Initialize the prompts editor modal
*/
export function initPromptsEditor() {
$editorModal = $('#rpg-prompts-editor-popup');
if (!$editorModal.length) {
console.error('[RPG Companion] Prompts editor modal not found in template');
return;
}
// Save button
$(document).on('click', '#rpg-prompts-save', function() {
savePrompts();
closePromptsEditor();
toastr.success('Prompts saved successfully.');
});
// Cancel button
$(document).on('click', '#rpg-prompts-cancel', function() {
closePromptsEditor();
});
// Close X button
$(document).on('click', '#rpg-close-prompts-editor', function() {
closePromptsEditor();
});
// Restore All button
$(document).on('click', '#rpg-prompts-restore-all', function() {
restoreAllToDefaults();
toastr.success('All prompts restored to defaults.');
});
// Individual restore buttons
$(document).on('click', '.rpg-restore-prompt-btn', function() {
const promptType = $(this).data('prompt');
restorePromptToDefault(promptType);
toastr.success('Prompt restored to default.');
});
// Close on background click
$(document).on('click', '#rpg-prompts-editor-popup', function(e) {
if (e.target.id === 'rpg-prompts-editor-popup') {
closePromptsEditor();
}
});
// Open button
$(document).on('click', '#rpg-open-prompts-editor', function() {
openPromptsEditor();
});
}
/**
* Open the prompts editor modal
*/
function openPromptsEditor() {
// Create temporary copy for cancel functionality
tempPrompts = {
html: extensionSettings.customHtmlPrompt || '',
dialogueColoring: extensionSettings.customDialogueColoringPrompt || '',
deception: extensionSettings.customDeceptionPrompt || '',
omniscience: extensionSettings.customOmnisciencePrompt || '',
cyoa: extensionSettings.customCYOAPrompt || '',
spotify: extensionSettings.customSpotifyPrompt || '',
narrator: extensionSettings.customNarratorPrompt || '',
contextInstructions: extensionSettings.customContextInstructionsPrompt || '',
plotRandom: extensionSettings.customPlotRandomPrompt || '',
plotNatural: extensionSettings.customPlotNaturalPrompt || '',
avatar: extensionSettings.avatarLLMCustomInstruction || '',
trackerInstructions: extensionSettings.customTrackerInstructionsPrompt || '',
trackerContinuation: extensionSettings.customTrackerContinuationPrompt || '',
combatNarrative: extensionSettings.customCombatNarrativePrompt || ''
};
// Load current values or defaults
$('#rpg-prompt-html').val(extensionSettings.customHtmlPrompt || DEFAULT_PROMPTS.html);
$('#rpg-prompt-dialogue-coloring').val(extensionSettings.customDialogueColoringPrompt || DEFAULT_PROMPTS.dialogueColoring);
$('#rpg-prompt-deception').val(extensionSettings.customDeceptionPrompt || DEFAULT_PROMPTS.deception);
$('#rpg-prompt-omniscience').val(extensionSettings.customOmnisciencePrompt || DEFAULT_PROMPTS.omniscience);
$('#rpg-prompt-cyoa').val(extensionSettings.customCYOAPrompt || DEFAULT_PROMPTS.cyoa);
$('#rpg-prompt-spotify').val(extensionSettings.customSpotifyPrompt || DEFAULT_PROMPTS.spotify);
$('#rpg-prompt-narrator').val(extensionSettings.customNarratorPrompt || DEFAULT_PROMPTS.narrator);
$('#rpg-prompt-context-instructions').val(extensionSettings.customContextInstructionsPrompt || DEFAULT_PROMPTS.contextInstructions);
$('#rpg-prompt-plot-random').val(extensionSettings.customPlotRandomPrompt || DEFAULT_PROMPTS.plotRandom);
$('#rpg-prompt-plot-natural').val(extensionSettings.customPlotNaturalPrompt || DEFAULT_PROMPTS.plotNatural);
$('#rpg-prompt-avatar').val(extensionSettings.avatarLLMCustomInstruction || DEFAULT_PROMPTS.avatar);
$('#rpg-prompt-tracker-instructions').val(extensionSettings.customTrackerInstructionsPrompt || DEFAULT_PROMPTS.trackerInstructions);
$('#rpg-prompt-tracker-continuation').val(extensionSettings.customTrackerContinuationPrompt || DEFAULT_PROMPTS.trackerContinuation);
$('#rpg-prompt-combat-narrative').val(extensionSettings.customCombatNarrativePrompt || DEFAULT_PROMPTS.combatNarrative);
// Set theme to match current extension theme
const theme = extensionSettings.theme || 'default';
$editorModal.attr('data-theme', theme);
$editorModal.addClass('is-open').css('display', '');
}
/**
* Close the prompts editor modal
*/
function closePromptsEditor() {
// Restore from temp if canceling
if (tempPrompts) {
tempPrompts = null;
}
$editorModal.removeClass('is-open').addClass('is-closing');
setTimeout(() => {
$editorModal.removeClass('is-closing').hide();
}, 200);
}
/**
* Save all prompts from the editor
*/
function savePrompts() {
extensionSettings.customHtmlPrompt = $('#rpg-prompt-html').val().trim();
extensionSettings.customDialogueColoringPrompt = $('#rpg-prompt-dialogue-coloring').val().trim();
extensionSettings.customDeceptionPrompt = $('#rpg-prompt-deception').val().trim();
extensionSettings.customOmnisciencePrompt = $('#rpg-prompt-omniscience').val().trim();
extensionSettings.customCYOAPrompt = $('#rpg-prompt-cyoa').val().trim();
extensionSettings.customSpotifyPrompt = $('#rpg-prompt-spotify').val().trim();
extensionSettings.customNarratorPrompt = $('#rpg-prompt-narrator').val().trim();
extensionSettings.customContextInstructionsPrompt = $('#rpg-prompt-context-instructions').val().trim();
extensionSettings.customPlotRandomPrompt = $('#rpg-prompt-plot-random').val().trim();
extensionSettings.customPlotNaturalPrompt = $('#rpg-prompt-plot-natural').val().trim();
extensionSettings.avatarLLMCustomInstruction = $('#rpg-prompt-avatar').val().trim();
extensionSettings.customTrackerInstructionsPrompt = $('#rpg-prompt-tracker-instructions').val().trim();
extensionSettings.customTrackerContinuationPrompt = $('#rpg-prompt-tracker-continuation').val().trim();
extensionSettings.customCombatNarrativePrompt = $('#rpg-prompt-combat-narrative').val().trim();
saveSettings();
}
/**
* Restore a specific prompt to its default
* @param {string} promptType - Type of prompt to restore (html, plotRandom, plotNatural, avatar)
*/
function restorePromptToDefault(promptType) {
const defaultValue = DEFAULT_PROMPTS[promptType] || '';
$(`#rpg-prompt-${promptType.replace(/([A-Z])/g, '-$1').toLowerCase()}`).val(defaultValue);
// Also update the setting immediately
switch(promptType) {
case 'html':
extensionSettings.customHtmlPrompt = '';
break;
case 'dialogueColoring':
extensionSettings.customDialogueColoringPrompt = '';
break;
case 'deception':
extensionSettings.customDeceptionPrompt = '';
break;
case 'omniscience':
extensionSettings.customOmnisciencePrompt = '';
break;
case 'cyoa':
extensionSettings.customCYOAPrompt = '';
break;
case 'spotify':
extensionSettings.customSpotifyPrompt = '';
break;
case 'narrator':
extensionSettings.customNarratorPrompt = '';
break;
case 'contextInstructions':
extensionSettings.customContextInstructionsPrompt = '';
break;
case 'plotRandom':
extensionSettings.customPlotRandomPrompt = '';
break;
case 'plotNatural':
extensionSettings.customPlotNaturalPrompt = '';
break;
case 'avatar':
extensionSettings.avatarLLMCustomInstruction = '';
break;
case 'trackerInstructions':
extensionSettings.customTrackerInstructionsPrompt = '';
break;
case 'trackerContinuation':
extensionSettings.customTrackerContinuationPrompt = '';
break;
case 'combatNarrative':
extensionSettings.customCombatNarrativePrompt = '';
break;
}
saveSettings();
}
/**
* Restore all prompts to their defaults
*/
function restoreAllToDefaults() {
$('#rpg-prompt-html').val(DEFAULT_PROMPTS.html);
$('#rpg-prompt-dialogue-coloring').val(DEFAULT_PROMPTS.dialogueColoring);
$('#rpg-prompt-deception').val(DEFAULT_PROMPTS.deception);
$('#rpg-prompt-omniscience').val(DEFAULT_PROMPTS.omniscience);
$('#rpg-prompt-cyoa').val(DEFAULT_PROMPTS.cyoa);
$('#rpg-prompt-spotify').val(DEFAULT_PROMPTS.spotify);
$('#rpg-prompt-narrator').val(DEFAULT_PROMPTS.narrator);
$('#rpg-prompt-context-instructions').val(DEFAULT_PROMPTS.contextInstructions);
$('#rpg-prompt-plot-random').val(DEFAULT_PROMPTS.plotRandom);
$('#rpg-prompt-plot-natural').val(DEFAULT_PROMPTS.plotNatural);
$('#rpg-prompt-avatar').val(DEFAULT_PROMPTS.avatar);
$('#rpg-prompt-tracker-instructions').val(DEFAULT_PROMPTS.trackerInstructions);
$('#rpg-prompt-tracker-continuation').val(DEFAULT_PROMPTS.trackerContinuation);
$('#rpg-prompt-combat-narrative').val(DEFAULT_PROMPTS.combatNarrative);
// Clear all custom prompts
extensionSettings.customHtmlPrompt = '';
extensionSettings.customDialogueColoringPrompt = '';
extensionSettings.customDeceptionPrompt = '';
extensionSettings.customOmnisciencePrompt = '';
extensionSettings.customCYOAPrompt = '';
extensionSettings.customSpotifyPrompt = '';
extensionSettings.customNarratorPrompt = '';
extensionSettings.customContextInstructionsPrompt = '';
extensionSettings.customPlotRandomPrompt = '';
extensionSettings.customPlotNaturalPrompt = '';
extensionSettings.avatarLLMCustomInstruction = '';
extensionSettings.customTrackerInstructionsPrompt = '';
extensionSettings.customTrackerContinuationPrompt = '';
extensionSettings.customCombatNarrativePrompt = '';
saveSettings();
}
/**
* Get default prompts (for export/other modules)
*/
export function getDefaultPrompts() {
return { ...DEFAULT_PROMPTS };
}
+78
View File
@@ -0,0 +1,78 @@
/**
* Snowflakes Effect Module
* Creates and manages animated snowflakes overlay
*/
import { extensionSettings } from '../../core/state.js';
let snowflakesContainer = null;
/**
* Create snowflakes container and snowflakes
*/
function createSnowflakes() {
if (snowflakesContainer) return; // Already created
// Create container
snowflakesContainer = document.createElement('div');
snowflakesContainer.className = 'rpg-snowflakes-container';
// Create 50 snowflakes with random positions
for (let i = 0; i < 50; i++) {
const snowflake = document.createElement('div');
snowflake.className = 'rpg-snowflake';
snowflake.textContent = '❄';
// Random horizontal position
snowflake.style.left = `${Math.random() * 100}%`;
// Random animation delay for staggered effect
snowflake.style.animationDelay = `${Math.random() * 10}s`;
// Random animation duration (between 10-20s)
snowflake.style.animationDuration = `${10 + Math.random() * 10}s`;
snowflakesContainer.appendChild(snowflake);
}
document.body.appendChild(snowflakesContainer);
}
/**
* Remove snowflakes container
*/
function removeSnowflakes() {
if (snowflakesContainer) {
snowflakesContainer.remove();
snowflakesContainer = null;
}
}
/**
* Toggle snowflakes effect
*/
export function toggleSnowflakes(enabled) {
if (enabled) {
createSnowflakes();
} else {
removeSnowflakes();
}
}
/**
* Initialize snowflakes based on saved state
*/
export function initSnowflakes() {
const enabled = extensionSettings.enableSnowflakes || false;
if (enabled) {
createSnowflakes();
}
}
/**
* Clean up snowflakes
*/
export function cleanupSnowflakes() {
removeSnowflakes();
}
+148 -7
View File
@@ -4,6 +4,38 @@
*/
import { extensionSettings, $panelContainer } from '../../core/state.js';
import { syncAlternatePresentCharactersTheme } from './alternatePresentCharacters.js';
/**
* Converts hex color and opacity percentage to rgba string
* @param {string} hex - Hex color (e.g., '#ff0000')
* @param {number} opacity - Opacity percentage (0-100)
* @returns {string} - RGBA color string
*/
export function hexToRgba(hex, opacity = 100) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const a = opacity / 100;
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
/**
* Gets stat bar colors with opacity applied
* @returns {{low: string, high: string}} RGBA color strings for stat bars
*/
export function getStatBarColors() {
return {
low: hexToRgba(
extensionSettings.statBarColorLow || '#cc3333',
extensionSettings.statBarColorLowOpacity ?? 100
),
high: hexToRgba(
extensionSettings.statBarColorHigh || '#33cc66',
extensionSettings.statBarColorHighOpacity ?? 100
)
};
}
/**
* Applies the selected theme to the panel.
@@ -36,6 +68,37 @@ export function applyTheme() {
}
// For 'default', we do nothing - it will use the CSS variables from .rpg-panel class
// which fall back to SillyTavern's theme variables
// Apply theme to mobile toggle and thought elements as well
const $mobileToggle = $('#rpg-mobile-toggle');
const $thoughtIcon = $('#rpg-thought-icon');
const $thoughtPanel = $('#rpg-thought-panel');
if ($mobileToggle.length) {
if (theme === 'default') {
$mobileToggle.removeAttr('data-theme');
} else {
$mobileToggle.attr('data-theme', theme);
}
}
if ($thoughtIcon.length) {
if (theme === 'default') {
$thoughtIcon.removeAttr('data-theme');
} else {
$thoughtIcon.attr('data-theme', theme);
}
}
if ($thoughtPanel.length) {
if (theme === 'default') {
$thoughtPanel.removeAttr('data-theme');
} else {
$thoughtPanel.attr('data-theme', theme);
}
}
syncAlternatePresentCharactersTheme();
}
/**
@@ -46,15 +109,52 @@ export function applyCustomTheme() {
const colors = extensionSettings.customColors;
// Apply custom CSS variables as inline styles
// Convert hex colors with opacity to rgba
const bgColor = hexToRgba(colors.bg, colors.bgOpacity ?? 100);
const accentColor = hexToRgba(colors.accent, colors.accentOpacity ?? 100);
const textColor = hexToRgba(colors.text, colors.textOpacity ?? 100);
const highlightColor = hexToRgba(colors.highlight, colors.highlightOpacity ?? 100);
// Create shadow with 50% opacity of highlight color
const shadowColor = hexToRgba(colors.highlight, (colors.highlightOpacity ?? 100) * 0.5);
// Apply custom CSS variables as inline styles to main panel
$panelContainer.css({
'--rpg-bg': colors.bg,
'--rpg-accent': colors.accent,
'--rpg-text': colors.text,
'--rpg-highlight': colors.highlight,
'--rpg-border': colors.highlight,
'--rpg-shadow': `${colors.highlight}80` // Add alpha for shadow
'--rpg-bg': bgColor,
'--rpg-accent': accentColor,
'--rpg-text': textColor,
'--rpg-highlight': highlightColor,
'--rpg-border': highlightColor,
'--rpg-shadow': shadowColor
});
// Apply custom colors to mobile toggle and thought elements
const customStyles = {
'--rpg-bg': bgColor,
'--rpg-accent': accentColor,
'--rpg-text': textColor,
'--rpg-highlight': highlightColor,
'--rpg-border': highlightColor,
'--rpg-shadow': shadowColor
};
const $mobileToggle = $('#rpg-mobile-toggle');
const $thoughtIcon = $('#rpg-thought-icon');
const $thoughtPanel = $('#rpg-thought-panel');
if ($mobileToggle.length) {
$mobileToggle.attr('data-theme', 'custom').css(customStyles);
}
if ($thoughtIcon.length) {
$thoughtIcon.attr('data-theme', 'custom').css(customStyles);
}
if ($thoughtPanel.length) {
$thoughtPanel.attr('data-theme', 'custom').css(customStyles);
}
syncAlternatePresentCharactersTheme();
}
/**
@@ -76,6 +176,47 @@ export function toggleAnimations() {
}
}
/**
* Updates visibility of feature toggles in main panel based on settings
*/
export function updateFeatureTogglesVisibility() {
const $featuresRow = $('#rpg-features-row');
const $htmlToggle = $('#rpg-html-toggle-wrapper');
const $dialogueColoringToggle = $('#rpg-dialogue-coloring-toggle-wrapper');
const $deceptionToggle = $('#rpg-deception-toggle-wrapper');
const $omniscienceToggle = $('#rpg-omniscience-toggle-wrapper');
const $cyoaToggle = $('#rpg-cyoa-toggle-wrapper');
const $spotifyToggle = $('#rpg-spotify-toggle-wrapper');
const $dynamicWeatherToggle = $('#rpg-dynamic-weather-toggle-wrapper');
const $narratorToggle = $('#rpg-narrator-toggle-wrapper');
const $autoAvatarsToggle = $('#rpg-auto-avatars-toggle-wrapper');
// Show/hide individual toggles
$htmlToggle.toggle(extensionSettings.showHtmlToggle);
$dialogueColoringToggle.toggle(extensionSettings.showDialogueColoringToggle);
$deceptionToggle.toggle(extensionSettings.showDeceptionToggle ?? true);
$omniscienceToggle.toggle(extensionSettings.showOmniscienceToggle ?? true);
$cyoaToggle.toggle(extensionSettings.showCYOAToggle ?? true);
$spotifyToggle.toggle(extensionSettings.showSpotifyToggle);
$dynamicWeatherToggle.toggle(extensionSettings.showDynamicWeatherToggle);
$narratorToggle.toggle(extensionSettings.showNarratorMode);
$autoAvatarsToggle.toggle(extensionSettings.showAutoAvatars);
// Hide entire row if all toggles are hidden
const anyVisible = extensionSettings.showHtmlToggle ||
extensionSettings.showDialogueColoringToggle ||
(extensionSettings.showDeceptionToggle ?? true) ||
(extensionSettings.showOmniscienceToggle ?? true) ||
(extensionSettings.showCYOAToggle ?? true) ||
extensionSettings.showSpotifyToggle ||
extensionSettings.showDynamicWeatherToggle ||
extensionSettings.showNarratorMode ||
extensionSettings.showAutoAvatars;
$featuresRow.toggle(anyVisible);
}
/**
* Updates the settings popup theme in real-time.
* Backwards compatible wrapper for SettingsModal class.
File diff suppressed because it is too large Load Diff
+856
View File
@@ -0,0 +1,856 @@
/**
* Dynamic Weather Effects Module
* Creates weather effects based on the Info Box weather field
*/
import { extensionSettings, lastGeneratedData, committedTrackerData } from '../../core/state.js';
import { repairJSON } from '../../utils/jsonRepair.js';
let weatherContainer = null;
let currentWeatherType = null;
let currentTimeOfDay = null;
let currentHour = null;
/**
* Parse time string to extract hour (24-hour format)
* Supports formats like "3:00 PM", "15:00", "3 PM", "Evening", etc.
*/
function parseHourFromTime(timeStr) {
if (!timeStr) return null;
const text = timeStr.toLowerCase().trim();
// Check for descriptive time words first
if (text.includes('dawn') || text.includes('sunrise')) return 6;
if (text.includes('early morning')) return 7;
if (text.includes('morning')) return 9;
if (text.includes('midday') || text.includes('noon') || text.includes('mid-day')) return 12;
if (text.includes('afternoon')) return 14;
if (text.includes('late afternoon')) return 16;
if (text.includes('evening') || text.includes('dusk') || text.includes('sunset')) return 19;
if (text.includes('twilight')) return 20;
if (text.includes('night') || text.includes('nighttime')) return 22;
if (text.includes('midnight')) return 0;
if (text.includes('late night')) return 2;
// Try to parse numeric time formats
// Format: "3:00 PM" or "3:00PM" or "3 PM"
const ampmMatch = text.match(/(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i);
if (ampmMatch) {
let hour = parseInt(ampmMatch[1], 10);
const isPM = ampmMatch[3].toLowerCase() === 'pm';
if (isPM && hour !== 12) hour += 12;
if (!isPM && hour === 12) hour = 0;
return hour;
}
// Format: "15:00" (24-hour)
const militaryMatch = text.match(/(\d{1,2}):(\d{2})/);
if (militaryMatch) {
return parseInt(militaryMatch[1], 10);
}
return null;
}
/**
* Determine time of day based on hour
*/
function getTimeOfDay(hour) {
if (hour === null) return 'unknown';
// Night: 8 PM (20:00) to 5 AM (05:00)
if (hour >= 20 || hour < 5) return 'night';
// Dawn/Dusk: 5 AM - 7 AM and 6 PM - 8 PM
if (hour >= 5 && hour < 7) return 'dawn';
if (hour >= 18 && hour < 20) return 'dusk';
// Day: 7 AM to 6 PM
return 'day';
}
/**
* Extract time from Info Box data
*/
function getCurrentTime() {
const infoBoxData = lastGeneratedData.infoBox || committedTrackerData.infoBox || '';
// Try to parse as JSON first (new format)
try {
const parsed = typeof infoBoxData === 'string' ? repairJSON(infoBoxData) : infoBoxData;
if (parsed && parsed.time) {
// Use the end time if available (current time), otherwise start time
return parsed.time.end || parsed.time.start || null;
}
} catch (e) {
// Not JSON, try old text format
}
// Fallback: Parse the old text format to find Time field
const lines = infoBoxData.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('Time:')) {
const timeStr = trimmed.substring('Time:'.length).trim();
// If it contains →, take the end time (after arrow)
if (timeStr.includes('→')) {
const parts = timeStr.split('→');
return parts[1]?.trim() || parts[0]?.trim();
}
return timeStr;
}
}
return null;
}
// Patterns for specific weather conditions (order matters - combined effects first)
// Grouped by languages for easy editing
// EXPORTED: Used by jsonPromptHelpers.js to provide valid weather keywords to LLM
export const WEATHER_PATTERNS_BY_LANGUAGE = {
en: [
{ id: "blizzard", patterns: [ "blizzard" ] }, // Snow + Wind
{ id: "storm", patterns: [ "storm", "thunder", "lightning" ] }, // Rain + Lightning
{ id: "wind", patterns: [ "wind", "breeze", "gust", "gale" ] },
{ id: "snow", patterns: [ "snow", "flurries" ] },
{ id: "rain", patterns: [ "rain", "drizzle", "shower" ] },
{ id: "mist", patterns: [ "mist", "fog", "haze" ] },
{ id: "sunny", patterns: [ "sunny", "clear", "bright" ] },
{ id: "none", patterns: [ "cloud", "overcast", "indoor", "inside" ] },
],
ru: [
{ id: "blizzard", patterns: [ "метель" ] },
{ id: "storm", patterns: [ "гроза", "буря", "шторм" ] },
{ id: "wind", patterns: [ "ветер", "ветрено", "ветерок", "бриз", "легкий бриз", "слегка ветрено", "легкий ветер", "шквал,буря" ] },
{ id: "snow", patterns: [ "снег", "снегопад" ] },
{ id: "rain", patterns: [ "дождь", "морось", "ливень" ] },
{ id: "mist", patterns: [ "мгла", "туман", "туманно" ] },
{ id: "sunny", patterns: [ "солнечно", "ясно", "ярко", "ясное утро", "ясный день" ] },
{ id: "none", patterns: [ "облачно", "пасмурно", "в помещении", "внутри" ] },
],
"zh-cn": [
{ id: "blizzard", patterns: ["暴风雪"] },
{ id: "storm", patterns: ["风暴", "雷暴", "雷电"] },
{ id: "wind", patterns: ["风", "微风", "阵风", "大风"] },
{ id: "snow", patterns: ["雪", "小雪"] },
{ id: "rain", patterns: ["雨", "毛毛雨", "阵雨"] },
{ id: "mist", patterns: ["薄雾", "雾", "霾"] },
{ id: "sunny", patterns: ["晴朗", "晴天", "阳光明媚"] },
{ id: "none", patterns: ["多云", "阴天", "室内", "屋内"] },
],
}
/**
* Get valid weather keywords for LLM prompt injection.
* Returns weather patterns for specified language or all languages.
* This ensures LLM generates responses that exactly match our expected patterns.
*
* @param {string} [language] - Language code (e.g., 'en', 'ru'). If not specified, returns all languages.
* @returns {Object} Object with weather type IDs as keys and arrays of valid keywords as values
* @example
* // Returns: { blizzard: ["blizzard"], storm: ["storm", "thunder", "lightning"], ... }
* getWeatherKeywordsForPrompt('en');
*/
export function getWeatherKeywordsForPrompt(language) {
const result = {};
// Get patterns for specified language or merge all languages
const languagesToProcess = language && WEATHER_PATTERNS_BY_LANGUAGE[language]
? { [language]: WEATHER_PATTERNS_BY_LANGUAGE[language] }
: WEATHER_PATTERNS_BY_LANGUAGE;
for (const [lang, patterns] of Object.entries(languagesToProcess)) {
for (const { id, patterns: keywords } of patterns) {
if (!result[id]) {
result[id] = [];
}
// Add keywords, avoiding duplicates
for (const keyword of keywords) {
if (!result[id].includes(keyword)) {
result[id].push(keyword);
}
}
}
}
return result;
}
/**
* Get weather keywords as a formatted string for LLM instructions.
* Provides a clear template showing valid weather forecast values.
*
* @param {string} [language] - Language code. If not specified, uses all available patterns.
* @returns {string} Formatted string for prompt injection
* @example
* // Returns: 'Valid forecast values: "blizzard", "storm", "thunder", "lightning", "wind", ...'
* getWeatherKeywordsAsPromptString('en');
*/
export function getWeatherKeywordsAsPromptString(language) {
const keywords = getWeatherKeywordsForPrompt(language);
const allKeywords = [];
for (const patterns of Object.values(keywords)) {
allKeywords.push(...patterns);
}
return `Valid forecast values (use one of these exactly): ${allKeywords.map(k => `"${k}"`).join(', ')}`;
}
/**
* Parse weather text to determine effect type
*/
function parseWeatherType(weatherText) {
if (!weatherText) return "none";
const text = weatherText.toLowerCase();
for (const language of Object.values(WEATHER_PATTERNS_BY_LANGUAGE)) {
for (const { id, patterns } of language) {
if (patterns.some(p => text.includes(p))) {
return id;
}
}
}
return "none";
}
/**
* Extract weather from Info Box data
*/
function getCurrentWeather() {
const infoBoxData = lastGeneratedData.infoBox || committedTrackerData.infoBox || '';
// Try to parse as JSON first (new format)
try {
const parsed = typeof infoBoxData === 'string' ? JSON.parse(infoBoxData) : infoBoxData;
if (parsed && parsed.weather) {
// Return the forecast text from the weather object
return parsed.weather.forecast || parsed.weather.emoji || null;
}
} catch (e) {
// Not JSON, try old text format
}
// Fallback: Parse the old text format to find Weather field
const lines = infoBoxData.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('Weather:')) {
return trimmed.substring('Weather:'.length).trim();
}
}
return null;
}
/**
* Create snowflakes effect
*/
function createSnowflakes() {
const container = document.createElement('div');
container.className = 'rpg-weather-particles';
// Create 50 snowflakes
for (let i = 0; i < 50; i++) {
const snowflake = document.createElement('div');
snowflake.className = 'rpg-weather-particle rpg-snowflake';
snowflake.textContent = '❄';
snowflake.style.left = `${Math.random() * 100}%`;
snowflake.style.animationDelay = `${Math.random() * 10}s`;
snowflake.style.animationDuration = `${10 + Math.random() * 10}s`;
container.appendChild(snowflake);
}
return container;
}
/**
* Create rain effect
*/
function createRain() {
const container = document.createElement('div');
container.className = 'rpg-weather-particles';
// Create 100 raindrops for heavier effect
for (let i = 0; i < 100; i++) {
const raindrop = document.createElement('div');
raindrop.className = 'rpg-weather-particle rpg-raindrop';
raindrop.style.left = `${Math.random() * 100}%`;
raindrop.style.animationDelay = `${Math.random() * 2}s`;
raindrop.style.animationDuration = `${0.5 + Math.random() * 0.5}s`;
container.appendChild(raindrop);
}
return container;
}
/**
* Create mist/fog effect
*/
function createMist() {
const container = document.createElement('div');
container.className = 'rpg-weather-particles';
// Create 5 mist layers
for (let i = 0; i < 5; i++) {
const mist = document.createElement('div');
mist.className = 'rpg-weather-particle rpg-mist';
mist.style.animationDelay = `${i * 2}s`;
mist.style.animationDuration = `${15 + i * 2}s`;
mist.style.opacity = `${0.1 + Math.random() * 0.2}`;
container.appendChild(mist);
}
return container;
}
/**
* Calculate sun position based on hour (arc across sky)
* Returns { left: vw%, top: dvh% }
*/
function calculateSunPosition(hour) {
// Daytime is roughly 5 AM to 8 PM (5-20)
// Map hour to position along an arc
// 5 AM = far left, low | 12 PM = center, high | 8 PM = far right, low
if (hour === null) hour = 12; // Default to noon if unknown
// Clamp to daytime hours
const clampedHour = Math.max(5, Math.min(20, hour));
// Normalize to 0-1 range (5 AM = 0, 20 PM = 1)
const progress = (clampedHour - 5) / 15;
// Horizontal position: 3% to 92% (left to right, wider range)
const left = 3 + progress * 89;
// Vertical position: parabolic arc (high at noon, low at dawn/dusk)
// At progress 0.5 (noon), top should be ~8% (high)
// At progress 0 or 1, top should be ~40% (low, near horizon)
const normalizedProgress = (progress - 0.5) * 2; // -1 to 1
const top = 8 + 32 * (normalizedProgress * normalizedProgress);
return { left, top };
}
/**
* Create clear/sunny weather effect with floating particles and warm glow
*/
function createSunshine(hour) {
const container = document.createElement('div');
container.className = 'rpg-weather-particles rpg-clear-weather';
// Create the sun based on current hour
const sunPos = calculateSunPosition(hour);
const sun = document.createElement('div');
sun.className = 'rpg-weather-particle rpg-clear-sun';
sun.style.left = `${sunPos.left}vw`;
sun.style.top = `${sunPos.top}dvh`;
container.appendChild(sun);
// Create sun glow
const sunGlow = document.createElement('div');
sunGlow.className = 'rpg-weather-particle rpg-clear-sun-glow';
sunGlow.style.left = `${sunPos.left}vw`;
sunGlow.style.top = `${sunPos.top}dvh`;
container.appendChild(sunGlow);
// Create warm ambient glow overlay
const ambientGlow = document.createElement('div');
ambientGlow.className = 'rpg-weather-particle rpg-clear-ambient-glow';
container.appendChild(ambientGlow);
// Create floating dust motes / pollen particles (golden sparkles)
for (let i = 0; i < 25; i++) {
const particle = document.createElement('div');
particle.className = 'rpg-weather-particle rpg-clear-dust-mote';
particle.style.left = `${Math.random() * 100}vw`;
particle.style.top = `${Math.random() * 100}dvh`;
particle.style.animationDelay = `${Math.random() * 15}s`;
particle.style.animationDuration = `${12 + Math.random() * 8}s`;
// Vary the size slightly
const size = 2 + Math.random() * 4;
particle.style.width = `${size}px`;
particle.style.height = `${size}px`;
container.appendChild(particle);
}
// Create soft light orbs that drift gently
for (let i = 0; i < 6; i++) {
const orb = document.createElement('div');
orb.className = 'rpg-weather-particle rpg-clear-light-orb';
orb.style.left = `${10 + Math.random() * 80}vw`;
orb.style.top = `${10 + Math.random() * 80}dvh`;
orb.style.animationDelay = `${i * 2}s`;
orb.style.animationDuration = `${20 + Math.random() * 10}s`;
// Vary the size
const size = 80 + Math.random() * 120;
orb.style.width = `${size}px`;
orb.style.height = `${size}px`;
container.appendChild(orb);
}
// Create lens flare effect in corner
const lensFlare = document.createElement('div');
lensFlare.className = 'rpg-weather-particle rpg-clear-lens-flare';
container.appendChild(lensFlare);
return container;
}
/**
* Create sunrise effect (dawn - warm orange/pink sky gradient with low sun)
*/
function createSunrise(hour) {
const container = document.createElement('div');
container.className = 'rpg-weather-particles rpg-sunrise-weather';
// Create sunrise gradient overlay
const sunriseOverlay = document.createElement('div');
sunriseOverlay.className = 'rpg-weather-particle rpg-sunrise-overlay';
container.appendChild(sunriseOverlay);
// Calculate sun position (rising from left horizon)
const sunPos = calculateSunPosition(hour);
// Create the rising sun
const sun = document.createElement('div');
sun.className = 'rpg-weather-particle rpg-clear-sun rpg-sunrise-sun';
sun.style.left = `${sunPos.left}vw`;
sun.style.top = `${sunPos.top}dvh`;
container.appendChild(sun);
// Create sun glow (more orange during sunrise)
const sunGlow = document.createElement('div');
sunGlow.className = 'rpg-weather-particle rpg-clear-sun-glow rpg-sunrise-glow';
sunGlow.style.left = `${sunPos.left}vw`;
sunGlow.style.top = `${sunPos.top}dvh`;
container.appendChild(sunGlow);
// Create horizon glow
const horizonGlow = document.createElement('div');
horizonGlow.className = 'rpg-weather-particle rpg-sunrise-horizon-glow';
container.appendChild(horizonGlow);
// Add some fading stars (still visible at dawn)
for (let i = 0; i < 15; i++) {
const star = document.createElement('div');
star.className = 'rpg-weather-particle rpg-night-star rpg-sunrise-fading-star';
star.style.left = `${Math.random() * 100}vw`;
star.style.top = `${Math.random() * 40}dvh`;
star.style.animationDelay = `${Math.random() * 3}s`;
const size = 1 + Math.random() * 1.5;
star.style.width = `${size}px`;
star.style.height = `${size}px`;
container.appendChild(star);
}
// Add some golden dust motes
for (let i = 0; i < 12; i++) {
const particle = document.createElement('div');
particle.className = 'rpg-weather-particle rpg-clear-dust-mote';
particle.style.left = `${Math.random() * 100}vw`;
particle.style.top = `${Math.random() * 100}dvh`;
particle.style.animationDelay = `${Math.random() * 15}s`;
particle.style.animationDuration = `${12 + Math.random() * 8}s`;
const size = 2 + Math.random() * 3;
particle.style.width = `${size}px`;
particle.style.height = `${size}px`;
container.appendChild(particle);
}
return container;
}
/**
* Create sunset effect (dusk - warm red/purple sky gradient with low sun)
*/
function createSunset(hour) {
const container = document.createElement('div');
container.className = 'rpg-weather-particles rpg-sunset-weather';
// Create sunset gradient overlay
const sunsetOverlay = document.createElement('div');
sunsetOverlay.className = 'rpg-weather-particle rpg-sunset-overlay';
container.appendChild(sunsetOverlay);
// Calculate sun position (setting on right horizon)
const sunPos = calculateSunPosition(hour);
// Create the setting sun
const sun = document.createElement('div');
sun.className = 'rpg-weather-particle rpg-clear-sun rpg-sunset-sun';
sun.style.left = `${sunPos.left}vw`;
sun.style.top = `${sunPos.top}dvh`;
container.appendChild(sun);
// Create sun glow (more red during sunset)
const sunGlow = document.createElement('div');
sunGlow.className = 'rpg-weather-particle rpg-clear-sun-glow rpg-sunset-glow';
sunGlow.style.left = `${sunPos.left}vw`;
sunGlow.style.top = `${sunPos.top}dvh`;
container.appendChild(sunGlow);
// Create horizon glow
const horizonGlow = document.createElement('div');
horizonGlow.className = 'rpg-weather-particle rpg-sunset-horizon-glow';
container.appendChild(horizonGlow);
// Add some early stars (appearing at dusk)
for (let i = 0; i < 20; i++) {
const star = document.createElement('div');
star.className = 'rpg-weather-particle rpg-night-star rpg-sunset-emerging-star';
star.style.left = `${Math.random() * 100}vw`;
star.style.top = `${Math.random() * 50}dvh`;
star.style.animationDelay = `${Math.random() * 5}s`;
const size = 1 + Math.random() * 1.5;
star.style.width = `${size}px`;
star.style.height = `${size}px`;
container.appendChild(star);
}
// Add some golden/pink dust motes
for (let i = 0; i < 12; i++) {
const particle = document.createElement('div');
particle.className = 'rpg-weather-particle rpg-clear-dust-mote rpg-sunset-dust';
particle.style.left = `${Math.random() * 100}vw`;
particle.style.top = `${Math.random() * 100}dvh`;
particle.style.animationDelay = `${Math.random() * 15}s`;
particle.style.animationDuration = `${12 + Math.random() * 8}s`;
const size = 2 + Math.random() * 3;
particle.style.width = `${size}px`;
particle.style.height = `${size}px`;
container.appendChild(particle);
}
return container;
}
/**
* Create clear nighttime weather effect with moon, stars, and fireflies
*/
function createNighttime(hour) {
const container = document.createElement('div');
container.className = 'rpg-weather-particles rpg-night-weather';
// Create dark blue ambient overlay
const nightOverlay = document.createElement('div');
nightOverlay.className = 'rpg-weather-particle rpg-night-overlay';
container.appendChild(nightOverlay);
// Calculate moon position based on hour
const moonPos = calculateMoonPosition(hour);
// Create the moon
const moon = document.createElement('div');
moon.className = 'rpg-weather-particle rpg-night-moon';
moon.style.left = `${moonPos.left}vw`;
moon.style.top = `${moonPos.top}dvh`;
container.appendChild(moon);
// Create moon glow
const moonGlow = document.createElement('div');
moonGlow.className = 'rpg-weather-particle rpg-night-moon-glow';
moonGlow.style.left = `${moonPos.left - 3}vw`;
moonGlow.style.top = `${moonPos.top - 3}dvh`;
container.appendChild(moonGlow);
// Create twinkling stars
for (let i = 0; i < 60; i++) {
const star = document.createElement('div');
star.className = 'rpg-weather-particle rpg-night-star';
star.style.left = `${Math.random() * 100}vw`;
star.style.top = `${Math.random() * 60}dvh`; // Stars mostly in upper portion
star.style.animationDelay = `${Math.random() * 5}s`;
star.style.animationDuration = `${2 + Math.random() * 3}s`;
// Vary the size
const size = 1 + Math.random() * 2;
star.style.width = `${size}px`;
star.style.height = `${size}px`;
container.appendChild(star);
}
// Create a few brighter stars
for (let i = 0; i < 8; i++) {
const brightStar = document.createElement('div');
brightStar.className = 'rpg-weather-particle rpg-night-star rpg-night-star-bright';
brightStar.style.left = `${Math.random() * 100}vw`;
brightStar.style.top = `${Math.random() * 50}dvh`;
brightStar.style.animationDelay = `${Math.random() * 4}s`;
brightStar.style.animationDuration = `${3 + Math.random() * 2}s`;
container.appendChild(brightStar);
}
// Create fireflies / floating light particles
for (let i = 0; i < 15; i++) {
const firefly = document.createElement('div');
firefly.className = 'rpg-weather-particle rpg-night-firefly';
firefly.style.left = `${Math.random() * 100}vw`;
firefly.style.top = `${40 + Math.random() * 55}dvh`; // Fireflies in lower portion
firefly.style.animationDelay = `${Math.random() * 10}s`;
firefly.style.animationDuration = `${8 + Math.random() * 7}s`;
container.appendChild(firefly);
}
// Create subtle shooting star occasionally
const shootingStar = document.createElement('div');
shootingStar.className = 'rpg-weather-particle rpg-night-shooting-star';
container.appendChild(shootingStar);
return container;
}
/**
* Create lightning flash effect
*/
function createLightning() {
const container = document.createElement('div');
container.className = 'rpg-weather-particles';
// Create lightning flash overlay
const flash = document.createElement('div');
flash.className = 'rpg-weather-particle rpg-lightning';
container.appendChild(flash);
return container;
}
/**
* Create wind effect
*/
function createWind() {
const container = document.createElement('div');
container.className = 'rpg-weather-particles';
// Create 30 wind streaks
for (let i = 0; i < 30; i++) {
const streak = document.createElement('div');
streak.className = 'rpg-weather-particle rpg-wind-streak';
streak.style.top = `${Math.random() * 100}%`;
streak.style.animationDelay = `${Math.random() * 5}s`;
streak.style.animationDuration = `${1.5 + Math.random() * 1}s`;
container.appendChild(streak);
}
return container;
}
/**
* Calculate moon position based on hour (arc across sky at night)
* Returns { left: vw%, top: dvh% }
*/
function calculateMoonPosition(hour) {
// Nighttime is roughly 8 PM to 5 AM (20-5)
// Map hour to position along an arc
// 8 PM = far left, low | midnight = center-left, high | 5 AM = far right, low
if (hour === null) hour = 0; // Default to midnight if unknown
// Normalize night hours to 0-1 range
// 20 (8 PM) = 0, 0 (midnight) = ~0.44, 5 (5 AM) = 1
let progress;
if (hour >= 20) {
// 8 PM to midnight: 20-24 maps to 0-0.44
progress = (hour - 20) / 9;
} else {
// Midnight to 5 AM: 0-5 maps to 0.44-1
progress = (hour + 4) / 9;
}
// Horizontal position: 10% to 80% (left to right)
const left = 10 + progress * 70;
// Vertical position: parabolic arc (high at ~2 AM, low at dusk/dawn)
// Peak should be around progress 0.67 (~2 AM)
const peakProgress = 0.5;
const normalizedProgress = (progress - peakProgress) * 2; // -1 to 1
const top = 8 + 25 * (normalizedProgress * normalizedProgress);
return { left, top };
}
/**
* Update sun/moon position without recreating the whole effect
*/
function updateCelestialPosition(hour) {
if (!weatherContainer) return false;
// Update sun position if it exists
const sun = weatherContainer.querySelector('.rpg-clear-sun');
const sunGlow = weatherContainer.querySelector('.rpg-clear-sun-glow');
if (sun && sunGlow) {
const sunPos = calculateSunPosition(hour);
sun.style.left = `${sunPos.left}vw`;
sun.style.top = `${sunPos.top}dvh`;
sunGlow.style.left = `${sunPos.left}vw`;
sunGlow.style.top = `${sunPos.top}dvh`;
return true;
}
// Update moon position if it exists
const moon = weatherContainer.querySelector('.rpg-night-moon');
const moonGlow = weatherContainer.querySelector('.rpg-night-moon-glow');
if (moon && moonGlow) {
const moonPos = calculateMoonPosition(hour);
moon.style.left = `${moonPos.left}vw`;
moon.style.top = `${moonPos.top}dvh`;
moonGlow.style.left = `${moonPos.left - 3}vw`;
moonGlow.style.top = `${moonPos.top - 3}dvh`;
return true;
}
return false;
}
/**
* Remove current weather effect
*/
function removeWeatherEffect() {
if (weatherContainer) {
weatherContainer.remove();
weatherContainer = null;
currentWeatherType = null;
currentTimeOfDay = null;
currentHour = null;
}
}
/**
* Update weather effect based on current weather and time
*/
export function updateWeatherEffect() {
// Check if dynamic weather is enabled
if (!extensionSettings.enableDynamicWeather) {
removeWeatherEffect();
return;
}
const weather = getCurrentWeather();
const weatherType = parseWeatherType(weather);
// Get current time of day
const timeStr = getCurrentTime();
const hour = parseHourFromTime(timeStr);
const timeOfDay = getTimeOfDay(hour);
// If only the hour changed (same weather and time of day), just update celestial position
if (weatherType === currentWeatherType && timeOfDay === currentTimeOfDay && hour !== currentHour) {
if (updateCelestialPosition(hour)) {
currentHour = hour;
return; // Successfully updated position without recreating
}
}
// Don't recreate if nothing has changed
if (weatherType === currentWeatherType && timeOfDay === currentTimeOfDay && hour === currentHour) {
return;
}
// Remove existing effect
removeWeatherEffect();
// Create new effect based on weather type
if (weatherType === 'none') {
return; // No effect
}
currentWeatherType = weatherType;
currentTimeOfDay = timeOfDay;
currentHour = hour;
switch (weatherType) {
case 'snow':
weatherContainer = createSnowflakes();
break;
case 'rain':
weatherContainer = createRain();
break;
case 'mist':
weatherContainer = createMist();
break;
case 'sunny':
// Use appropriate effect based on time of day
if (timeOfDay === 'night') {
weatherContainer = createNighttime(hour);
} else if (timeOfDay === 'dawn') {
weatherContainer = createSunrise(hour);
} else if (timeOfDay === 'dusk') {
weatherContainer = createSunset(hour);
} else {
weatherContainer = createSunshine(hour);
}
break;
case 'wind':
weatherContainer = createWind();
break;
case 'storm': {
// Storm = Rain + Lightning (combined effects)
const rainContainer = createRain();
const lightningContainer = createLightning();
// Merge both containers
weatherContainer = document.createElement('div');
weatherContainer.className = 'rpg-weather-particles';
weatherContainer.appendChild(rainContainer);
weatherContainer.appendChild(lightningContainer);
break;
}
case 'blizzard': {
// Blizzard = Snow + Wind (combined effects)
const snowContainer = createSnowflakes();
const windContainer = createWind();
// Merge both containers
weatherContainer = document.createElement('div');
weatherContainer.className = 'rpg-weather-particles';
weatherContainer.appendChild(snowContainer);
weatherContainer.appendChild(windContainer);
break;
}
}
if (weatherContainer) {
// Apply z-index based on background/foreground settings
if (extensionSettings.weatherForeground) {
weatherContainer.style.zIndex = '9998'; // In front of chat
weatherContainer.classList.add('rpg-weather-foreground');
} else if (extensionSettings.weatherBackground) {
weatherContainer.style.zIndex = '1'; // Behind chat (default)
weatherContainer.classList.remove('rpg-weather-foreground');
} else {
// Both disabled - don't show weather
return;
}
document.body.appendChild(weatherContainer);
}
}
/**
* Initialize weather effects
*/
export function initWeatherEffects() {
updateWeatherEffect();
}
/**
* Toggle dynamic weather effects
*/
export function toggleDynamicWeather(enabled) {
if (enabled) {
updateWeatherEffect();
} else {
removeWeatherEffect();
}
}
/**
* Clean up weather effects
*/
export function cleanupWeatherEffects() {
removeWeatherEffect();
}
+1
View File
@@ -8,6 +8,7 @@
* @typedef {Object} InventoryV2
* @property {number} version - Schema version (always 2)
* @property {string} onPerson - Items currently carried/worn by the character (plaintext list)
* @property {string} clothing - Clothing and armor currently worn (plaintext list)
* @property {Object.<string, string>} stored - Items stored at named locations (location name plaintext list)
* @property {string} assets - Character's vehicles, property, and major possessions (plaintext list)
*/
+53
View File
@@ -0,0 +1,53 @@
/**
* Image URL Utilities Module
* Centralizes validation for image sources captured from DOM or settings.
*/
const DEFAULT_IMAGE_BASE_URL = typeof window !== 'undefined'
? window.location.href
: 'http://localhost/';
export function normalizeImageSrc(src) {
return String(src ?? '').trim();
}
export function resolveImageUrl(src, baseUrl = DEFAULT_IMAGE_BASE_URL) {
const normalized = normalizeImageSrc(src);
if (!normalized) {
return null;
}
try {
return new URL(normalized, baseUrl);
} catch {
return null;
}
}
export function isSafeImageSrc(src) {
const normalized = normalizeImageSrc(src);
if (!normalized) {
return false;
}
const candidate = resolveImageUrl(normalized);
if (!candidate) {
return false;
}
const protocol = candidate.protocol.toLowerCase();
if (protocol === 'http:' || protocol === 'https:' || protocol === 'blob:') {
return true;
}
if (protocol === 'data:') {
return normalized.toLowerCase().startsWith('data:image/');
}
return false;
}
export function getSafeImageSrc(src) {
const normalized = normalizeImageSrc(src);
return isSafeImageSrc(normalized) ? normalized : null;
}
+17 -2
View File
@@ -17,6 +17,7 @@ import { sanitizeItemName, MAX_ITEMS_PER_SECTION } from './security.js';
* - Strips list markers: -, , 1., 2., etc.
* - Collapses newlines inside parentheses to spaces
* - Only splits on commas OUTSIDE parentheses (preserves commas in descriptions)
* - Preserves commas in numbers (decimal separators like 4443,445)
* - Gracefully handles unmatched parentheses
*
* @param {string} itemString - Item string from AI (various formats supported)
@@ -35,6 +36,10 @@ import { sanitizeItemName, MAX_ITEMS_PER_SECTION } from './security.js';
* parseItems("Potato (Cursed, Sexy, Your Mum & Dick, Etc), Sword")
* // → ["Potato (Cursed, Sexy, Your Mum & Dick, Etc)", "Sword"]
*
* // Decimal commas in numbers (preserved)
* parseItems("4443,445 gold coins, Sword, 1,234,567 credits")
* // → ["4443,445 gold coins", "Sword", "1,234,567 credits"]
*
* // Markdown formatting (stripped)
* parseItems("**Sword** (equipped), *Shield*") // ["Sword (equipped)", "Shield"]
*
@@ -125,7 +130,7 @@ export function parseItems(itemString) {
// STEP 5: Normalize whitespace
processed = processed.replace(/\s+/g, ' ');
// STEP 6: Smart comma splitting (only split on commas OUTSIDE parentheses)
// STEP 6: Smart comma splitting (only split on commas OUTSIDE parentheses and NOT in numbers)
// Also handles list markers, quotes, and security validation per-item
const items = [];
let currentItem = '';
@@ -146,7 +151,16 @@ export function parseItems(itemString) {
}
currentItem += char;
} else if (char === ',' && parenDepth === 0) {
// Comma outside parentheses - this is a separator
// Check if this comma is between digits (decimal separator like 4443,445)
const prevChar = i > 0 ? processed[i - 1] : '';
const nextChar = i < processed.length - 1 ? processed[i + 1] : '';
const isDecimalComma = /\d/.test(prevChar) && /\d/.test(nextChar);
if (isDecimalComma) {
// This is a decimal comma, not a separator - keep it
currentItem += char;
} else {
// Comma outside parentheses and not in a number - this is a separator
const cleaned = cleanSingleItem(currentItem);
if (cleaned) {
// Security check: validate and sanitize item name
@@ -162,6 +176,7 @@ export function parseItems(itemString) {
}
}
currentItem = ''; // Start new item
}
} else {
currentItem += char;
}
+433
View File
@@ -0,0 +1,433 @@
/**
* JSON Migration Module
* Migrates committed tracker data from v2 text format to v3 JSON format
*/
import { committedTrackerData, extensionSettings, updateCommittedTrackerData, updateExtensionSettings } from '../core/state.js';
import { saveSettings, saveChatData } from '../core/persistence.js';
/**
* Helper to separate emoji from text in a string
* @param {string} str - String potentially containing emoji followed by text
* @returns {{emoji: string, text: string}} Separated emoji and text
*/
function separateEmojiFromText(str) {
if (!str) return { emoji: '', text: '' };
str = str.trim();
// Regex to match emoji at the start
const emojiRegex = /^[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F000}-\u{1F02F}\u{1F0A0}-\u{1F0FF}\u{1F100}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F910}-\u{1F96B}\u{1F980}-\u{1F9E0}\u{FE00}-\u{FE0F}\u{200D}\u{20E3}]+/u;
const emojiMatch = str.match(emojiRegex);
if (emojiMatch) {
const emoji = emojiMatch[0];
let text = str.substring(emoji.length).trim();
// Remove leading comma or space
text = text.replace(/^[,\s]+/, '');
return { emoji, text };
}
// Check if there's a comma separator anyway
const commaParts = str.split(',');
if (commaParts.length >= 2) {
return {
emoji: commaParts[0].trim(),
text: commaParts.slice(1).join(',').trim()
};
}
// No clear separation - return original as text
return { emoji: '', text: str };
}
/**
* Parses item text to JSON format
* Handles "3x Item Name" or "Item Name" formats
* @param {string} itemsText - Comma-separated items string
* @returns {Array<{name: string, quantity?: number}>} Array of item objects
*/
function parseItemsToJSON(itemsText) {
if (!itemsText || itemsText.trim() === '' || itemsText.toLowerCase() === 'none') {
return [];
}
const items = itemsText.split(',').map(s => s.trim()).filter(s => s);
return items.map(item => {
// Parse "3x Health Potion" format
const qtyMatch = item.match(/^(\d+)x\s*(.+)/i);
if (qtyMatch) {
return {
name: qtyMatch[2].trim(),
quantity: parseInt(qtyMatch[1])
};
}
return { name: item, quantity: 1 };
});
}
/**
* Migrates User Stats from v2 text format to v3 JSON format
* @param {string} textData - V2 text format user stats
* @returns {object} V3 JSON format user stats
*/
export function migrateUserStatsToJSON(textData) {
if (!textData || typeof textData !== 'string') {
return null;
}
const lines = textData.split('\n');
const result = {
version: 3,
stats: [],
status: {},
skills: [],
inventory: {
onPerson: [],
clothing: [],
stored: {},
assets: []
},
quests: {
main: null,
optional: []
}
};
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed === '---' || trimmed.startsWith('```')) continue;
// Parse "- StatName: X%" format
const statMatch = trimmed.match(/^-\s*([^:]+):\s*(\d+)%/);
if (statMatch) {
const name = statMatch[1].trim();
const id = name.toLowerCase().replace(/\s+/g, '_').replace(/[^\p{L}\p{N}_]/gu, '');
result.stats.push({
id: id,
name: name,
value: parseInt(statMatch[2])
});
continue;
}
// Parse "Status: emoji, text" or "Status: text" format
const statusMatch = trimmed.match(/^Status:\s*(.+)/i);
if (statusMatch) {
const { emoji, text } = separateEmojiFromText(statusMatch[1]);
if (emoji) result.status.mood = emoji;
if (text) result.status.conditions = text;
continue;
}
// Parse "Skills: skill1, skill2" format
const skillsMatch = trimmed.match(/^Skills:\s*(.+)/i);
if (skillsMatch) {
const skillsText = skillsMatch[1].trim();
if (skillsText && skillsText.toLowerCase() !== 'none') {
const skills = skillsText.split(',').map(s => s.trim()).filter(s => s);
result.skills = skills.map(name => ({ name }));
}
continue;
}
// Parse inventory lines
const onPersonMatch = trimmed.match(/^On Person:\s*(.+)/i);
if (onPersonMatch) {
result.inventory.onPerson = parseItemsToJSON(onPersonMatch[1]);
continue;
}
const clothingMatch = trimmed.match(/^Clothing:\s*(.+)/i);
if (clothingMatch) {
result.inventory.clothing = parseItemsToJSON(clothingMatch[1]);
continue;
}
const storedMatch = trimmed.match(/^Stored\s*-\s*([^:]+):\s*(.+)/i);
if (storedMatch) {
const location = storedMatch[1].trim();
result.inventory.stored[location] = parseItemsToJSON(storedMatch[2]);
continue;
}
const assetsMatch = trimmed.match(/^Assets:\s*(.+)/i);
if (assetsMatch) {
const assetsText = assetsMatch[1].trim();
if (assetsText && assetsText.toLowerCase() !== 'none') {
result.inventory.assets = assetsText.split(',').map(s => s.trim()).filter(s => s).map(name => ({ name }));
}
continue;
}
// Parse quest lines
const mainQuestMatch = trimmed.match(/^Main Quests?:\s*(.+)/i);
if (mainQuestMatch) {
const questText = mainQuestMatch[1].trim();
if (questText && questText.toLowerCase() !== 'none') {
result.quests.main = { title: questText };
}
continue;
}
const optionalQuestsMatch = trimmed.match(/^Optional Quests?:\s*(.+)/i);
if (optionalQuestsMatch) {
const questsText = optionalQuestsMatch[1].trim();
if (questsText && questsText.toLowerCase() !== 'none') {
const quests = questsText.split(',').map(s => s.trim()).filter(s => s);
result.quests.optional = quests.map(title => ({ title }));
}
continue;
}
}
return result;
}
/**
* Migrates Info Box from v2 text format to v3 JSON format
* @param {string} textData - V2 text format info box
* @returns {object} V3 JSON format info box
*/
export function migrateInfoBoxToJSON(textData) {
if (!textData || typeof textData !== 'string') {
return null;
}
const lines = textData.split('\n');
const result = {
version: 3
};
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed === '---' || trimmed.startsWith('```') || trimmed.toLowerCase() === 'info box') continue;
// Parse "Date: value" format
const dateMatch = trimmed.match(/^Date:\s*(.+)/i);
if (dateMatch) {
result.date = { value: dateMatch[1].trim() };
continue;
}
// Parse "Weather: emoji, text" or "Weather: text" format
const weatherMatch = trimmed.match(/^Weather:\s*(.+)/i);
if (weatherMatch) {
const { emoji, text } = separateEmojiFromText(weatherMatch[1]);
result.weather = {
emoji: emoji || '',
forecast: text || weatherMatch[1].trim()
};
continue;
}
// Parse "Temperature: X°C" or "Temperature: X°F" format
const tempMatch = trimmed.match(/^Temperature:\s*(\d+)\s*°?([CF])?/i);
if (tempMatch) {
result.temperature = {
value: parseInt(tempMatch[1]),
unit: tempMatch[2] ? tempMatch[2].toUpperCase() : 'C'
};
continue;
}
// Parse "Time: start → end" format
const timeMatch = trimmed.match(/^Time:\s*(.+?)\s*→\s*(.+)/i);
if (timeMatch) {
result.time = {
start: timeMatch[1].trim(),
end: timeMatch[2].trim()
};
continue;
}
// Parse "Location: value" format
const locationMatch = trimmed.match(/^Location:\s*(.+)/i);
if (locationMatch) {
result.location = { value: locationMatch[1].trim() };
continue;
}
// Parse "Recent Events: event1, event2, event3" format
const eventsMatch = trimmed.match(/^Recent Events:\s*(.+)/i);
if (eventsMatch) {
const eventsText = eventsMatch[1].trim();
if (eventsText && eventsText.toLowerCase() !== 'none') {
result.recentEvents = eventsText.split(',').map(s => s.trim()).filter(s => s);
}
continue;
}
}
return result;
}
/**
* Migrates Present Characters from v2 text format to v3 JSON format
* @param {string} textData - V2 text format present characters
* @returns {object} V3 JSON format present characters
*/
export function migrateCharactersToJSON(textData) {
if (!textData || typeof textData !== 'string') {
return null;
}
const result = {
version: 3,
characters: []
};
// Split by character blocks (marked by "- Name")
const blocks = ('\n' + textData).split(/\n-\s+/);
for (const block of blocks) {
if (!block.trim()) continue;
const lines = block.trim().split('\n');
if (lines.length === 0) continue;
const character = {
name: lines[0].trim()
};
// Parse subsequent lines for this character
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// Parse "Details: emoji | field1 | field2" format
const detailsMatch = line.match(/^Details:\s*(.+)/i);
if (detailsMatch) {
const detailsText = detailsMatch[1].trim();
const parts = detailsText.split('|').map(s => s.trim());
const { emoji } = separateEmojiFromText(parts[0] || '');
if (emoji) character.emoji = emoji;
character.details = {};
for (let j = 1; j < parts.length; j++) {
const fieldName = `field${j}`;
character.details[fieldName] = parts[j];
}
continue;
}
// Parse "Relationship: status" format
const relationshipMatch = line.match(/^Relationship:\s*(.+)/i);
if (relationshipMatch) {
character.relationship = { status: relationshipMatch[1].trim() };
continue;
}
// Parse "Stats: stat1: X% | stat2: Y%" format
const statsMatch = line.match(/^Stats:\s*(.+)/i);
if (statsMatch) {
const statsText = statsMatch[1].trim();
const statParts = statsText.split('|').map(s => s.trim());
character.stats = [];
for (const statPart of statParts) {
const statValueMatch = statPart.match(/^([^:]+):\s*(\d+)%/);
if (statValueMatch) {
character.stats.push({
name: statValueMatch[1].trim(),
value: parseInt(statValueMatch[2])
});
}
}
continue;
}
// Parse "Thoughts: content" format
const thoughtsMatch = line.match(/^Thoughts:\s*(.+)/i);
if (thoughtsMatch) {
character.thoughts = { content: thoughtsMatch[1].trim() };
continue;
}
}
result.characters.push(character);
}
return result;
}
/**
* Main migration function - migrates all committed tracker data to v3 JSON format
* @returns {Promise<void>}
*/
export async function migrateToV3JSON() {
// console.log('[RPG Migration] Starting migration to v3 JSON format...');
const migrated = {
userStats: null,
infoBox: null,
characterThoughts: null
};
// Migrate User Stats
if (committedTrackerData.userStats && typeof committedTrackerData.userStats === 'string') {
// console.log('[RPG Migration] Migrating User Stats...');
migrated.userStats = migrateUserStatsToJSON(committedTrackerData.userStats);
if (migrated.userStats) {
// console.log('[RPG Migration] ✓ User Stats migrated');
}
}
// Migrate Info Box
if (committedTrackerData.infoBox && typeof committedTrackerData.infoBox === 'string') {
// console.log('[RPG Migration] Migrating Info Box...');
migrated.infoBox = migrateInfoBoxToJSON(committedTrackerData.infoBox);
if (migrated.infoBox) {
// console.log('[RPG Migration] ✓ Info Box migrated');
}
}
// Migrate Present Characters
if (committedTrackerData.characterThoughts && typeof committedTrackerData.characterThoughts === 'string') {
// console.log('[RPG Migration] Migrating Present Characters...');
migrated.characterThoughts = migrateCharactersToJSON(committedTrackerData.characterThoughts);
if (migrated.characterThoughts) {
// console.log('[RPG Migration] ✓ Present Characters migrated');
}
}
// Update committed data
updateCommittedTrackerData(migrated);
// Initialize lockedItems if not present
if (!extensionSettings.lockedItems) {
// console.log('[RPG Migration] Initializing lockedItems structure...');
updateExtensionSettings({
lockedItems: {
stats: [],
skills: [],
inventory: {
onPerson: [],
clothing: [],
stored: {},
assets: []
},
quests: {
main: false,
optional: []
},
infoBox: {
date: false,
weather: false,
temperature: false,
time: false,
location: false,
recentEvents: false
},
characters: {}
}
});
}
// Save migrated data
await saveChatData();
await saveSettings();
// console.log('[RPG Migration] ✅ Migration to v3 JSON format complete');
}
+225
View File
@@ -0,0 +1,225 @@
/**
* JSON Repair Utilities
* Handles parsing and repairing malformed JSON from AI responses
*/
/**
* Repairs malformed JSON from AI responses
* Handles common AI mistakes like trailing commas, missing commas, wrong quotes, etc.
*
* @param {string} jsonString - Potentially malformed JSON string
* @returns {object|null} Repaired JSON object or null if repair fails
*/
export function repairJSON(jsonString) {
if (typeof jsonString !== 'string') {
console.warn('[RPG JSON Repair] Invalid input type:', typeof jsonString);
return null;
}
let cleaned = jsonString.trim();
if (!cleaned) {
return null;
}
// Remove markdown code fences
cleaned = cleaned.replace(/```json\s*/gi, '');
cleaned = cleaned.replace(/```\s*/g, '');
// Remove thinking tags (model's internal reasoning)
cleaned = cleaned.replace(/<think>[\s\S]*?<\/think>/gi, '');
cleaned = cleaned.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');
// Fix common JSON errors:
// 1. Trailing commas before closing brackets
cleaned = cleaned.replace(/,(\s*[}\]])/g, '$1');
// 2. Missing commas between properties - DISABLED because it corrupts valid JSON
// Modern AI models send properly formatted JSON, so this aggressive repair is not needed
// cleaned = cleaned.replace(/("\s*:\s*(?:"[^"]*"|[^,}\]]+))(\s+")/g, '$1,$2');
// 3. Single quotes to double quotes - DISABLED because it corrupts apostrophes in text
// Apostrophes in strings like "Zandik's Office" would become "Zandik"s Office" (invalid JSON)
// Modern AI models already use double quotes for JSON strings
// cleaned = cleaned.replace(/'/g, '"');
// 4. Unquoted keys - DISABLED because it corrupts valid JSON string values
// The AI models already send properly quoted JSON, so this is not needed
// cleaned = cleaned.replace(/(\{|,)\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":');
// 5. Remove JavaScript comments
cleaned = cleaned.replace(/\/\/.*$/gm, '');
cleaned = cleaned.replace(/\/\*[\s\S]*?\*\//g, '');
// Attempt 1: Standard JSON.parse
try {
return JSON.parse(cleaned);
} catch (e) {
}
// Attempt 2: Extract JSON object between first { and last }
const objectMatch = cleaned.match(/\{[\s\S]*\}/);
if (objectMatch) {
try {
return JSON.parse(objectMatch[0]);
} catch (e) {
// Silent fail, try next method
}
}
// Attempt 3: Try to extract JSON array between first [ and last ]
const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
if (arrayMatch) {
try {
return JSON.parse(arrayMatch[0]);
} catch (e) {
// Silent fail, try next method
}
}
// Attempt 4: Use Function constructor (safer than eval, still controlled)
// Only as last resort for trusted AI output
try {
const fn = new Function(`"use strict"; return (${cleaned});`);
const result = fn();
// Validate it's actually an object or array
if (result && (typeof result === 'object')) {
// console.log('[RPG JSON Repair] ✓ Repaired using Function constructor');
return result;
}
} catch (e) {
console.error('[RPG JSON Repair] ✗ All repair attempts failed:', e.message);
}
return null;
}
/**
* Validates JSON structure matches expected schema for a tracker type
*
* @param {object} data - Parsed JSON data to validate
* @param {string} type - Type of tracker ('userStats', 'infoBox', 'characters')
* @returns {boolean} True if valid, false otherwise
*/
export function validateJSONSchema(data, type) {
if (!data || typeof data !== 'object') {
return false;
}
try {
switch (type) {
case 'userStats':
return Array.isArray(data.stats) &&
data.stats.every(s =>
s &&
typeof s === 'object' &&
s.id &&
s.name &&
typeof s.value === 'number'
);
case 'infoBox':
return (data.date || data.weather || data.time || data.location || data.temperature || data.recentEvents);
case 'characters':
return Array.isArray(data.characters) &&
data.characters.every(c => c && c.name);
default:
console.warn('[RPG JSON Validation] Unknown tracker type:', type);
return false;
}
} catch (e) {
console.error('[RPG JSON Validation] Error during validation:', e);
return false;
}
}
/**
* Extracts JSON from text that may contain other content
* Looks for JSON blocks within ```json fences or standalone JSON objects
*
* @param {string} text - Text potentially containing JSON
* @returns {string|null} Extracted JSON string or null
*/
export function extractJSONFromText(text) {
if (!text || typeof text !== 'string') {
return null;
}
// Try to extract from ```json code fence
const fenceMatch = text.match(/```json\s*([\s\S]*?)```/i);
if (fenceMatch && fenceMatch[1]) {
const trimmed = fenceMatch[1].trim();
if (trimmed) return trimmed;
}
// Try to extract from ``` code fence (without json label)
const genericFenceMatch = text.match(/```\s*([\s\S]*?)```/);
if (genericFenceMatch && genericFenceMatch[1]) {
const content = genericFenceMatch[1].trim();
// Check if it looks like JSON (starts with { or [)
if (content && (content.startsWith('{') || content.startsWith('['))) {
return content;
}
}
// Try to find standalone JSON object
const objectMatch = text.match(/\{[\s\S]*\}/);
if (objectMatch && objectMatch[0].trim()) {
return objectMatch[0];
}
// Try to find standalone JSON array
const arrayMatch = text.match(/\[[\s\S]*\]/);
if (arrayMatch && arrayMatch[0].trim()) {
return arrayMatch[0];
}
return null;
}
/**
* Safely parses JSON with automatic repair attempts
* Combines extraction, repair, and validation in one call
*
* @param {string} text - Text containing JSON (with or without code fences)
* @param {string} expectedType - Expected tracker type for validation ('userStats', 'infoBox', 'characters')
* @returns {{data: object|null, success: boolean, error: string|null}} Result object
*/
export function safeParseJSON(text, expectedType = null) {
const result = {
data: null,
success: false,
error: null
};
// Extract JSON from text
const jsonString = extractJSONFromText(text);
if (!jsonString) {
result.error = 'No JSON found in text';
return result;
}
// Attempt to repair and parse
const parsed = repairJSON(jsonString);
if (!parsed) {
result.error = 'Failed to parse JSON after repair attempts';
return result;
}
// Validate schema if type specified
if (expectedType) {
const valid = validateJSONSchema(parsed, expectedType);
if (!valid) {
result.error = `JSON does not match expected schema for type: ${expectedType}`;
result.data = parsed; // Return data anyway, might be partially useful
return result;
}
}
result.data = parsed;
result.success = true;
return result;
}
+33 -3
View File
@@ -15,6 +15,7 @@
const DEFAULT_INVENTORY_V2 = {
version: 2,
onPerson: "None",
clothing: "None",
stored: {},
assets: "None"
};
@@ -27,8 +28,36 @@ const DEFAULT_INVENTORY_V2 = {
* @returns {MigrationResult} Migration result with v2 inventory and metadata
*/
export function migrateInventory(inventory) {
// Case 1: Already v2 format (has version property and is an object)
// Case 1: v2 format missing version property (parser output)
// Parser returns v2 structure but without the version tag
if (inventory && typeof inventory === 'object' &&
'onPerson' in inventory && 'clothing' in inventory &&
'stored' in inventory && 'assets' in inventory &&
!('version' in inventory)) {
// console.log('[RPG Companion Migration] v2 inventory missing version tag, adding it');
return {
inventory: {
version: 2,
...inventory
},
migrated: true,
source: 'parser-output'
};
}
// Case 2: Already v2 format (has version property and is an object)
if (inventory && typeof inventory === 'object' && inventory.version === 2) {
// Check if clothing field exists (v2.1 upgrade)
if (!inventory.hasOwnProperty('clothing')) {
// console.log('[RPG Companion Migration] Upgrading v2 inventory to v2.1 (adding clothing field)');
inventory.clothing = "None";
return {
inventory: inventory,
migrated: true,
source: 'v2-upgrade'
};
}
// console.log('[RPG Companion Migration] Inventory already v2, no migration needed');
return {
inventory: inventory,
@@ -37,7 +66,7 @@ export function migrateInventory(inventory) {
};
}
// Case 2: null or undefined → use defaults
// Case 3: null or undefined → use defaults
if (inventory === null || inventory === undefined) {
// console.log('[RPG Companion Migration] Inventory is null/undefined, using defaults');
return {
@@ -47,7 +76,7 @@ export function migrateInventory(inventory) {
};
}
// Case 3: v1 string format → migrate to v2
// Case 4: v1 string format → migrate to v2
if (typeof inventory === 'string') {
// Check if it's an empty/default string
const trimmed = inventory.trim();
@@ -66,6 +95,7 @@ export function migrateInventory(inventory) {
inventory: {
version: 2,
onPerson: inventory,
clothing: "None",
stored: {},
assets: "None"
},
+244
View File
@@ -0,0 +1,244 @@
import { this_chid, characters } from '../../../../../../script.js';
import { selected_group, getGroupMembers } from '../../../../../group-chats.js';
import {
extensionSettings,
lastGeneratedData,
committedTrackerData,
FALLBACK_AVATAR_DATA_URI
} from '../core/state.js';
import { getSafeThumbnailUrl } from './avatars.js';
export function stripBrackets(value) {
if (typeof value !== 'string') return value;
return value.replace(/^\[|\]$/g, '').trim();
}
export function extractFieldValue(fieldValue) {
if (fieldValue && typeof fieldValue === 'object' && 'value' in fieldValue) {
return fieldValue.value || '';
}
return fieldValue || '';
}
export function toSnakeCase(name) {
return name
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '_')
.replace(/^_+|_+$/g, '');
}
export function namesMatch(cardName, aiName) {
if (!cardName || !aiName) return false;
if (cardName.toLowerCase() === aiName.toLowerCase()) return true;
const stripParens = (s) => s.replace(/\s*\([^)]*\)/g, '').trim();
const cardCore = stripParens(cardName).toLowerCase();
const aiCore = stripParens(aiName).toLowerCase();
if (cardCore === aiCore) return true;
const escapedCardCore = cardCore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const wordBoundary = new RegExp(`\\b${escapedCardCore}\\b`);
return wordBoundary.test(aiCore);
}
export function isPresentCharactersEnabled() {
return !!(
extensionSettings.showCharacterThoughts
|| extensionSettings.showAlternatePresentCharactersPanel
|| extensionSettings.showThoughtsInChat
);
}
export function getPresentCharactersTrackerData({ useCommittedFallback = true } = {}) {
return lastGeneratedData.characterThoughts || (useCommittedFallback ? committedTrackerData.characterThoughts : null) || '';
}
export function parsePresentCharacters(characterThoughtsData, { enabledFields = [], enabledCharStats = [] } = {}) {
if (!characterThoughtsData) {
return [];
}
let presentCharacters = [];
try {
const parsed = typeof characterThoughtsData === 'string'
? JSON.parse(characterThoughtsData)
: characterThoughtsData;
const charactersArray = Array.isArray(parsed) ? parsed : (parsed.characters || []);
if (charactersArray.length > 0) {
presentCharacters = charactersArray.map(char => {
const character = {
name: char.name,
emoji: char.emoji || '👤'
};
if (char.details) {
for (const field of enabledFields) {
if (char.details[field.name] !== undefined) {
character[field.name] = stripBrackets(char.details[field.name]);
} else {
const fieldKey = toSnakeCase(field.name);
if (char.details[fieldKey] !== undefined) {
character[field.name] = stripBrackets(char.details[fieldKey]);
}
}
}
}
for (const field of enabledFields) {
if (character[field.name] === undefined) {
const fieldKey = toSnakeCase(field.name);
if (char[fieldKey] !== undefined) {
character[field.name] = stripBrackets(char[fieldKey]);
}
}
}
if (char.Relationship) {
character.Relationship = stripBrackets(char.Relationship);
} else if (char.relationship) {
character.Relationship = stripBrackets(char.relationship.status || char.relationship);
}
if (char.thoughts) {
character.ThoughtsContent = stripBrackets(char.thoughts.content || char.thoughts);
}
if (char.stats && enabledCharStats.length > 0) {
if (Array.isArray(char.stats)) {
for (const statObj of char.stats) {
if (statObj.name && statObj.value !== undefined) {
const matchingStat = enabledCharStats.find(s => s.name === statObj.name);
if (matchingStat) {
character[statObj.name] = statObj.value;
}
}
}
} else {
for (const stat of enabledCharStats) {
if (char.stats[stat.name] !== undefined) {
character[stat.name] = char.stats[stat.name];
}
}
}
}
return character;
});
}
} catch {
// Fall back to the legacy text format below.
}
if (presentCharacters.length > 0 || typeof characterThoughtsData !== 'string') {
return presentCharacters;
}
const lines = characterThoughtsData.split('\n');
let currentCharacter = null;
const thoughtsLabel = extensionSettings.trackerConfig?.presentCharacters?.thoughts?.name || 'Thoughts';
for (const line of lines) {
if (!line.trim()
|| line.includes('Present Characters')
|| line.includes('---')
|| line.trim().startsWith('```')
|| line.trim() === '- …'
|| line.includes('(Repeat the format')) {
continue;
}
if (line.trim().startsWith('- ')) {
const name = line.trim().substring(2).trim();
if (name && name.toLowerCase() !== 'unavailable') {
currentCharacter = { name };
presentCharacters.push(currentCharacter);
} else {
currentCharacter = null;
}
} else if (line.trim().startsWith('Details:') && currentCharacter) {
const detailsContent = line.substring(line.indexOf(':') + 1).trim();
const parts = detailsContent.split('|').map(p => p.trim());
if (parts.length > 0) {
currentCharacter.emoji = parts[0];
}
for (let i = 0; i < enabledFields.length && i + 1 < parts.length; i++) {
currentCharacter[enabledFields[i].name] = parts[i + 1];
}
} else if (line.trim().startsWith('Relationship:') && currentCharacter) {
currentCharacter.Relationship = line.substring(line.indexOf(':') + 1).trim();
} else if (line.trim().startsWith('Stats:') && currentCharacter && enabledCharStats.length > 0) {
const statsContent = line.substring(line.indexOf(':') + 1).trim();
const statParts = statsContent.split('|').map(p => p.trim());
for (const statPart of statParts) {
const statMatch = statPart.match(/^(.+?):\s*(\d+)%$/);
if (statMatch) {
currentCharacter[statMatch[1].trim()] = parseInt(statMatch[2], 10);
}
}
} else if (line.trim().startsWith(thoughtsLabel + ':') && currentCharacter) {
currentCharacter.ThoughtsContent = line.substring(line.indexOf(':') + 1).trim();
}
}
return presentCharacters;
}
export function resolvePresentCharacterPortrait(name) {
let characterPortrait = FALLBACK_AVATAR_DATA_URI;
if (!name) {
return characterPortrait;
}
if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[name]) {
return extensionSettings.npcAvatars[name];
}
if (selected_group) {
try {
const groupMembers = getGroupMembers(selected_group);
const matchingMember = groupMembers?.find(member =>
member && member.name && namesMatch(member.name, name)
);
if (matchingMember?.avatar && matchingMember.avatar !== 'none') {
const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingMember.avatar);
if (thumbnailUrl) {
return thumbnailUrl;
}
}
} catch {
// Ignore avatar lookup issues and continue through fallback chain.
}
}
if (characters?.length > 0) {
const matchingCharacter = characters.find(character =>
character && character.name && namesMatch(character.name, name)
);
if (matchingCharacter?.avatar && matchingCharacter.avatar !== 'none') {
const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingCharacter.avatar);
if (thumbnailUrl) {
return thumbnailUrl;
}
}
}
if (this_chid !== undefined && characters?.[this_chid]?.name && namesMatch(characters[this_chid].name, name)) {
const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar);
if (thumbnailUrl) {
return thumbnailUrl;
}
}
return characterPortrait;
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Response Extractor Utility
*
* Handles extraction of text content from various API response formats.
* Fixes the "No message generated" error caused by Claude models with
* extended thinking, where the API response `content` field is an array
* of content blocks instead of a single string.
*
* Also provides a safe wrapper around SillyTavern's `generateRaw` that
* intercepts the raw fetch response as a fallback.
*/
import { generateRaw } from '../../../../../../../script.js';
/**
* Extracts text from any API response shape (Anthropic content-block arrays,
* OpenAI choices, plain strings, etc.).
*
* @param {*} response - The raw API response (string, array, or object)
* @returns {string} The extracted text content
*/
export function extractTextFromResponse(response) {
if (!response) return '';
if (typeof response === 'string') return response;
// Response itself is an array of content blocks (Anthropic extended thinking)
if (Array.isArray(response)) {
const texts = response
.filter(b => b && b.type === 'text' && typeof b.text === 'string')
.map(b => b.text);
if (texts.length > 0) return texts.join('\n');
const strings = response.filter(item => typeof item === 'string');
if (strings.length > 0) return strings.join('\n');
return JSON.stringify(response);
}
// response.content (string or Anthropic content array)
if (response.content !== undefined && response.content !== null) {
if (typeof response.content === 'string') return response.content;
if (Array.isArray(response.content)) {
const texts = response.content
.filter(b => b && b.type === 'text' && typeof b.text === 'string')
.map(b => b.text);
if (texts.length > 0) return texts.join('\n');
}
}
// OpenAI choices format
if (response.choices?.[0]?.message?.content) {
const c = response.choices[0].message.content;
if (typeof c === 'string') return c;
if (Array.isArray(c)) {
const texts = c
.filter(b => b && b.type === 'text' && typeof b.text === 'string')
.map(b => b.text);
if (texts.length > 0) return texts.join('\n');
}
}
// Other common fields
if (typeof response.text === 'string') return response.text;
if (typeof response.message === 'string') return response.message;
if (response.message?.content && typeof response.message.content === 'string') {
return response.message.content;
}
return JSON.stringify(response);
}
/**
* Safe wrapper around SillyTavern's `generateRaw`.
*
* Temporarily intercepts `window.fetch` to capture the raw API response.
* If `generateRaw` throws "No message generated" (e.g. because the first
* content block from Claude extended thinking is empty), we extract the
* real text from the captured raw data ourselves.
*
* @param {object} options - Options passed directly to `generateRaw`
* @param {Array<{role: string, content: string}>} options.prompt - Message array
* @param {boolean} [options.quietToLoud] - Whether to use quiet-to-loud mode
* @returns {Promise<string>} The generated text
*/
export async function safeGenerateRaw(options) {
let capturedRawData = null;
const originalFetch = window.fetch;
window.fetch = async function (...args) {
const response = await originalFetch.apply(this, args);
try {
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || '';
if (url.includes('/api/backends/chat-completions/generate') ||
(url.includes('/api/backends/') && url.includes('/generate'))) {
const clone = response.clone();
capturedRawData = await clone.json();
}
} catch (e) {
/* ignore clone/parse errors */
}
return response;
};
try {
const result = await generateRaw(options);
return result;
} catch (genErr) {
if (genErr.message?.includes('No message generated') && capturedRawData) {
console.warn(
'[RPG Companion] generateRaw failed (likely extended thinking). Extracting from raw API data.',
);
const extracted = extractTextFromResponse(capturedRawData);
if (!extracted || !extracted.trim()) {
throw new Error('Could not extract text from API response');
}
return extracted;
}
throw genErr; // Re-throw non-related errors
} finally {
window.fetch = originalFetch; // ALWAYS restore original fetch
}
}
+626
View File
@@ -0,0 +1,626 @@
import { Fuse } from '../../../../../../lib.js';
import {
characters,
eventSource,
event_types,
generateQuietPrompt,
generateRaw,
getRequestHeaders,
online_status,
substituteParams,
substituteParamsExtended,
this_chid
} from '../../../../../../script.js';
import {
doExtrasFetch,
extension_settings as stExtensionSettings,
getApiUrl,
modules
} from '../../../../../extensions.js';
import { selected_group, getGroupMembers } from '../../../../../group-chats.js';
import { removeReasoningFromString } from '../../../../../reasoning.js';
import { isJsonSchemaSupported } from '../../../../../textgen-settings.js';
import { trimToEndSentence, trimToStartSentence, waitUntilCondition } from '../../../../../utils.js';
import { generateWebLlmChatPrompt, isWebLlmSupported } from '../../../../../extensions/shared.js';
import { namesMatch } from './presentCharacters.js';
import { normalizeImageSrc } from './imageUrls.js';
const EXPRESSIONS_EXTENSION_NAME = 'expressions';
const DEFAULT_FALLBACK_EXPRESSION = 'joy';
const DEFAULT_LLM_PROMPT = 'Ignore previous instructions. Classify the emotion of the last message. Output just one word, e.g. "joy" or "anger". Choose only one of the following labels: {{labels}}';
const DEFAULT_EXPRESSIONS = [
'admiration',
'amusement',
'anger',
'annoyance',
'approval',
'caring',
'confusion',
'curiosity',
'desire',
'disappointment',
'disapproval',
'disgust',
'embarrassment',
'excitement',
'fear',
'gratitude',
'grief',
'joy',
'love',
'nervousness',
'optimism',
'pride',
'realization',
'relief',
'remorse',
'sadness',
'surprise',
'neutral'
];
export const EXPRESSION_API = {
local: 0,
extras: 1,
llm: 2,
webllm: 3,
none: 99
};
const PROMPT_TYPE = {
raw: 'raw',
full: 'full'
};
let expressionsListCache = null;
const spriteCache = new Map();
function getNormalizedExpressionsSettings() {
const settings = stExtensionSettings.expressions || {};
return {
api: Number.isInteger(settings.api) ? settings.api : EXPRESSION_API.none,
custom: Array.isArray(settings.custom) ? settings.custom.slice() : [],
showDefault: settings.showDefault === true,
translate: settings.translate === true,
fallbackExpression: typeof settings.fallback_expression === 'string' && settings.fallback_expression.trim()
? settings.fallback_expression.trim().toLowerCase()
: '',
llmPrompt: typeof settings.llmPrompt === 'string' && settings.llmPrompt.trim()
? settings.llmPrompt
: DEFAULT_LLM_PROMPT,
allowMultiple: settings.allowMultiple !== false,
rerollIfSame: settings.rerollIfSame === true,
filterAvailable: settings.filterAvailable === true,
promptType: settings.promptType === PROMPT_TYPE.full ? PROMPT_TYPE.full : PROMPT_TYPE.raw,
expressionOverrides: Array.isArray(stExtensionSettings.expressionOverrides)
? stExtensionSettings.expressionOverrides.slice()
: []
};
}
export function isExpressionsExtensionEnabled() {
return !stExtensionSettings.disabledExtensions?.includes(EXPRESSIONS_EXTENSION_NAME);
}
export function getExpressionsSettingsSignature() {
if (!isExpressionsExtensionEnabled()) {
return 'disabled';
}
const settings = getNormalizedExpressionsSettings();
return JSON.stringify({
api: settings.api,
custom: settings.custom,
showDefault: settings.showDefault,
translate: settings.translate,
fallbackExpression: settings.fallbackExpression,
llmPrompt: settings.llmPrompt,
allowMultiple: settings.allowMultiple,
rerollIfSame: settings.rerollIfSame,
filterAvailable: settings.filterAvailable,
promptType: settings.promptType,
expressionOverrides: settings.expressionOverrides
});
}
export function getExpressionClassificationSettingsSignature() {
if (!isExpressionsExtensionEnabled()) {
return 'disabled';
}
const settings = getNormalizedExpressionsSettings();
return JSON.stringify({
api: settings.api,
custom: settings.custom,
translate: settings.translate,
fallbackExpression: settings.fallbackExpression,
llmPrompt: settings.llmPrompt,
filterAvailable: settings.filterAvailable,
promptType: settings.promptType
});
}
export function getExpressionPortraitSettingsSignature() {
if (!isExpressionsExtensionEnabled()) {
return 'disabled';
}
const settings = getNormalizedExpressionsSettings();
return JSON.stringify({
custom: settings.custom,
showDefault: settings.showDefault,
fallbackExpression: settings.fallbackExpression,
allowMultiple: settings.allowMultiple,
rerollIfSame: settings.rerollIfSame
});
}
export function clearExpressionsCompatibilityCache() {
expressionsListCache = null;
spriteCache.clear();
}
function uniqueValues(values) {
return values.filter((value, index) => values.indexOf(value) === index);
}
function normalizeExpressionLabel(label) {
return String(label || '').trim().toLowerCase();
}
function stripExtension(fileName) {
return String(fileName || '').replace(/\.[^/.]+$/, '');
}
function resolveFolderOverride(folderName, expressionOverrides) {
const override = expressionOverrides.find(entry => entry?.name === folderName);
return override?.path ? String(override.path) : folderName;
}
function getAvatarFolderName(avatar) {
if (!avatar || avatar === 'none') {
return '';
}
return String(avatar).replace(/\.[^/.]+$/, '');
}
export function resolveSpriteFolderNameForCharacter(characterName) {
if (!characterName) {
return '';
}
const settings = getNormalizedExpressionsSettings();
const groupId = selected_group;
if (groupId) {
try {
const groupMembers = getGroupMembers(groupId) || [];
const matchingMember = groupMembers.find(member =>
member?.name && namesMatch(member.name, characterName));
const memberFolder = getAvatarFolderName(matchingMember?.avatar);
if (memberFolder) {
return resolveFolderOverride(memberFolder, settings.expressionOverrides);
}
} catch {
// Ignore group lookup issues and continue through the fallback chain.
}
}
if (Array.isArray(characters) && characters.length > 0) {
const matchingCharacter = characters.find(character =>
character?.name && namesMatch(character.name, characterName));
const characterFolder = getAvatarFolderName(matchingCharacter?.avatar);
if (characterFolder) {
return resolveFolderOverride(characterFolder, settings.expressionOverrides);
}
}
if (this_chid !== undefined && characters?.[this_chid]?.name && namesMatch(characters[this_chid].name, characterName)) {
const currentCharacterFolder = getAvatarFolderName(characters[this_chid].avatar);
if (currentCharacterFolder) {
return resolveFolderOverride(currentCharacterFolder, settings.expressionOverrides);
}
}
return '';
}
function sampleClassifyText(text, expressionsApi) {
if (!text) {
return '';
}
let result = substituteParams(text).replace(/[*"]/g, '');
if (expressionsApi === EXPRESSION_API.llm) {
return result.trim();
}
const SAMPLE_THRESHOLD = 500;
const HALF_SAMPLE_THRESHOLD = SAMPLE_THRESHOLD / 2;
if (text.length < SAMPLE_THRESHOLD) {
result = trimToEndSentence(result);
} else {
result = `${trimToEndSentence(result.slice(0, HALF_SAMPLE_THRESHOLD))} ${trimToStartSentence(result.slice(-HALF_SAMPLE_THRESHOLD))}`;
}
return result.trim();
}
function getJsonSchema(labels) {
return {
$schema: 'http://json-schema.org/draft-04/schema#',
type: 'object',
properties: {
emotion: {
type: 'string',
enum: labels
}
},
required: ['emotion'],
additionalProperties: false
};
}
function buildFullContextThoughtPrompt(prompt, text) {
return [
prompt,
'',
'Classify the emotion of the following text instead of the last chat message.',
'Output exactly one label from the allowed list.',
'',
`Text: ${text}`
].join('\n');
}
function parseLlmResponse(emotionResponse, labels) {
try {
const parsedEmotion = JSON.parse(emotionResponse);
const response = parsedEmotion?.emotion?.trim()?.toLowerCase();
if (response && labels.includes(response)) {
return response;
}
} catch {
// Fall through to the fuzzy parse below.
}
const cleanedResponse = removeReasoningFromString(String(emotionResponse || ''));
const lowerCaseResponse = cleanedResponse.toLowerCase();
for (const label of labels) {
if (lowerCaseResponse.includes(label.toLowerCase())) {
return label;
}
}
const fuse = new Fuse(labels, { includeScore: true });
const match = fuse.search(cleanedResponse)[0];
if (match?.item) {
return match.item;
}
throw new Error('Could not parse expression label from response');
}
async function resolveExpressionsList() {
const settings = getNormalizedExpressionsSettings();
try {
if (settings.api === EXPRESSION_API.extras && modules.includes('classify')) {
const url = new URL(getApiUrl());
url.pathname = '/api/classify/labels';
const response = await doExtrasFetch(url, {
method: 'GET',
headers: { 'Bypass-Tunnel-Reminder': 'bypass' }
});
if (response.ok) {
const data = await response.json();
return Array.isArray(data?.labels)
? data.labels.map(normalizeExpressionLabel).filter(Boolean)
: DEFAULT_EXPRESSIONS.slice();
}
}
if (settings.api === EXPRESSION_API.local) {
const response = await fetch('/api/extra/classify/labels', {
method: 'POST',
headers: getRequestHeaders({ omitContentType: true })
});
if (response.ok) {
const data = await response.json();
return Array.isArray(data?.labels)
? data.labels.map(normalizeExpressionLabel).filter(Boolean)
: DEFAULT_EXPRESSIONS.slice();
}
}
} catch {
// Fall back to the built-in labels below.
}
return DEFAULT_EXPRESSIONS.slice();
}
async function getAvailableExpressionLabelsForCharacter(characterName) {
const spriteFolderName = resolveSpriteFolderNameForCharacter(characterName);
if (!spriteFolderName) {
return [];
}
const expressions = await getSpritesList(spriteFolderName);
return expressions
.filter(expression => Array.isArray(expression?.files) && expression.files.length > 0)
.map(expression => String(expression.label || '').trim().toLowerCase())
.filter(Boolean);
}
export async function getExpressionsList({ characterName = '', filterAvailable = false } = {}) {
if (!Array.isArray(expressionsListCache)) {
expressionsListCache = await resolveExpressionsList();
}
const settings = getNormalizedExpressionsSettings();
const expressions = uniqueValues([...expressionsListCache, ...settings.custom.map(value => String(value).trim().toLowerCase())])
.filter(Boolean);
if (!filterAvailable || ![EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(settings.api)) {
return expressions;
}
const availableExpressions = await getAvailableExpressionLabelsForCharacter(characterName);
if (!availableExpressions.length) {
return expressions;
}
return expressions.filter(expression => availableExpressions.includes(expression));
}
async function getSpritesList(spriteFolderName) {
if (!spriteFolderName) {
return [];
}
if (spriteCache.has(spriteFolderName)) {
return spriteCache.get(spriteFolderName);
}
try {
const response = await fetch(`/api/sprites/get?name=${encodeURIComponent(spriteFolderName)}`);
const sprites = response.ok ? await response.json() : [];
const grouped = [];
for (const sprite of Array.isArray(sprites) ? sprites : []) {
const fileName = String(sprite?.path || '').split('/').pop()?.split('?')[0] || '';
const imageData = {
expression: normalizeExpressionLabel(sprite?.label),
fileName,
title: stripExtension(fileName),
imageSrc: String(sprite?.path || ''),
type: 'success',
isCustom: getNormalizedExpressionsSettings().custom.includes(normalizeExpressionLabel(sprite?.label))
};
let existing = grouped.find(entry => entry.label === imageData.expression);
if (!existing) {
existing = { label: imageData.expression, files: [] };
grouped.push(existing);
}
existing.files.push(imageData);
}
for (const expression of grouped) {
expression.files.sort((left, right) => {
if (left.title === expression.label) return -1;
if (right.title === expression.label) return 1;
return left.title.localeCompare(right.title);
});
}
spriteCache.set(spriteFolderName, grouped);
return grouped;
} catch {
spriteCache.set(spriteFolderName, []);
return [];
}
}
function chooseSpriteForExpression(expressions, expression, { previousSrc = null } = {}) {
const settings = getNormalizedExpressionsSettings();
let sprite = expressions.find(entry => entry.label === expression);
if (!(sprite?.files?.length > 0) && settings.fallbackExpression) {
sprite = expressions.find(entry => entry.label === settings.fallbackExpression);
}
if (!(sprite?.files?.length > 0)) {
return null;
}
let candidates = sprite.files;
if (settings.allowMultiple && sprite.files.length > 1) {
if (settings.rerollIfSame) {
const filtered = sprite.files.filter(file => !previousSrc || file.imageSrc !== previousSrc);
if (filtered.length > 0) {
candidates = filtered;
}
}
return candidates[Math.floor(Math.random() * candidates.length)] || null;
}
return candidates[0] || null;
}
function getDefaultExpressionImage(expression, customExpressions) {
let normalizedExpression = String(expression || '').trim().toLowerCase();
if (!normalizedExpression) {
return '';
}
if (customExpressions.includes(normalizedExpression)) {
normalizedExpression = DEFAULT_FALLBACK_EXPRESSION;
}
return `/img/default-expressions/${normalizedExpression}.png`;
}
export async function classifyExpressionText(text, { characterName = '' } = {}) {
if (!isExpressionsExtensionEnabled()) {
return null;
}
const settings = getNormalizedExpressionsSettings();
if (!text) {
return settings.fallbackExpression || '';
}
if (settings.api === EXPRESSION_API.none) {
return settings.fallbackExpression || '';
}
let processedText = text;
if (settings.translate && typeof globalThis.translate === 'function') {
processedText = await globalThis.translate(processedText, 'en');
}
processedText = sampleClassifyText(processedText, settings.api);
if (!processedText) {
return settings.fallbackExpression || '';
}
const labels = await getExpressionsList({
characterName,
filterAvailable: settings.filterAvailable === true
});
const fallbackLabels = labels.length > 0 ? labels : await getExpressionsList();
try {
switch (settings.api) {
case EXPRESSION_API.local: {
const response = await fetch('/api/extra/classify', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ text: processedText })
});
if (response.ok) {
const data = await response.json();
return String(data?.classification?.[0]?.label || settings.fallbackExpression || '').trim().toLowerCase();
}
break;
}
case EXPRESSION_API.extras: {
if (!modules.includes('classify')) {
return settings.fallbackExpression || '';
}
const url = new URL(getApiUrl());
url.pathname = '/api/classify';
const response = await doExtrasFetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Bypass-Tunnel-Reminder': 'bypass'
},
body: JSON.stringify({ text: processedText })
});
if (response.ok) {
const data = await response.json();
return String(data?.classification?.[0]?.label || settings.fallbackExpression || '').trim().toLowerCase();
}
break;
}
case EXPRESSION_API.llm: {
await waitUntilCondition(() => online_status !== 'no_connection', 3000, 250);
const labelsString = fallbackLabels.map(label => `"${label}"`).join(', ');
const basePrompt = substituteParamsExtended(settings.llmPrompt, { labels: labelsString });
const prompt = settings.promptType === PROMPT_TYPE.full
? buildFullContextThoughtPrompt(basePrompt, processedText)
: basePrompt;
const onReady = (args) => {
if (isJsonSchemaSupported()) {
Object.assign(args, {
top_k: 1,
stop: [],
stopping_strings: [],
custom_token_bans: [],
json_schema: getJsonSchema(fallbackLabels)
});
}
};
eventSource.once(event_types.TEXT_COMPLETION_SETTINGS_READY, onReady);
const responseText = settings.promptType === PROMPT_TYPE.full
? await generateQuietPrompt({ quietPrompt: prompt })
: await generateRaw({ prompt: processedText, systemPrompt: prompt });
return parseLlmResponse(responseText, fallbackLabels);
}
case EXPRESSION_API.webllm: {
if (!isWebLlmSupported()) {
return settings.fallbackExpression || '';
}
const labelsString = fallbackLabels.map(label => `"${label}"`).join(', ');
const prompt = substituteParamsExtended(settings.llmPrompt, { labels: labelsString });
const responseText = await generateWebLlmChatPrompt([
{
role: 'user',
content: `${processedText}\n\n${prompt}`
}
]);
return parseLlmResponse(responseText, fallbackLabels);
}
default:
break;
}
} catch {
return settings.fallbackExpression || '';
}
return settings.fallbackExpression || '';
}
export async function resolveExpressionPortraitForCharacter(characterName, expression, { previousSrc = null } = {}) {
if (!isExpressionsExtensionEnabled()) {
return null;
}
const settings = getNormalizedExpressionsSettings();
const normalizedExpression = String(expression || '').trim().toLowerCase();
const spriteFolderName = resolveSpriteFolderNameForCharacter(characterName);
if (spriteFolderName) {
const expressions = await getSpritesList(spriteFolderName);
const spriteFile = chooseSpriteForExpression(expressions, normalizedExpression, { previousSrc });
const spriteSrc = normalizeImageSrc(spriteFile?.imageSrc || '');
if (spriteSrc) {
return spriteSrc;
}
}
if (settings.showDefault) {
const defaultExpression = normalizedExpression || settings.fallbackExpression;
const defaultImage = normalizeImageSrc(getDefaultExpressionImage(defaultExpression, settings.custom));
if (defaultImage) {
return defaultImage;
}
}
return null;
}
@@ -0,0 +1,73 @@
import {
thoughtBasedExpressionPortraits,
getThoughtBasedExpressionPortrait
} from '../core/state.js';
import {
isSafeImageSrc,
normalizeImageSrc,
resolveImageUrl
} from './imageUrls.js';
import { isExpressionsExtensionEnabled } from './sillyTavernExpressions.js';
function normalizeName(name) {
return String(name || '').trim().toLowerCase();
}
function namesMatch(a, b) {
const left = normalizeName(a);
const right = normalizeName(b);
if (!left || !right) {
return false;
}
return left === right || left.startsWith(right + ' ') || right.startsWith(left + ' ');
}
function isDocumentLikeUrl(src) {
const candidate = resolveImageUrl(src);
if (!candidate) {
return false;
}
const current = new URL(window.location.href);
return candidate.origin === current.origin
&& candidate.pathname === current.pathname
&& candidate.search === current.search;
}
export function isUsableThoughtBasedExpressionSrc(src) {
const normalized = normalizeImageSrc(src);
if (!normalized) {
return false;
}
if (isDocumentLikeUrl(normalized)) {
return false;
}
return isSafeImageSrc(normalized);
}
export function getThoughtBasedExpressionPortraitForCharacter(characterName) {
if (!isExpressionsExtensionEnabled()) {
return null;
}
const target = normalizeName(characterName);
if (!target) {
return null;
}
const exact = getThoughtBasedExpressionPortrait(target);
if (isUsableThoughtBasedExpressionSrc(exact)) {
return exact;
}
for (const [storedName, src] of Object.entries(thoughtBasedExpressionPortraits)) {
if (namesMatch(storedName, target) && isUsableThoughtBasedExpressionSrc(src)) {
return src;
}
}
return null;
}
+16
View File
@@ -0,0 +1,16 @@
const toSnake = (str) => str
// replace any sequence of non-alphanumeric characters with a single underscore
.replace(/[^0-9A-Za-z]+/g, '_')
// insert underscore between a lower-case letter/digit and an upper-case letter (but not between consecutive uppers)
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
// collapse multiple underscores
.replace(/_+/g, '_')
// trim leading/trailing underscores
.replace(/^_+|_+$/g, '')
// finally, lowercase the result
.toLowerCase();
export const safeToSnake = (str) => {
const res = toSnake(str);
return (res.length >= 2) ? res : str; // considering element with one symbol is too short to be safe
};
+6146 -435
View File
File diff suppressed because it is too large Load Diff
+1119 -110
View File
File diff suppressed because it is too large Load Diff