Compare commits

...

26 Commits

Author SHA1 Message Date
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 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
22 changed files with 722 additions and 176 deletions
+4 -8
View File
@@ -7,16 +7,12 @@ An immersive RPG extension for browsers that tracks character stats, scene infor
## 🆕 What's New
### v3.7.0
### v3.7.2
- Added omniscience filter.
- Added opacity to the color selector.
- Overwritten SillyTavern's dumb-ahh trim logic when joining prompts.
- Fixed custom attributes not allowing value increase/decrease.
- Various bug fixes.
- Minor bug fixes
**Special thanks to all the other contributors for this project:**
Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte, Chungchandev, Joenunezb, Amauragis, and Tomt610.
Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte, Chungchandev, Joenunezb, Amauragis, Tomt610, and Jakstein!
## 📥 Installation
@@ -269,7 +265,7 @@ If you enjoy this extension, consider supporting development:
## 🙏 Credits
**Contributors:**
SpicyMarinara, Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte, Chungchandev, Joenunezb, Amauragis, and Tomt610.
SpicyMarinara, Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte, Chungchandev, Joenunezb, Amauragis, Tomt610, and Jakstein.
## 🚀 Planned Features
+1 -1
View File
@@ -1,5 +1,5 @@
import { getContext, renderExtensionTemplateAsync, extension_settings as st_extension_settings } from '../../../extensions.js';
import { eventSource, event_types, substituteParams, chat, generateRaw, saveSettingsDebounced, chat_metadata, saveChatDebounced, user_avatar, getThumbnailUrl, characters, this_chid, extension_prompt_types, extension_prompt_roles, setExtensionPrompt, reloadCurrentChat, Generate, getRequestHeaders } from '../../../../script.js';
import { eventSource, event_types, substituteParams, chat, saveSettingsDebounced, chat_metadata, saveChatDebounced, user_avatar, getThumbnailUrl, characters, this_chid, extension_prompt_types, extension_prompt_roles, setExtensionPrompt, reloadCurrentChat, Generate, getRequestHeaders } from '../../../../script.js';
import { selected_group, getGroupMembers } from '../../../group-chats.js';
import { power_user } from '../../../power-user.js';
+1 -1
View File
@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Marinara",
"version": "3.7.0",
"version": "3.7.2",
"homePage": "https://github.com/SpicyMarinara/rpg-companion-sillytavern"
}
+2 -2
View File
@@ -44,12 +44,12 @@
<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, and Tomt610.
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.0
v3.7.2
</div>
</div>
</div>
+1 -1
View File
@@ -159,7 +159,7 @@
"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 bars).",
"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:",
+3 -2
View File
@@ -9,7 +9,8 @@
* - Manual regeneration support
*/
import { generateRaw, characters, this_chid } from '../../../../../../../script.js';
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';
@@ -254,7 +255,7 @@ async function generateAvatarPrompt(characterName) {
// console.log('[RPG Avatar] Using external API for avatar prompt generation');
response = await generateWithExternalAPI(promptMessages);
} else {
response = await generateRaw({
response = await safeGenerateRaw({
prompt: promptMessages,
quietToLoud: false
});
+7 -7
View File
@@ -3,8 +3,9 @@
* Handles API calls for RPG tracker generation
*/
import { generateRaw, chat, eventSource } 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
@@ -107,11 +108,10 @@ export async function generateWithExternalAPI(messages) {
const data = await response.json();
if (!data.choices || !data.choices[0] || !data.choices[0].message) {
throw new Error('Invalid response format from external API');
const content = extractTextFromResponse(data);
if (!content || !content.trim()) {
throw new Error('Invalid response format from external API — no text content found');
}
const content = data.choices[0].message.content;
// console.log('[RPG Companion] External API response received successfully');
return content;
@@ -255,8 +255,8 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
// console.log('[RPG Companion] Using external API for tracker generation');
response = await generateWithExternalAPI(prompt);
} else {
// Separate mode: Use SillyTavern's generateRaw
response = await generateRaw({
// Separate mode: Use SillyTavern's generateRaw (with extended thinking fallback)
response = await safeGenerateRaw({
prompt: prompt,
quietToLoud: false
});
-5
View File
@@ -507,11 +507,6 @@ function onGenerateAfterCombinePrompts(eventData) {
// 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');
// Remove extra newlines after last_message opening and closing tags
// Match exactly the double newline pattern
eventData.prompt = eventData.prompt.replace(/<last_message>\n\n/g, '<last_message>\n');
eventData.prompt = eventData.prompt.replace(/\n\n<\/last_message>/g, '\n</last_message>');
}
/**
+21 -3
View File
@@ -5,6 +5,8 @@
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
@@ -19,6 +21,19 @@ function toSnakeCase(name) {
.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
@@ -58,12 +73,12 @@ export function buildUserStatsJSONInstruction() {
if (customFields.length > 0) {
for (let i = 0; i < customFields.length; i++) {
const fieldName = customFields[i].toLowerCase();
const fieldKey = toSnakeCase(fieldName);
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}1, ${fieldName}2]"${comma}`;
instruction += ` "${fieldKey}": "[${fieldName}]"${comma}`;
}
}
if (!userStatsConfig.statusSection.showMoodEmoji && customFields.length > 0) {
@@ -132,7 +147,10 @@ export function buildInfoBoxJSONInstruction() {
}
if (widgets.weather?.enabled) {
instruction += (hasFields ? ',\n' : '') + ' "weather": {"emoji": "Weather Emoji", "forecast": "Forecast"}';
// 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;
}
+14 -11
View File
@@ -98,16 +98,19 @@ function applyUserStatsLocks(data, lockedItems) {
}
}
// Lock inventory items - handle bracket notation paths like "inventory.onPerson[0]"
// Lock inventory items - match by item name instead of index
if (data.inventory && lockedItems.inventory) {
// Helper function to parse bracket notation and apply lock
// 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, index) => {
// Check if this specific item is locked using bracket notation with inventory prefix
const bracketPath = `${category}[${index}]`;
if (lockedItems.inventory[bracketPath]) {
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 };
@@ -131,13 +134,13 @@ function applyUserStatsLocks(data, lockedItems) {
data.inventory.assets = applyInventoryLocks(data.inventory.assets, 'assets');
}
// Apply locks to stored items (nested structure with inventory.stored.location[index])
// 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])) {
data.inventory.stored[location] = data.inventory.stored[location].map((item, index) => {
const bracketPath = `${location}[${index}]`;
if (lockedItems.inventory.stored[bracketPath]) {
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 };
+20 -4
View File
@@ -9,6 +9,20 @@ import { saveSettings } from '../../core/persistence.js';
import { extractInventory } from './inventoryParser.js';
import { repairJSON } from '../../utils/jsonRepair.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(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
/**
* Helper to separate emoji from text in a string
* Handles cases where there's no comma or space after emoji
@@ -559,10 +573,12 @@ export function parseUserStats(statsText) {
const trackerConfig = extensionSettings.trackerConfig;
const customFields = trackerConfig?.userStats?.statusSection?.customFields || [];
for (const fieldName of customFields) {
const fieldKey = fieldName.toLowerCase();
if (statsData.status[fieldKey]) {
extensionSettings.userStats[fieldKey] = statsData.status[fieldKey];
// console.log(`[RPG Parser] ✓ Set ${fieldKey} =`, statsData.status[fieldKey]);
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);
}
}
}
+6 -5
View File
@@ -739,13 +739,14 @@ function formatTrackerDataForContext(jsonData, trackerType, userName) {
}
}
// Relationship
if (char.relationship) {
// Relationship - check both Relationship (new format) and relationship (old format)
const relationshipValue = char.Relationship || char.relationship;
if (relationshipValue) {
let relValue;
if (typeof char.relationship === 'object' && !Array.isArray(char.relationship) && 'status' in char.relationship) {
relValue = getValue(char.relationship.status);
if (typeof relationshipValue === 'object' && !Array.isArray(relationshipValue) && 'status' in relationshipValue) {
relValue = getValue(relationshipValue.status);
} else {
relValue = getValue(char.relationship);
relValue = getValue(relationshipValue);
}
if (relValue) formatted += ` Relationship: ${relValue}\n`;
}
+11 -4
View File
@@ -395,13 +395,20 @@ export function onMessageSwiped(messageIndex) {
// Load swipe data into lastGeneratedData for display (both modes)
lastGeneratedData.userStats = swipeData.userStats || null;
lastGeneratedData.infoBox = swipeData.infoBox || null;
lastGeneratedData.characterThoughts = swipeData.characterThoughts || null;
// Parse user stats if available
if (swipeData.userStats) {
parseUserStats(swipeData.userStats);
// 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;
}
// DON'T parse user stats when loading swipe data
// This would overwrite manually edited fields (like Conditions) with old swipe data
// The lastGeneratedData is loaded for display purposes only
// parseUserStats() updates extensionSettings.userStats which should only be modified
// by new generations or manual edits, not by swipe navigation
// console.log('[RPG Companion] 🔄 Loaded swipe data into lastGeneratedData for display:', currentSwipeId);
} else {
// console.log('[RPG Companion] ️ No stored data for swipe:', currentSwipeId);
+8 -8
View File
@@ -81,7 +81,7 @@ export function renderOnPersonView(onPersonItems, viewMode = 'list') {
if (viewMode === 'grid') {
// Grid view: card-style items
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.onPerson[${index}]`);
const lockIconHtml = getLockIconHtml('userStats', `inventory.onPerson.${item}`);
return `
<div class="rpg-item-card" data-field="onPerson" data-index="${index}">
${lockIconHtml}
@@ -94,7 +94,7 @@ export function renderOnPersonView(onPersonItems, viewMode = 'list') {
} else {
// List view: full-width rows
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.onPerson[${index}]`);
const lockIconHtml = getLockIconHtml('userStats', `inventory.onPerson.${item}`);
return `
<div class="rpg-item-row" data-field="onPerson" data-index="${index}">
${lockIconHtml}
@@ -163,7 +163,7 @@ export function renderClothingView(clothingItems, viewMode = 'list') {
if (viewMode === 'grid') {
// Grid view: card-style items
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.clothing[${index}]`);
const lockIconHtml = getLockIconHtml('userStats', `inventory.clothing.${item}`);
return `
<div class="rpg-item-card" data-field="clothing" data-index="${index}">
${lockIconHtml}
@@ -176,7 +176,7 @@ export function renderClothingView(clothingItems, viewMode = 'list') {
} else {
// List view: full-width rows
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.clothing[${index}]`);
const lockIconHtml = getLockIconHtml('userStats', `inventory.clothing.${item}`);
return `
<div class="rpg-item-row" data-field="clothing" data-index="${index}">
${lockIconHtml}
@@ -291,7 +291,7 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
if (viewMode === 'grid') {
// Grid view: card-style items
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.stored.${location}[${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}">
${lockIconHtml}
@@ -304,7 +304,7 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
} else {
// List view: full-width rows
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.stored.${location}[${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}">
${lockIconHtml}
@@ -393,7 +393,7 @@ export function renderAssetsView(assets, viewMode = 'list') {
if (viewMode === 'grid') {
// Grid view: card-style items
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.assets[${index}]`);
const lockIconHtml = getLockIconHtml('userStats', `inventory.assets.${item}`);
return `
<div class="rpg-item-card" data-field="assets" data-index="${index}">
${lockIconHtml}
@@ -406,7 +406,7 @@ export function renderAssetsView(assets, viewMode = 'list') {
} else {
// List view: full-width rows
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.assets[${index}]`);
const lockIconHtml = getLockIconHtml('userStats', `inventory.assets.${item}`);
return `
<div class="rpg-item-row" data-field="assets" data-index="${index}">
${lockIconHtml}
+292 -43
View File
@@ -153,11 +153,20 @@ function namesMatch(cardName, aiName) {
* Displays character cards with avatars, relationship badges, and traits.
* Includes event listeners for editable character fields.
*/
export function renderThoughts() {
export function renderThoughts({ preserveScroll = false } = {}) {
if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) {
return;
}
// Save scroll position before re-render if requested
let savedContentScroll = 0;
if (preserveScroll) {
const $content = $thoughtsContainer.find('.rpg-thoughts-content');
if ($content.length) {
savedContentScroll = $content[0].scrollTop;
}
}
// Don't render if no data exists (e.g., after cache clear)
const thoughtsData = lastGeneratedData.characterThoughts || committedTrackerData.characterThoughts;
if (!thoughtsData) {
@@ -492,17 +501,19 @@ export function renderThoughts() {
html += `
<div class="rpg-character-card" data-character-name="${char.name}">
<div class="rpg-character-header-row">
<div class="rpg-character-avatar rpg-avatar-upload" data-character="${char.name}" title="Click to upload avatar">
<img src="${characterPortrait}" alt="${char.name}" onerror="this.style.opacity='0.5';this.onerror=null;" />
${hasRelationshipEnabled ? `<div class="rpg-relationship-badge rpg-editable" contenteditable="true" data-character="${char.name}" data-field="${relationshipFieldName}" title="Click to edit (use emoji: ⚔️ ⚖️ ⭐ ❤️)">${relationshipBadge}</div>` : ''}
</div>
<div class="rpg-character-content">
<div class="rpg-character-info">
<div class="rpg-character-header">
<span class="rpg-character-emoji rpg-editable" contenteditable="true" data-character="${char.name}" data-field="emoji" title="Click to edit emoji">${char.emoji}</span>
<span class="rpg-character-name rpg-editable" contenteditable="true" data-character="${char.name}" data-field="name" title="Click to edit name">${char.name}</span>
<button class="rpg-character-remove" data-character="${char.name}" title="Remove character">×</button>
</div>
</div>
<div class="rpg-character-content">
<div class="rpg-character-info">
`;
// Render custom fields dynamically
@@ -513,17 +524,20 @@ export function renderThoughts() {
const fieldNameLower = field.name.toLowerCase();
// Skip lock icons for thoughts field
const showLock = !fieldNameLower.includes('thought');
// Add placeholder for empty fields
const placeholder = fieldValue ? '' : `data-placeholder="${field.name}"`;
const emptyClass = fieldValue ? '' : ' rpg-empty-field';
if (showLock) {
const lockIconHtml = getLockIconHtml('characters', `${char.name}.${field.name}`);
html += `
<div class="rpg-character-field rpg-character-${fieldId}" style="position: relative;">
${lockIconHtml}
<span class="rpg-editable" contenteditable="true" data-character="${char.name}" data-field="${field.name}" title="Click to edit ${field.name}">${fieldValue}</span>
<span class="rpg-editable${emptyClass}" contenteditable="true" data-character="${char.name}" data-field="${field.name}" title="Click to edit ${field.name}" ${placeholder}>${fieldValue}</span>
</div>
`;
} else {
html += `
<div class="rpg-character-field rpg-character-${fieldId} rpg-editable" contenteditable="true" data-character="${char.name}" data-field="${field.name}" title="Click to edit ${field.name}">${fieldValue}</div>
<div class="rpg-character-field rpg-character-${fieldId} rpg-editable${emptyClass}" contenteditable="true" data-character="${char.name}" data-field="${field.name}" title="Click to edit ${field.name}" ${placeholder}>${fieldValue}</div>
`;
}
}
@@ -571,6 +585,16 @@ export function renderThoughts() {
}
debugLog('[RPG Thoughts] Finished building all character cards');
// Add "Add Character" button if data exists (inside rpg-thoughts-content)
if (presentCharacters.length > 0) {
html += `
<button class="rpg-add-character-btn" title="Add a new character">
<i class="fa-solid fa-plus"></i> Add Character
</button>
`;
}
html += '</div>';
}
@@ -669,11 +693,44 @@ export function renderThoughts() {
fileInput.trigger('click');
});
// Add event listener for "Add Character" button (support both click and touch for mobile)
$thoughtsContainer.find('.rpg-add-character-btn').on('click touchend', function(e) {
e.preventDefault();
e.stopPropagation();
addNewCharacter();
});
// Handle empty field focus - remove placeholder styling on focus
$thoughtsContainer.find('.rpg-editable.rpg-empty-field').on('focus', function() {
$(this).removeClass('rpg-empty-field');
$(this).removeAttr('data-placeholder');
});
// Restore placeholder if field becomes empty on blur (after the main blur handler)
$thoughtsContainer.find('.rpg-editable').on('blur', function() {
const $this = $(this);
if (!$this.text().trim()) {
const field = $this.data('field');
if (field) {
$this.addClass('rpg-empty-field');
$this.attr('data-placeholder', field);
}
}
});
// Remove updating class after animation
if (extensionSettings.enableAnimations) {
setTimeout(() => $thoughtsContainer.removeClass('rpg-content-updating'), 600);
}
// Restore scroll position after re-render
if (preserveScroll) {
const $content = $thoughtsContainer.find('.rpg-thoughts-content');
if ($content.length) {
$content[0].scrollTop = savedContentScroll;
}
}
// Update chat overlay if enabled
if (extensionSettings.showThoughtsInChat) {
updateChatThoughts();
@@ -795,6 +852,136 @@ export function removeCharacter(characterName) {
renderThoughts();
}
/**
* Adds a new blank character to Present Characters data.
* Creates a character with empty fields based on the tracker template.
*/
export function addNewCharacter() {
const presentCharsConfig = extensionSettings.trackerConfig?.presentCharacters;
const enabledFields = presentCharsConfig?.customFields?.filter(f => f && f.enabled && f.name) || [];
const characterStats = presentCharsConfig?.characterStats;
const enabledCharStats = characterStats?.enabled && characterStats?.customStats?.filter(s => s && s.enabled && s.name) || [];
const hasRelationship = presentCharsConfig?.relationshipFields?.length > 0;
// Check if data is in JSON format
let isJSON = false;
let parsedData = null;
try {
parsedData = typeof lastGeneratedData.characterThoughts === 'string'
? JSON.parse(lastGeneratedData.characterThoughts)
: lastGeneratedData.characterThoughts;
if (Array.isArray(parsedData) || (parsedData && parsedData.characters)) {
isJSON = true;
}
} catch (e) {
// Not JSON, treat as text format
}
if (isJSON) {
// JSON format - add new character object
const charactersArray = Array.isArray(parsedData) ? parsedData : (parsedData.characters || []);
const newCharacter = {
name: 'New Character',
emoji: '👤',
details: {}
};
// Add all enabled custom fields as empty
for (const field of enabledFields) {
newCharacter.details[field.name] = '';
}
// Add relationship if enabled
if (hasRelationship) {
newCharacter.relationship = 'Neutral';
}
// Add stats if enabled
if (enabledCharStats.length > 0) {
newCharacter.stats = {};
for (const stat of enabledCharStats) {
newCharacter.stats[stat.name] = 100;
}
}
charactersArray.push(newCharacter);
// Save back as JSON string
lastGeneratedData.characterThoughts = JSON.stringify(
Array.isArray(parsedData) ? charactersArray : { ...parsedData, characters: charactersArray },
null,
2
);
committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts;
} else {
// Text format - add new character block
const lines = lastGeneratedData.characterThoughts.split('\n');
const dividerIndex = lines.findIndex(line => line.includes('---'));
if (dividerIndex >= 0) {
const newCharacterLines = ['- New Character'];
// Add custom detail fields as standalone lines
for (const customField of enabledFields) {
newCharacterLines.push(` ${customField.name}: `);
}
// Add Relationship field if enabled
if (hasRelationship) {
newCharacterLines.push(` Relationship: Neutral`);
}
// Add Stats if enabled
if (enabledCharStats.length > 0) {
const statsParts = enabledCharStats.map(s => `${s.name}: 100%`);
newCharacterLines.push(` Stats: ${statsParts.join(' | ')}`);
}
// Find the last character and add after it, or after divider if no characters
let insertIndex = dividerIndex + 1;
for (let i = lines.length - 1; i > dividerIndex; i--) {
if (lines[i].trim().startsWith('- ')) {
// Find the end of this character block
insertIndex = i + 1;
while (insertIndex < lines.length && lines[insertIndex].trim() && !lines[insertIndex].trim().startsWith('- ')) {
insertIndex++;
}
break;
}
}
lines.splice(insertIndex, 0, ...newCharacterLines);
lastGeneratedData.characterThoughts = lines.join('\n');
committedTrackerData.characterThoughts = lines.join('\n');
}
}
// Update message swipe data
const chat = getContext().chat;
if (chat && chat.length > 0) {
for (let i = chat.length - 1; i >= 0; i--) {
const message = chat[i];
if (!message.is_user) {
if (message.extra && message.extra.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0;
if (message.extra.rpg_companion_swipes[swipeId]) {
message.extra.rpg_companion_swipes[swipeId].characterThoughts = lastGeneratedData.characterThoughts;
}
}
break;
}
}
}
saveChatData();
// Re-render to show new character
renderThoughts();
}
/**
* Updates a specific character field in Present Characters data and re-renders.
* Works with the new multi-line format.
@@ -862,18 +1049,27 @@ export function updateCharacterField(characterName, field, value) {
} else if (field === 'emoji') {
char.emoji = value;
} else if (field === 'Relationship') {
// Store relationship as text, converting emoji if needed
// Store relationship in the correct nested format
// Remove old flat format if it exists
if (char.Relationship) {
delete char.Relationship;
}
// First check if it's an emoji → convert to text
let relationshipValue;
if (emojiToRelationship[value]) {
char.Relationship = emojiToRelationship[value];
relationshipValue = emojiToRelationship[value];
} else {
// It's text - find matching relationship name (case-insensitive)
const matchingRelationship = Object.keys(relationshipEmojis).find(
name => name.toLowerCase() === value.toLowerCase()
);
char.Relationship = matchingRelationship || value;
relationshipValue = matchingRelationship || value;
}
// console.log('[RPG Companion] After update - char.Relationship:', char.Relationship);
// Store in the correct nested format
char.relationship = { status: relationshipValue };
// console.log('[RPG Companion] After update - char.relationship:', char.relationship);
// console.log('[RPG Companion] relationshipEmojis:', relationshipEmojis);
// console.log('[RPG Companion] emojiToRelationship:', emojiToRelationship);
} else if (field.toLowerCase() === 'thoughts' || field === (presentCharsConfig?.thoughts?.name || 'Thoughts')) {
@@ -883,21 +1079,64 @@ export function updateCharacterField(characterName, field, value) {
// Check if it's a character stat
const isStatField = enabledCharStats.findIndex(s => s.name === field) !== -1;
if (isStatField) {
if (!char.stats) char.stats = {};
let numValue = parseInt(value.replace('%', '').trim());
if (isNaN(numValue)) numValue = 0;
numValue = Math.max(0, Math.min(100, numValue));
char.stats[field] = numValue;
// Handle both array format (from LLM) and object format
if (Array.isArray(char.stats)) {
// Array format: [{name: "Health", value: 80}]
const statIndex = char.stats.findIndex(s => s.name === field);
if (statIndex !== -1) {
char.stats[statIndex].value = numValue;
} else {
// It's a custom detail field
// Stat not found in array - add it
char.stats.push({ name: field, value: numValue });
}
} else {
// Object format: {Health: 80} or undefined
if (!char.stats) char.stats = {};
char.stats[field] = numValue;
}
} else {
// It's a custom detail field - store in details object
if (!char.details) char.details = {};
char.details[field] = value;
// Clean up snake_case version if it exists (from AI generation)
const fieldKey = toSnakeCase(field);
if (fieldKey !== field && char.details[fieldKey] !== undefined) {
delete char.details[fieldKey];
}
// Clean up old root-level field if it exists (from v2 format)
if (char[field] !== undefined && field !== 'name' && field !== 'emoji') {
delete char[field];
}
if (char[fieldKey] !== undefined && fieldKey !== 'name' && fieldKey !== 'emoji') {
delete char[fieldKey];
}
}
}
// Save back to lastGeneratedData
lastGeneratedData.characterThoughts = Array.isArray(parsedData) ? charactersArray : { ...parsedData, characters: charactersArray };
// Clean up ALL duplicate snake_case fields in details (not just the edited field)
// This prevents duplicates from AI-generated data
if (char.details) {
for (const customField of enabledFields) {
const fieldName = customField.name;
const snakeCaseKey = toSnakeCase(fieldName);
// If both versions exist, keep the properly-cased one and remove snake_case
if (snakeCaseKey !== fieldName &&
char.details[fieldName] !== undefined &&
char.details[snakeCaseKey] !== undefined) {
delete char.details[snakeCaseKey];
}
}
}
}
// Save back to lastGeneratedData as JSON string (consistent with infoBox and userStats)
lastGeneratedData.characterThoughts = JSON.stringify(Array.isArray(parsedData) ? charactersArray : { ...parsedData, characters: charactersArray }, null, 2);
committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts;
// console.log('[RPG Companion] Saved to lastGeneratedData.characterThoughts:', JSON.stringify(lastGeneratedData.characterThoughts));
@@ -925,8 +1164,8 @@ export function updateCharacterField(characterName, field, value) {
// console.log('[RPG Companion] JSON format updated successfully');
// console.log('[RPG Companion] Updated data:', lastGeneratedData.characterThoughts);
// Re-render the thoughts panel to show updated value
renderThoughts();
// Re-render the thoughts panel to show updated value (preserve scroll position)
renderThoughts({ preserveScroll: true });
// Update chat thought overlays if editing thoughts
const thoughtsFieldName = presentCharsConfig?.thoughts?.name || 'Thoughts';
@@ -978,6 +1217,9 @@ export function updateCharacterField(characterName, field, value) {
const thoughtsFieldName = presentCharsConfig?.thoughts?.name || 'Thoughts';
const isThoughtsField = field.toLowerCase() === 'thoughts' || field === thoughtsFieldName;
// Track if field was found and updated
let fieldUpdated = false;
// First pass: check if Stats line exists and update other fields
for (let i = characterStartIndex; i < characterEndIndex; i++) {
const line = lines[i].trim();
@@ -985,35 +1227,37 @@ export function updateCharacterField(characterName, field, value) {
if (line.startsWith('Stats:')) {
statsLineExists = true;
statsLineIndex = i;
continue; // Skip to next line
}
// Check for name update
if (field === 'name' && line.startsWith('- ')) {
lines[i] = `- ${value}`;
fieldUpdated = true;
continue;
}
else if (field === 'emoji' && line.startsWith('Details:')) {
const parts = line.substring(line.indexOf(':') + 1).split('|').map(p => p.trim());
parts[0] = value;
lines[i] = `Details: ${parts.join(' | ')}`;
}
else if (line.startsWith('Details:')) {
const fieldIndex = enabledFields.findIndex(f => f.name === field);
if (fieldIndex !== -1) {
const parts = line.substring(line.indexOf(':') + 1).split('|').map(p => p.trim());
if (parts.length > fieldIndex + 1) {
parts[fieldIndex + 1] = value;
lines[i] = `Details: ${parts.join(' | ')}`;
}
}
}
else if (field === 'Relationship' && line.startsWith('Relationship:')) {
// Check for Relationship field
if (field === 'Relationship' && line.startsWith('Relationship:')) {
const emojiToRelationship = { '⚔️': 'Enemy', '⚖️': 'Neutral', '⭐': 'Friend', '❤️': 'Lover' };
const relationshipValue = emojiToRelationship[value] || value;
lines[i] = `Relationship: ${relationshipValue}`;
fieldUpdated = true;
continue;
}
else if (isThoughtsField && line.startsWith(thoughtsFieldName + ':')) {
// Update thoughts field
lines[i] = `${thoughtsFieldName}: ${value}`;
// console.log('[RPG Companion] Updated thoughts:', lines[i]);
// Check for Thoughts field
if (isThoughtsField && line.startsWith(thoughtsFieldName + ':')) {
lines[i] = ` ${thoughtsFieldName}: ${value}`;
fieldUpdated = true;
continue;
}
// Check for v3 text format standalone field lines (e.g., "Appearance: ...", "Demeanor: ...")
if (line.startsWith(field + ':')) {
lines[i] = ` ${field}: ${value}`;
fieldUpdated = true;
// Don't break - update ALL instances of this field (in case of duplicates from previous bugs)
}
}
@@ -1080,23 +1324,28 @@ export function updateCharacterField(characterName, field, value) {
}
}
} else {
// Create new character block
// Create new character block (v3 text format only)
const dividerIndex = lines.findIndex(line => line.includes('---'));
if (dividerIndex >= 0) {
const newCharacterLines = [`- ${characterName}`];
let detailsParts = [field === 'emoji' ? value : '😊'];
for (let i = 0; i < enabledFields.length; i++) {
detailsParts.push(field === enabledFields[i].name ? value : '');
// Add custom detail fields as standalone lines
for (const customField of enabledFields) {
if (field === customField.name) {
newCharacterLines.push(` ${customField.name}: ${value}`);
} else {
newCharacterLines.push(` ${customField.name}: `);
}
}
newCharacterLines.push(`Details: ${detailsParts.join(' | ')}`);
// Add Relationship field if enabled
if (presentCharsConfig?.relationshipFields?.length > 0) {
const emojiToRelationship = { '⚔️': 'Enemy', '⚖️': 'Neutral', '⭐': 'Friend', '❤️': 'Lover' };
const relationshipValue = field === 'Relationship' ? (emojiToRelationship[value] || value) : 'Neutral';
newCharacterLines.push(`Relationship: ${relationshipValue}`);
newCharacterLines.push(` Relationship: ${relationshipValue}`);
}
// Add Stats if enabled
if (enabledCharStats.length > 0) {
const statsParts = enabledCharStats.map(s => {
if (field === s.name) {
@@ -1111,7 +1360,7 @@ export function updateCharacterField(characterName, field, value) {
}
return `${s.name}: 0%`;
});
newCharacterLines.push(`Stats: ${statsParts.join(' | ')}`);
newCharacterLines.push(` Stats: ${statsParts.join(' | ')}`);
}
lines.splice(dividerIndex + 1, 0, ...newCharacterLines);
+21 -4
View File
@@ -23,6 +23,20 @@ import { isItemLocked, setItemLock } from '../generation/lockManager.js';
import { updateFabWidgets } from '../ui/mobile.js';
import { getStatBarColors } from '../ui/theme.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(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
/**
* Builds the user stats text string using custom stat names
* @returns {string} Formatted stats text for tracker
@@ -107,7 +121,7 @@ function updateUserStatsData() {
// Then, add any other numeric stats from extensionSettings that aren't in config
// (these could be custom stats the AI added or disabled stats)
const customFields = config.statusSection?.customFields || [];
const excludeFields = new Set(['mood', ...customFields.map(f => f.toLowerCase()), 'inventory', 'skills', 'level']);
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({
@@ -127,7 +141,7 @@ function updateUserStatsData() {
// Add all custom status fields
for (const fieldName of customFields) {
const fieldKey = fieldName.toLowerCase();
const fieldKey = toFieldKey(fieldName);
jsonData.status[fieldKey] = stats[fieldKey] || 'None';
}
@@ -334,10 +348,13 @@ export function renderUserStats() {
// Render custom status fields
if (config.statusSection.customFields && config.statusSection.customFields.length > 0) {
for (const fieldName of config.statusSection.customFields) {
const fieldKey = fieldName.toLowerCase();
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)
if (typeof fieldValue === 'string') {
fieldValue = fieldValue.replace(/^\[|\]$/g, '').trim();
}
html += `<div class="rpg-mood-conditions rpg-editable" contenteditable="true" data-field="${fieldKey}" title="Click to edit ${fieldName}">${fieldValue}</div>`;
+5 -4
View File
@@ -4,7 +4,8 @@
*/
import { getContext } from '../../../../../../extensions.js';
import { generateRaw, chat, saveChatDebounced, characters, this_chid, user_avatar } from '../../../../../../../script.js';
import { chat, saveChatDebounced, characters, this_chid, user_avatar } from '../../../../../../../script.js';
import { safeGenerateRaw } from '../../utils/responseExtractor.js';
import { selected_group, getGroupMembers, groups } from '../../../../../../group-chats.js';
import { executeSlashCommandsOnChatInput } from '../../../../../../../scripts/slash-commands.js';
import { extensionSettings } from '../../core/state.js';
@@ -81,7 +82,7 @@ export class EncounterModal {
// Store request for potential regeneration
this.lastRequest = { type: 'init', prompt: initPrompt };
const response = await generateRaw({
const response = await safeGenerateRaw({
prompt: initPrompt,
quietToLoud: false
});
@@ -816,7 +817,7 @@ export class EncounterModal {
// Store request for potential regeneration
this.lastRequest = { type: 'action', action, prompt: actionPrompt };
const response = await generateRaw({
const response = await safeGenerateRaw({
prompt: actionPrompt,
quietToLoud: false
});
@@ -1078,7 +1079,7 @@ export class EncounterModal {
// Generate summary
const summaryPrompt = await buildCombatSummaryPrompt(currentEncounter.encounterLog, result);
const summaryResponse = await generateRaw({
const summaryResponse = await safeGenerateRaw({
prompt: summaryPrompt,
quietToLoud: false
});
+5
View File
@@ -794,12 +794,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
+89 -24
View File
@@ -105,41 +105,106 @@ function getCurrentTime() {
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: [ "облачно", "пасмурно", "в помещении", "внутри" ] },
],
}
/**
* 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';
if (!weatherText) return "none";
const text = weatherText.toLowerCase();
// Check for specific weather conditions (order matters - check combined effects first)
if (text.includes('blizzard')) {
return 'blizzard'; // Snow + Wind
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;
}
if (text.includes('storm') || text.includes('thunder') || text.includes('lightning')) {
return 'storm'; // Rain + Lightning
}
if (text.includes('wind') || text.includes('breeze') || text.includes('gust') || text.includes('gale')) {
return 'wind';
}
if (text.includes('snow') || text.includes('flurries')) {
return 'snow';
}
if (text.includes('rain') || text.includes('drizzle') || text.includes('shower')) {
return 'rain';
}
if (text.includes('mist') || text.includes('fog') || text.includes('haze')) {
return 'mist';
}
if (text.includes('sunny') || text.includes('clear') || text.includes('bright')) {
return 'sunny';
}
if (text.includes('cloud') || text.includes('overcast') || text.includes('indoor') || text.includes('inside')) {
return 'none';
}
return 'none';
return "none";
}
/**
+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
}
}
+64 -14
View File
@@ -2118,8 +2118,8 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
/* Present Characters - Character Cards */
.rpg-character-card {
display: flex;
align-items: flex-start;
gap: clamp(8px, 1vw, 12px);
flex-direction: column;
gap: clamp(6px, 0.8vh, 10px);
padding: clamp(6px, 1vh, 8px);
background: rgba(0, 0, 0, 0.3);
border-radius: clamp(4px, 0.5vh, 6px);
@@ -2157,6 +2157,14 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
border-color: var(--rpg-highlight);
}
/* Header row with avatar and name */
.rpg-character-header-row {
display: flex;
align-items: center;
gap: clamp(8px, 1vw, 12px);
width: 100%;
}
/* Character avatar container with relationship badge */
.rpg-character-avatar {
position: relative;
@@ -2164,8 +2172,8 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
}
.rpg-character-avatar img {
width: clamp(35px, 6vh, 45px);
height: clamp(35px, 6vh, 45px);
width: clamp(30px, 5vh, 40px);
height: clamp(30px, 5vh, 40px);
border-radius: 50%;
border: 2px solid var(--rpg-highlight);
object-fit: cover;
@@ -2232,13 +2240,12 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
/* Character info section */
/* Character info section - now takes full width below header row */
.rpg-character-content {
flex: 1;
min-width: 0;
width: 100%;
display: flex;
flex-direction: column;
gap: 0;
gap: clamp(3px, 0.5vh, 5px);
overflow: hidden;
}
@@ -2271,13 +2278,14 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
background: var(--rpg-highlight);
}
/* Character header with emoji and name */
/* Character header with emoji and name - now inside header row */
.rpg-character-header {
display: flex;
align-items: center;
gap: clamp(4px, 0.5vw, 6px);
flex-wrap: nowrap; /* Prevent wrapping */
position: relative;
flex: 1;
min-width: 0;
}
.rpg-character-emoji {
@@ -2329,6 +2337,40 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
transform: scale(0.95);
}
/* Add Character Button (inside rpg-thoughts-content, after last character) */
.rpg-add-character-btn {
background: var(--rpg-accent);
border: 1px solid var(--rpg-border);
color: var(--rpg-text);
padding: 0;
margin: 0.5rem auto 0;
font-size: clamp(0.625rem, 0.6vw, 0.75rem);
font-weight: 500;
cursor: pointer;
border-radius: 3px;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.25rem;
opacity: 0.6;
width: auto;
}
.rpg-add-character-btn:hover {
background: var(--rpg-highlight);
border-color: var(--rpg-highlight);
color: var(--rpg-bg);
opacity: 1;
}
.rpg-add-character-btn:active {
transform: scale(0.95);
}
.rpg-add-character-btn i {
font-size: 0.875em;
}
/* Character traits/status line and custom fields */
.rpg-character-traits,
.rpg-character-field {
@@ -2340,12 +2382,20 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
word-wrap: break-word;
}
/* Placeholder for empty editable character fields */
.rpg-character-field.rpg-editable:empty::before {
content: 'Click to edit...';
/* Empty field placeholder using data-placeholder attribute */
.rpg-editable.rpg-empty-field:empty::before {
content: attr(data-placeholder);
color: var(--rpg-highlight);
opacity: 0.5;
opacity: 0.4;
font-style: italic;
pointer-events: none;
}
/* Ensure empty fields have minimum height for clickability */
.rpg-editable.rpg-empty-field {
min-height: 1.2em;
display: inline-block;
min-width: 3em;
}
/* Character stats display */
+2 -2
View File
@@ -1259,9 +1259,9 @@
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.
</p>
<h4 style="margin-top: 20px; margin-bottom: 10px;"><strong>Special thanks to all the other contributors for this project:</strong></h4>
<h4 style="margin-top: 20px; margin-bottom: 10px;"><strong>Special thanks to all the contributors for this project:</strong></h4>
<p style="margin-left: 20px; line-height: 1.6;">
Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude (???), IDeathByte, Chungchandev, Joenunezb, Amauragis, and Tomt610.
Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude (???), IDeathByte, Chungchandev, Joenunezb, Amauragis, Tomt610, and Olaroll.
</p>
<div style="margin-top: 20px; text-align: center;">