Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 105e20e97a | |||
| 5498c64f5d | |||
| 5fa369e3d7 | |||
| 52be8dca1f | |||
| 32c4f67822 | |||
| b61a426efe | |||
| 2a77c091dd | |||
| c0431a6117 | |||
| 43610bf8b6 | |||
| 2a5b57087b | |||
| 653d23ef9a | |||
| 7a3487c741 | |||
| 6fc35e50a1 | |||
| e82918004e | |||
| f78c8a1b78 | |||
| 2a48c30808 | |||
| c5a9c8631f | |||
| 2623df4050 | |||
| 03f21ef1ef | |||
| 2e747bc8aa | |||
| d0dd8950a6 | |||
| 5ddc380dac | |||
| f4324a5d19 | |||
| 4612ed2108 | |||
| 0e988b201c | |||
| 7b4ebb8d76 |
@@ -7,16 +7,12 @@ An immersive RPG extension for browsers that tracks character stats, scene infor
|
|||||||
|
|
||||||
## 🆕 What's New
|
## 🆕 What's New
|
||||||
|
|
||||||
### v3.7.0
|
### v3.7.2
|
||||||
|
|
||||||
- Added omniscience filter.
|
- Minor bug fixes
|
||||||
- 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.
|
|
||||||
|
|
||||||
**Special thanks to all the other contributors for this project:**
|
**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
|
## 📥 Installation
|
||||||
|
|
||||||
@@ -269,7 +265,7 @@ If you enjoy this extension, consider supporting development:
|
|||||||
## 🙏 Credits
|
## 🙏 Credits
|
||||||
|
|
||||||
**Contributors:**
|
**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
|
## 🚀 Planned Features
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getContext, renderExtensionTemplateAsync, extension_settings as st_extension_settings } from '../../../extensions.js';
|
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 { selected_group, getGroupMembers } from '../../../group-chats.js';
|
||||||
import { power_user } from '../../../power-user.js';
|
import { power_user } from '../../../power-user.js';
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -6,6 +6,6 @@
|
|||||||
"js": "index.js",
|
"js": "index.js",
|
||||||
"css": "style.css",
|
"css": "style.css",
|
||||||
"author": "Marinara",
|
"author": "Marinara",
|
||||||
"version": "3.7.0",
|
"version": "3.7.2",
|
||||||
"homePage": "https://github.com/SpicyMarinara/rpg-companion-sillytavern"
|
"homePage": "https://github.com/SpicyMarinara/rpg-companion-sillytavern"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -44,12 +44,12 @@
|
|||||||
<i class="fa-solid fa-users"></i> <strong>Contributors:</strong>
|
<i class="fa-solid fa-users"></i> <strong>Contributors:</strong>
|
||||||
</div>
|
</div>
|
||||||
<div style="opacity: 0.8; font-size: 0.9em;">
|
<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>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top: 10px; text-align: center; opacity: 0.6; font-size: 0.85em;">
|
<div style="margin-top: 10px; text-align: center; opacity: 0.6; font-size: 0.85em;">
|
||||||
v3.7.0
|
v3.7.2
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -159,7 +159,7 @@
|
|||||||
"template.trackerEditorModal.presentCharactersTab.aiInstructionLabel": "AI Instruction:",
|
"template.trackerEditorModal.presentCharactersTab.aiInstructionLabel": "AI Instruction:",
|
||||||
"template.trackerEditorModal.presentCharactersTab.characterStatsTitle": "Character Stats",
|
"template.trackerEditorModal.presentCharactersTab.characterStatsTitle": "Character Stats",
|
||||||
"template.trackerEditorModal.presentCharactersTab.trackCharacterStats": "Track 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.trackerEditorModal.presentCharactersTab.addCharacterStatButton": "Add Character Stat",
|
||||||
"template.mainPanel.title": "RPG Companion",
|
"template.mainPanel.title": "RPG Companion",
|
||||||
"template.mainPanel.lastRoll": "Last Roll:",
|
"template.mainPanel.lastRoll": "Last Roll:",
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
* - Manual regeneration support
|
* - 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 { executeSlashCommandsOnChatInput } from '../../../../../../../scripts/slash-commands.js';
|
||||||
import { selected_group, getGroupMembers } from '../../../../../../group-chats.js';
|
import { selected_group, getGroupMembers } from '../../../../../../group-chats.js';
|
||||||
import { extensionSettings, sessionAvatarPrompts, setSessionAvatarPrompt } from '../../core/state.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');
|
// console.log('[RPG Avatar] Using external API for avatar prompt generation');
|
||||||
response = await generateWithExternalAPI(promptMessages);
|
response = await generateWithExternalAPI(promptMessages);
|
||||||
} else {
|
} else {
|
||||||
response = await generateRaw({
|
response = await safeGenerateRaw({
|
||||||
prompt: promptMessages,
|
prompt: promptMessages,
|
||||||
quietToLoud: false
|
quietToLoud: false
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
* Handles API calls for RPG tracker generation
|
* 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 { executeSlashCommandsOnChatInput } from '../../../../../../../scripts/slash-commands.js';
|
||||||
|
import { safeGenerateRaw, extractTextFromResponse } from '../../utils/responseExtractor.js';
|
||||||
|
|
||||||
// Custom event name for when RPG Companion finishes updating tracker data
|
// 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
|
// 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();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!data.choices || !data.choices[0] || !data.choices[0].message) {
|
const content = extractTextFromResponse(data);
|
||||||
throw new Error('Invalid response format from external API');
|
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');
|
// console.log('[RPG Companion] External API response received successfully');
|
||||||
|
|
||||||
return content;
|
return content;
|
||||||
@@ -255,8 +255,8 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
|
|||||||
// console.log('[RPG Companion] Using external API for tracker generation');
|
// console.log('[RPG Companion] Using external API for tracker generation');
|
||||||
response = await generateWithExternalAPI(prompt);
|
response = await generateWithExternalAPI(prompt);
|
||||||
} else {
|
} else {
|
||||||
// Separate mode: Use SillyTavern's generateRaw
|
// Separate mode: Use SillyTavern's generateRaw (with extended thinking fallback)
|
||||||
response = await generateRaw({
|
response = await safeGenerateRaw({
|
||||||
prompt: prompt,
|
prompt: prompt,
|
||||||
quietToLoud: false
|
quietToLoud: false
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -507,11 +507,6 @@ function onGenerateAfterCombinePrompts(eventData) {
|
|||||||
// Always fix newlines around context tags (whether we just injected or not)
|
// 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, '\n<context>');
|
||||||
eventData.prompt = eventData.prompt.replace(/<\/context>/g, '</context>\n');
|
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>');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
import { extensionSettings, committedTrackerData } from '../../core/state.js';
|
import { extensionSettings, committedTrackerData } from '../../core/state.js';
|
||||||
import { getContext } from '../../../../../../extensions.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
|
* Converts a field name to snake_case for use as JSON key
|
||||||
@@ -19,6 +21,19 @@ function toSnakeCase(name) {
|
|||||||
.replace(/^_+|_+$/g, '');
|
.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
|
* Builds User Stats JSON format instruction
|
||||||
* @returns {string} JSON format instruction for user stats
|
* @returns {string} JSON format instruction for user stats
|
||||||
@@ -58,12 +73,12 @@ export function buildUserStatsJSONInstruction() {
|
|||||||
if (customFields.length > 0) {
|
if (customFields.length > 0) {
|
||||||
for (let i = 0; i < customFields.length; i++) {
|
for (let i = 0; i < customFields.length; i++) {
|
||||||
const fieldName = customFields[i].toLowerCase();
|
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');
|
const comma = (i === customFields.length - 1 && !userStatsConfig.statusSection.showMoodEmoji) ? '' : (userStatsConfig.statusSection.showMoodEmoji || i < customFields.length - 1 ? ',\n' : '\n');
|
||||||
if (i === 0 && userStatsConfig.statusSection.showMoodEmoji) {
|
if (i === 0 && userStatsConfig.statusSection.showMoodEmoji) {
|
||||||
instruction += ',\n';
|
instruction += ',\n';
|
||||||
}
|
}
|
||||||
instruction += ` "${fieldKey}": "[${fieldName}1, ${fieldName}2]"${comma}`;
|
instruction += ` "${fieldKey}": "[${fieldName}]"${comma}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!userStatsConfig.statusSection.showMoodEmoji && customFields.length > 0) {
|
if (!userStatsConfig.statusSection.showMoodEmoji && customFields.length > 0) {
|
||||||
@@ -132,7 +147,10 @@ export function buildInfoBoxJSONInstruction() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (widgets.weather?.enabled) {
|
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;
|
hasFields = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
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) => {
|
const applyInventoryLocks = (items, category) => {
|
||||||
if (!Array.isArray(items)) return items;
|
if (!Array.isArray(items)) return items;
|
||||||
|
if (!lockedItems.inventory[category]) return items;
|
||||||
|
|
||||||
return items.map((item, index) => {
|
return items.map((item) => {
|
||||||
// Check if this specific item is locked using bracket notation with inventory prefix
|
// Get item name (handle both string and object formats)
|
||||||
const bracketPath = `${category}[${index}]`;
|
const itemName = typeof item === 'string' ? item : (item.item || item.name || '');
|
||||||
if (lockedItems.inventory[bracketPath]) {
|
|
||||||
|
// Check if this specific item name is locked
|
||||||
|
if (lockedItems.inventory[category][itemName]) {
|
||||||
return typeof item === 'string'
|
return typeof item === 'string'
|
||||||
? { item, locked: true }
|
? { item, locked: true }
|
||||||
: { ...item, locked: true };
|
: { ...item, locked: true };
|
||||||
@@ -131,13 +134,13 @@ function applyUserStatsLocks(data, lockedItems) {
|
|||||||
data.inventory.assets = applyInventoryLocks(data.inventory.assets, 'assets');
|
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) {
|
if (data.inventory.stored && lockedItems.inventory.stored) {
|
||||||
for (const location in data.inventory.stored) {
|
for (const location in data.inventory.stored) {
|
||||||
if (Array.isArray(data.inventory.stored[location])) {
|
if (Array.isArray(data.inventory.stored[location]) && lockedItems.inventory.stored[location]) {
|
||||||
data.inventory.stored[location] = data.inventory.stored[location].map((item, index) => {
|
data.inventory.stored[location] = data.inventory.stored[location].map((item) => {
|
||||||
const bracketPath = `${location}[${index}]`;
|
const itemName = typeof item === 'string' ? item : (item.item || item.name || '');
|
||||||
if (lockedItems.inventory.stored[bracketPath]) {
|
if (lockedItems.inventory.stored[location][itemName]) {
|
||||||
return typeof item === 'string'
|
return typeof item === 'string'
|
||||||
? { item, locked: true }
|
? { item, locked: true }
|
||||||
: { ...item, locked: true };
|
: { ...item, locked: true };
|
||||||
|
|||||||
@@ -9,6 +9,20 @@ import { saveSettings } from '../../core/persistence.js';
|
|||||||
import { extractInventory } from './inventoryParser.js';
|
import { extractInventory } from './inventoryParser.js';
|
||||||
import { repairJSON } from '../../utils/jsonRepair.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
|
* Helper to separate emoji from text in a string
|
||||||
* Handles cases where there's no comma or space after emoji
|
* Handles cases where there's no comma or space after emoji
|
||||||
@@ -559,10 +573,12 @@ export function parseUserStats(statsText) {
|
|||||||
const trackerConfig = extensionSettings.trackerConfig;
|
const trackerConfig = extensionSettings.trackerConfig;
|
||||||
const customFields = trackerConfig?.userStats?.statusSection?.customFields || [];
|
const customFields = trackerConfig?.userStats?.statusSection?.customFields || [];
|
||||||
for (const fieldName of customFields) {
|
for (const fieldName of customFields) {
|
||||||
const fieldKey = fieldName.toLowerCase();
|
const fieldKey = toFieldKey(fieldName);
|
||||||
if (statsData.status[fieldKey]) {
|
// Try the base key first (e.g., "conditions"), then fall back to full lowercase name
|
||||||
extensionSettings.userStats[fieldKey] = statsData.status[fieldKey];
|
const value = statsData.status[fieldKey] || statsData.status[fieldName.toLowerCase()];
|
||||||
// console.log(`[RPG Parser] ✓ Set ${fieldKey} =`, statsData.status[fieldKey]);
|
if (value) {
|
||||||
|
extensionSettings.userStats[fieldKey] = value;
|
||||||
|
// console.log(`[RPG Parser] ✓ Set ${fieldKey} =`, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -739,13 +739,14 @@ function formatTrackerDataForContext(jsonData, trackerType, userName) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relationship
|
// Relationship - check both Relationship (new format) and relationship (old format)
|
||||||
if (char.relationship) {
|
const relationshipValue = char.Relationship || char.relationship;
|
||||||
|
if (relationshipValue) {
|
||||||
let relValue;
|
let relValue;
|
||||||
if (typeof char.relationship === 'object' && !Array.isArray(char.relationship) && 'status' in char.relationship) {
|
if (typeof relationshipValue === 'object' && !Array.isArray(relationshipValue) && 'status' in relationshipValue) {
|
||||||
relValue = getValue(char.relationship.status);
|
relValue = getValue(relationshipValue.status);
|
||||||
} else {
|
} else {
|
||||||
relValue = getValue(char.relationship);
|
relValue = getValue(relationshipValue);
|
||||||
}
|
}
|
||||||
if (relValue) formatted += ` Relationship: ${relValue}\n`;
|
if (relValue) formatted += ` Relationship: ${relValue}\n`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -395,13 +395,20 @@ export function onMessageSwiped(messageIndex) {
|
|||||||
// Load swipe data into lastGeneratedData for display (both modes)
|
// Load swipe data into lastGeneratedData for display (both modes)
|
||||||
lastGeneratedData.userStats = swipeData.userStats || null;
|
lastGeneratedData.userStats = swipeData.userStats || null;
|
||||||
lastGeneratedData.infoBox = swipeData.infoBox || 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.userStats) {
|
if (swipeData.characterThoughts && typeof swipeData.characterThoughts === 'object') {
|
||||||
parseUserStats(swipeData.userStats);
|
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);
|
// console.log('[RPG Companion] 🔄 Loaded swipe data into lastGeneratedData for display:', currentSwipeId);
|
||||||
} else {
|
} else {
|
||||||
// console.log('[RPG Companion] ℹ️ No stored data for swipe:', currentSwipeId);
|
// console.log('[RPG Companion] ℹ️ No stored data for swipe:', currentSwipeId);
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function renderOnPersonView(onPersonItems, viewMode = 'list') {
|
|||||||
if (viewMode === 'grid') {
|
if (viewMode === 'grid') {
|
||||||
// Grid view: card-style items
|
// Grid view: card-style items
|
||||||
itemsHtml = items.map((item, index) => {
|
itemsHtml = items.map((item, index) => {
|
||||||
const lockIconHtml = getLockIconHtml('userStats', `inventory.onPerson[${index}]`);
|
const lockIconHtml = getLockIconHtml('userStats', `inventory.onPerson.${item}`);
|
||||||
return `
|
return `
|
||||||
<div class="rpg-item-card" data-field="onPerson" data-index="${index}">
|
<div class="rpg-item-card" data-field="onPerson" data-index="${index}">
|
||||||
${lockIconHtml}
|
${lockIconHtml}
|
||||||
@@ -94,7 +94,7 @@ export function renderOnPersonView(onPersonItems, viewMode = 'list') {
|
|||||||
} else {
|
} else {
|
||||||
// List view: full-width rows
|
// List view: full-width rows
|
||||||
itemsHtml = items.map((item, index) => {
|
itemsHtml = items.map((item, index) => {
|
||||||
const lockIconHtml = getLockIconHtml('userStats', `inventory.onPerson[${index}]`);
|
const lockIconHtml = getLockIconHtml('userStats', `inventory.onPerson.${item}`);
|
||||||
return `
|
return `
|
||||||
<div class="rpg-item-row" data-field="onPerson" data-index="${index}">
|
<div class="rpg-item-row" data-field="onPerson" data-index="${index}">
|
||||||
${lockIconHtml}
|
${lockIconHtml}
|
||||||
@@ -163,7 +163,7 @@ export function renderClothingView(clothingItems, viewMode = 'list') {
|
|||||||
if (viewMode === 'grid') {
|
if (viewMode === 'grid') {
|
||||||
// Grid view: card-style items
|
// Grid view: card-style items
|
||||||
itemsHtml = items.map((item, index) => {
|
itemsHtml = items.map((item, index) => {
|
||||||
const lockIconHtml = getLockIconHtml('userStats', `inventory.clothing[${index}]`);
|
const lockIconHtml = getLockIconHtml('userStats', `inventory.clothing.${item}`);
|
||||||
return `
|
return `
|
||||||
<div class="rpg-item-card" data-field="clothing" data-index="${index}">
|
<div class="rpg-item-card" data-field="clothing" data-index="${index}">
|
||||||
${lockIconHtml}
|
${lockIconHtml}
|
||||||
@@ -176,7 +176,7 @@ export function renderClothingView(clothingItems, viewMode = 'list') {
|
|||||||
} else {
|
} else {
|
||||||
// List view: full-width rows
|
// List view: full-width rows
|
||||||
itemsHtml = items.map((item, index) => {
|
itemsHtml = items.map((item, index) => {
|
||||||
const lockIconHtml = getLockIconHtml('userStats', `inventory.clothing[${index}]`);
|
const lockIconHtml = getLockIconHtml('userStats', `inventory.clothing.${item}`);
|
||||||
return `
|
return `
|
||||||
<div class="rpg-item-row" data-field="clothing" data-index="${index}">
|
<div class="rpg-item-row" data-field="clothing" data-index="${index}">
|
||||||
${lockIconHtml}
|
${lockIconHtml}
|
||||||
@@ -291,7 +291,7 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
|
|||||||
if (viewMode === 'grid') {
|
if (viewMode === 'grid') {
|
||||||
// Grid view: card-style items
|
// Grid view: card-style items
|
||||||
itemsHtml = items.map((item, index) => {
|
itemsHtml = items.map((item, index) => {
|
||||||
const lockIconHtml = getLockIconHtml('userStats', `inventory.stored.${location}[${index}]`);
|
const lockIconHtml = getLockIconHtml('userStats', `inventory.stored.${location}.${item}`);
|
||||||
return `
|
return `
|
||||||
<div class="rpg-item-card" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}">
|
<div class="rpg-item-card" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}">
|
||||||
${lockIconHtml}
|
${lockIconHtml}
|
||||||
@@ -304,7 +304,7 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
|
|||||||
} else {
|
} else {
|
||||||
// List view: full-width rows
|
// List view: full-width rows
|
||||||
itemsHtml = items.map((item, index) => {
|
itemsHtml = items.map((item, index) => {
|
||||||
const lockIconHtml = getLockIconHtml('userStats', `inventory.stored.${location}[${index}]`);
|
const lockIconHtml = getLockIconHtml('userStats', `inventory.stored.${location}.${item}`);
|
||||||
return `
|
return `
|
||||||
<div class="rpg-item-row" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}">
|
<div class="rpg-item-row" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}">
|
||||||
${lockIconHtml}
|
${lockIconHtml}
|
||||||
@@ -393,7 +393,7 @@ export function renderAssetsView(assets, viewMode = 'list') {
|
|||||||
if (viewMode === 'grid') {
|
if (viewMode === 'grid') {
|
||||||
// Grid view: card-style items
|
// Grid view: card-style items
|
||||||
itemsHtml = items.map((item, index) => {
|
itemsHtml = items.map((item, index) => {
|
||||||
const lockIconHtml = getLockIconHtml('userStats', `inventory.assets[${index}]`);
|
const lockIconHtml = getLockIconHtml('userStats', `inventory.assets.${item}`);
|
||||||
return `
|
return `
|
||||||
<div class="rpg-item-card" data-field="assets" data-index="${index}">
|
<div class="rpg-item-card" data-field="assets" data-index="${index}">
|
||||||
${lockIconHtml}
|
${lockIconHtml}
|
||||||
@@ -406,7 +406,7 @@ export function renderAssetsView(assets, viewMode = 'list') {
|
|||||||
} else {
|
} else {
|
||||||
// List view: full-width rows
|
// List view: full-width rows
|
||||||
itemsHtml = items.map((item, index) => {
|
itemsHtml = items.map((item, index) => {
|
||||||
const lockIconHtml = getLockIconHtml('userStats', `inventory.assets[${index}]`);
|
const lockIconHtml = getLockIconHtml('userStats', `inventory.assets.${item}`);
|
||||||
return `
|
return `
|
||||||
<div class="rpg-item-row" data-field="assets" data-index="${index}">
|
<div class="rpg-item-row" data-field="assets" data-index="${index}">
|
||||||
${lockIconHtml}
|
${lockIconHtml}
|
||||||
|
|||||||
@@ -153,11 +153,20 @@ function namesMatch(cardName, aiName) {
|
|||||||
* Displays character cards with avatars, relationship badges, and traits.
|
* Displays character cards with avatars, relationship badges, and traits.
|
||||||
* Includes event listeners for editable character fields.
|
* Includes event listeners for editable character fields.
|
||||||
*/
|
*/
|
||||||
export function renderThoughts() {
|
export function renderThoughts({ preserveScroll = false } = {}) {
|
||||||
if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) {
|
if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) {
|
||||||
return;
|
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)
|
// Don't render if no data exists (e.g., after cache clear)
|
||||||
const thoughtsData = lastGeneratedData.characterThoughts || committedTrackerData.characterThoughts;
|
const thoughtsData = lastGeneratedData.characterThoughts || committedTrackerData.characterThoughts;
|
||||||
if (!thoughtsData) {
|
if (!thoughtsData) {
|
||||||
@@ -492,17 +501,19 @@ export function renderThoughts() {
|
|||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="rpg-character-card" data-character-name="${char.name}">
|
<div class="rpg-character-card" data-character-name="${char.name}">
|
||||||
<div class="rpg-character-avatar rpg-avatar-upload" data-character="${char.name}" title="Click to upload avatar">
|
<div class="rpg-character-header-row">
|
||||||
<img src="${characterPortrait}" alt="${char.name}" onerror="this.style.opacity='0.5';this.onerror=null;" />
|
<div class="rpg-character-avatar rpg-avatar-upload" data-character="${char.name}" title="Click to upload avatar">
|
||||||
${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>` : ''}
|
<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-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>
|
||||||
<div class="rpg-character-content">
|
<div class="rpg-character-content">
|
||||||
<div class="rpg-character-info">
|
<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>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Render custom fields dynamically
|
// Render custom fields dynamically
|
||||||
@@ -513,17 +524,20 @@ export function renderThoughts() {
|
|||||||
const fieldNameLower = field.name.toLowerCase();
|
const fieldNameLower = field.name.toLowerCase();
|
||||||
// Skip lock icons for thoughts field
|
// Skip lock icons for thoughts field
|
||||||
const showLock = !fieldNameLower.includes('thought');
|
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) {
|
if (showLock) {
|
||||||
const lockIconHtml = getLockIconHtml('characters', `${char.name}.${field.name}`);
|
const lockIconHtml = getLockIconHtml('characters', `${char.name}.${field.name}`);
|
||||||
html += `
|
html += `
|
||||||
<div class="rpg-character-field rpg-character-${fieldId}" style="position: relative;">
|
<div class="rpg-character-field rpg-character-${fieldId}" style="position: relative;">
|
||||||
${lockIconHtml}
|
${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>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
html += `
|
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');
|
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>';
|
html += '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,11 +693,44 @@ export function renderThoughts() {
|
|||||||
fileInput.trigger('click');
|
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
|
// Remove updating class after animation
|
||||||
if (extensionSettings.enableAnimations) {
|
if (extensionSettings.enableAnimations) {
|
||||||
setTimeout(() => $thoughtsContainer.removeClass('rpg-content-updating'), 600);
|
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
|
// Update chat overlay if enabled
|
||||||
if (extensionSettings.showThoughtsInChat) {
|
if (extensionSettings.showThoughtsInChat) {
|
||||||
updateChatThoughts();
|
updateChatThoughts();
|
||||||
@@ -795,6 +852,136 @@ export function removeCharacter(characterName) {
|
|||||||
renderThoughts();
|
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.
|
* Updates a specific character field in Present Characters data and re-renders.
|
||||||
* Works with the new multi-line format.
|
* Works with the new multi-line format.
|
||||||
@@ -862,18 +1049,27 @@ export function updateCharacterField(characterName, field, value) {
|
|||||||
} else if (field === 'emoji') {
|
} else if (field === 'emoji') {
|
||||||
char.emoji = value;
|
char.emoji = value;
|
||||||
} else if (field === 'Relationship') {
|
} 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
|
// First check if it's an emoji → convert to text
|
||||||
|
let relationshipValue;
|
||||||
if (emojiToRelationship[value]) {
|
if (emojiToRelationship[value]) {
|
||||||
char.Relationship = emojiToRelationship[value];
|
relationshipValue = emojiToRelationship[value];
|
||||||
} else {
|
} else {
|
||||||
// It's text - find matching relationship name (case-insensitive)
|
// It's text - find matching relationship name (case-insensitive)
|
||||||
const matchingRelationship = Object.keys(relationshipEmojis).find(
|
const matchingRelationship = Object.keys(relationshipEmojis).find(
|
||||||
name => name.toLowerCase() === value.toLowerCase()
|
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] relationshipEmojis:', relationshipEmojis);
|
||||||
// console.log('[RPG Companion] emojiToRelationship:', emojiToRelationship);
|
// console.log('[RPG Companion] emojiToRelationship:', emojiToRelationship);
|
||||||
} else if (field.toLowerCase() === 'thoughts' || field === (presentCharsConfig?.thoughts?.name || 'Thoughts')) {
|
} 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
|
// Check if it's a character stat
|
||||||
const isStatField = enabledCharStats.findIndex(s => s.name === field) !== -1;
|
const isStatField = enabledCharStats.findIndex(s => s.name === field) !== -1;
|
||||||
if (isStatField) {
|
if (isStatField) {
|
||||||
if (!char.stats) char.stats = {};
|
|
||||||
let numValue = parseInt(value.replace('%', '').trim());
|
let numValue = parseInt(value.replace('%', '').trim());
|
||||||
if (isNaN(numValue)) numValue = 0;
|
if (isNaN(numValue)) numValue = 0;
|
||||||
numValue = Math.max(0, Math.min(100, numValue));
|
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 {
|
||||||
|
// 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 {
|
} else {
|
||||||
// It's a custom detail field
|
// It's a custom detail field - store in details object
|
||||||
if (!char.details) char.details = {};
|
if (!char.details) char.details = {};
|
||||||
char.details[field] = value;
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// Save back to lastGeneratedData as JSON string (consistent with infoBox and userStats)
|
||||||
lastGeneratedData.characterThoughts = Array.isArray(parsedData) ? charactersArray : { ...parsedData, characters: charactersArray };
|
lastGeneratedData.characterThoughts = JSON.stringify(Array.isArray(parsedData) ? charactersArray : { ...parsedData, characters: charactersArray }, null, 2);
|
||||||
committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts;
|
committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts;
|
||||||
|
|
||||||
// console.log('[RPG Companion] Saved to lastGeneratedData.characterThoughts:', JSON.stringify(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] JSON format updated successfully');
|
||||||
// console.log('[RPG Companion] Updated data:', lastGeneratedData.characterThoughts);
|
// console.log('[RPG Companion] Updated data:', lastGeneratedData.characterThoughts);
|
||||||
|
|
||||||
// Re-render the thoughts panel to show updated value
|
// Re-render the thoughts panel to show updated value (preserve scroll position)
|
||||||
renderThoughts();
|
renderThoughts({ preserveScroll: true });
|
||||||
|
|
||||||
// Update chat thought overlays if editing thoughts
|
// Update chat thought overlays if editing thoughts
|
||||||
const thoughtsFieldName = presentCharsConfig?.thoughts?.name || 'Thoughts';
|
const thoughtsFieldName = presentCharsConfig?.thoughts?.name || 'Thoughts';
|
||||||
@@ -978,6 +1217,9 @@ export function updateCharacterField(characterName, field, value) {
|
|||||||
const thoughtsFieldName = presentCharsConfig?.thoughts?.name || 'Thoughts';
|
const thoughtsFieldName = presentCharsConfig?.thoughts?.name || 'Thoughts';
|
||||||
const isThoughtsField = field.toLowerCase() === 'thoughts' || field === thoughtsFieldName;
|
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
|
// First pass: check if Stats line exists and update other fields
|
||||||
for (let i = characterStartIndex; i < characterEndIndex; i++) {
|
for (let i = characterStartIndex; i < characterEndIndex; i++) {
|
||||||
const line = lines[i].trim();
|
const line = lines[i].trim();
|
||||||
@@ -985,35 +1227,37 @@ export function updateCharacterField(characterName, field, value) {
|
|||||||
if (line.startsWith('Stats:')) {
|
if (line.startsWith('Stats:')) {
|
||||||
statsLineExists = true;
|
statsLineExists = true;
|
||||||
statsLineIndex = i;
|
statsLineIndex = i;
|
||||||
|
continue; // Skip to next line
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for name update
|
||||||
if (field === 'name' && line.startsWith('- ')) {
|
if (field === 'name' && line.startsWith('- ')) {
|
||||||
lines[i] = `- ${value}`;
|
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());
|
// Check for Relationship field
|
||||||
parts[0] = value;
|
if (field === 'Relationship' && line.startsWith('Relationship:')) {
|
||||||
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:')) {
|
|
||||||
const emojiToRelationship = { '⚔️': 'Enemy', '⚖️': 'Neutral', '⭐': 'Friend', '❤️': 'Lover' };
|
const emojiToRelationship = { '⚔️': 'Enemy', '⚖️': 'Neutral', '⭐': 'Friend', '❤️': 'Lover' };
|
||||||
const relationshipValue = emojiToRelationship[value] || value;
|
const relationshipValue = emojiToRelationship[value] || value;
|
||||||
lines[i] = `Relationship: ${relationshipValue}`;
|
lines[i] = `Relationship: ${relationshipValue}`;
|
||||||
|
fieldUpdated = true;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
else if (isThoughtsField && line.startsWith(thoughtsFieldName + ':')) {
|
|
||||||
// Update thoughts field
|
// Check for Thoughts field
|
||||||
lines[i] = `${thoughtsFieldName}: ${value}`;
|
if (isThoughtsField && line.startsWith(thoughtsFieldName + ':')) {
|
||||||
// console.log('[RPG Companion] Updated thoughts:', lines[i]);
|
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 {
|
} else {
|
||||||
// Create new character block
|
// Create new character block (v3 text format only)
|
||||||
const dividerIndex = lines.findIndex(line => line.includes('---'));
|
const dividerIndex = lines.findIndex(line => line.includes('---'));
|
||||||
if (dividerIndex >= 0) {
|
if (dividerIndex >= 0) {
|
||||||
const newCharacterLines = [`- ${characterName}`];
|
const newCharacterLines = [`- ${characterName}`];
|
||||||
|
|
||||||
let detailsParts = [field === 'emoji' ? value : '😊'];
|
// Add custom detail fields as standalone lines
|
||||||
for (let i = 0; i < enabledFields.length; i++) {
|
for (const customField of enabledFields) {
|
||||||
detailsParts.push(field === enabledFields[i].name ? value : '');
|
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) {
|
if (presentCharsConfig?.relationshipFields?.length > 0) {
|
||||||
const emojiToRelationship = { '⚔️': 'Enemy', '⚖️': 'Neutral', '⭐': 'Friend', '❤️': 'Lover' };
|
const emojiToRelationship = { '⚔️': 'Enemy', '⚖️': 'Neutral', '⭐': 'Friend', '❤️': 'Lover' };
|
||||||
const relationshipValue = field === 'Relationship' ? (emojiToRelationship[value] || value) : 'Neutral';
|
const relationshipValue = field === 'Relationship' ? (emojiToRelationship[value] || value) : 'Neutral';
|
||||||
newCharacterLines.push(`Relationship: ${relationshipValue}`);
|
newCharacterLines.push(` Relationship: ${relationshipValue}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add Stats if enabled
|
||||||
if (enabledCharStats.length > 0) {
|
if (enabledCharStats.length > 0) {
|
||||||
const statsParts = enabledCharStats.map(s => {
|
const statsParts = enabledCharStats.map(s => {
|
||||||
if (field === s.name) {
|
if (field === s.name) {
|
||||||
@@ -1111,7 +1360,7 @@ export function updateCharacterField(characterName, field, value) {
|
|||||||
}
|
}
|
||||||
return `${s.name}: 0%`;
|
return `${s.name}: 0%`;
|
||||||
});
|
});
|
||||||
newCharacterLines.push(`Stats: ${statsParts.join(' | ')}`);
|
newCharacterLines.push(` Stats: ${statsParts.join(' | ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.splice(dividerIndex + 1, 0, ...newCharacterLines);
|
lines.splice(dividerIndex + 1, 0, ...newCharacterLines);
|
||||||
|
|||||||
@@ -23,6 +23,20 @@ import { isItemLocked, setItemLock } from '../generation/lockManager.js';
|
|||||||
import { updateFabWidgets } from '../ui/mobile.js';
|
import { updateFabWidgets } from '../ui/mobile.js';
|
||||||
import { getStatBarColors } from '../ui/theme.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
|
* Builds the user stats text string using custom stat names
|
||||||
* @returns {string} Formatted stats text for tracker
|
* @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
|
// Then, add any other numeric stats from extensionSettings that aren't in config
|
||||||
// (these could be custom stats the AI added or disabled stats)
|
// (these could be custom stats the AI added or disabled stats)
|
||||||
const customFields = config.statusSection?.customFields || [];
|
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]) => {
|
Object.entries(stats).forEach(([key, value]) => {
|
||||||
if (!processedIds.has(key) && !excludeFields.has(key) && typeof value === 'number') {
|
if (!processedIds.has(key) && !excludeFields.has(key) && typeof value === 'number') {
|
||||||
statsArray.push({
|
statsArray.push({
|
||||||
@@ -127,7 +141,7 @@ function updateUserStatsData() {
|
|||||||
|
|
||||||
// Add all custom status fields
|
// Add all custom status fields
|
||||||
for (const fieldName of customFields) {
|
for (const fieldName of customFields) {
|
||||||
const fieldKey = fieldName.toLowerCase();
|
const fieldKey = toFieldKey(fieldName);
|
||||||
jsonData.status[fieldKey] = stats[fieldKey] || 'None';
|
jsonData.status[fieldKey] = stats[fieldKey] || 'None';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,10 +348,13 @@ export function renderUserStats() {
|
|||||||
// Render custom status fields
|
// Render custom status fields
|
||||||
if (config.statusSection.customFields && config.statusSection.customFields.length > 0) {
|
if (config.statusSection.customFields && config.statusSection.customFields.length > 0) {
|
||||||
for (const fieldName of config.statusSection.customFields) {
|
for (const fieldName of config.statusSection.customFields) {
|
||||||
const fieldKey = fieldName.toLowerCase();
|
const fieldKey = toFieldKey(fieldName);
|
||||||
let fieldValue = stats[fieldKey] || 'None';
|
let fieldValue = stats[fieldKey] || 'None';
|
||||||
// Strip brackets if present (from JSON array format)
|
// Handle array format (from JSON)
|
||||||
if (typeof fieldValue === 'string') {
|
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();
|
fieldValue = fieldValue.replace(/^\[|\]$/g, '').trim();
|
||||||
}
|
}
|
||||||
html += `<div class="rpg-mood-conditions rpg-editable" contenteditable="true" data-field="${fieldKey}" title="Click to edit ${fieldName}">${fieldValue}</div>`;
|
html += `<div class="rpg-mood-conditions rpg-editable" contenteditable="true" data-field="${fieldKey}" title="Click to edit ${fieldName}">${fieldValue}</div>`;
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getContext } from '../../../../../../extensions.js';
|
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 { selected_group, getGroupMembers, groups } from '../../../../../../group-chats.js';
|
||||||
import { executeSlashCommandsOnChatInput } from '../../../../../../../scripts/slash-commands.js';
|
import { executeSlashCommandsOnChatInput } from '../../../../../../../scripts/slash-commands.js';
|
||||||
import { extensionSettings } from '../../core/state.js';
|
import { extensionSettings } from '../../core/state.js';
|
||||||
@@ -81,7 +82,7 @@ export class EncounterModal {
|
|||||||
// Store request for potential regeneration
|
// Store request for potential regeneration
|
||||||
this.lastRequest = { type: 'init', prompt: initPrompt };
|
this.lastRequest = { type: 'init', prompt: initPrompt };
|
||||||
|
|
||||||
const response = await generateRaw({
|
const response = await safeGenerateRaw({
|
||||||
prompt: initPrompt,
|
prompt: initPrompt,
|
||||||
quietToLoud: false
|
quietToLoud: false
|
||||||
});
|
});
|
||||||
@@ -816,7 +817,7 @@ export class EncounterModal {
|
|||||||
// Store request for potential regeneration
|
// Store request for potential regeneration
|
||||||
this.lastRequest = { type: 'action', action, prompt: actionPrompt };
|
this.lastRequest = { type: 'action', action, prompt: actionPrompt };
|
||||||
|
|
||||||
const response = await generateRaw({
|
const response = await safeGenerateRaw({
|
||||||
prompt: actionPrompt,
|
prompt: actionPrompt,
|
||||||
quietToLoud: false
|
quietToLoud: false
|
||||||
});
|
});
|
||||||
@@ -1078,7 +1079,7 @@ export class EncounterModal {
|
|||||||
// Generate summary
|
// Generate summary
|
||||||
const summaryPrompt = await buildCombatSummaryPrompt(currentEncounter.encounterLog, result);
|
const summaryPrompt = await buildCombatSummaryPrompt(currentEncounter.encounterLog, result);
|
||||||
|
|
||||||
const summaryResponse = await generateRaw({
|
const summaryResponse = await safeGenerateRaw({
|
||||||
prompt: summaryPrompt,
|
prompt: summaryPrompt,
|
||||||
quietToLoud: false
|
quietToLoud: false
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -794,12 +794,17 @@ export function setupMobileKeyboardHandling() {
|
|||||||
/**
|
/**
|
||||||
* Handles focus on contenteditable fields to ensure they're visible when keyboard appears.
|
* 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.
|
* 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() {
|
export function setupContentEditableScrolling() {
|
||||||
const $panel = $('#rpg-companion-panel');
|
const $panel = $('#rpg-companion-panel');
|
||||||
|
|
||||||
// Use event delegation for all contenteditable fields
|
// Use event delegation for all contenteditable fields
|
||||||
$panel.on('focusin', '[contenteditable="true"]', function(e) {
|
$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);
|
const $field = $(this);
|
||||||
|
|
||||||
// Small delay to let keyboard animate in
|
// Small delay to let keyboard animate in
|
||||||
|
|||||||
@@ -105,41 +105,106 @@ function getCurrentTime() {
|
|||||||
return null;
|
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
|
* Parse weather text to determine effect type
|
||||||
*/
|
*/
|
||||||
function parseWeatherType(weatherText) {
|
function parseWeatherType(weatherText) {
|
||||||
if (!weatherText) return 'none';
|
if (!weatherText) return "none";
|
||||||
|
|
||||||
const text = weatherText.toLowerCase();
|
const text = weatherText.toLowerCase();
|
||||||
|
|
||||||
// Check for specific weather conditions (order matters - check combined effects first)
|
for (const language of Object.values(WEATHER_PATTERNS_BY_LANGUAGE)) {
|
||||||
if (text.includes('blizzard')) {
|
for (const { id, patterns } of language) {
|
||||||
return 'blizzard'; // Snow + Wind
|
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";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2118,8 +2118,8 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
|
|||||||
/* Present Characters - Character Cards */
|
/* Present Characters - Character Cards */
|
||||||
.rpg-character-card {
|
.rpg-character-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
flex-direction: column;
|
||||||
gap: clamp(8px, 1vw, 12px);
|
gap: clamp(6px, 0.8vh, 10px);
|
||||||
padding: clamp(6px, 1vh, 8px);
|
padding: clamp(6px, 1vh, 8px);
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
border-radius: clamp(4px, 0.5vh, 6px);
|
border-radius: clamp(4px, 0.5vh, 6px);
|
||||||
@@ -2157,6 +2157,14 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
|
|||||||
border-color: var(--rpg-highlight);
|
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 */
|
/* Character avatar container with relationship badge */
|
||||||
.rpg-character-avatar {
|
.rpg-character-avatar {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -2164,8 +2172,8 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rpg-character-avatar img {
|
.rpg-character-avatar img {
|
||||||
width: clamp(35px, 6vh, 45px);
|
width: clamp(30px, 5vh, 40px);
|
||||||
height: clamp(35px, 6vh, 45px);
|
height: clamp(30px, 5vh, 40px);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 2px solid var(--rpg-highlight);
|
border: 2px solid var(--rpg-highlight);
|
||||||
object-fit: cover;
|
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);
|
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 {
|
.rpg-character-content {
|
||||||
flex: 1;
|
width: 100%;
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: clamp(3px, 0.5vh, 5px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2271,13 +2278,14 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
|
|||||||
background: var(--rpg-highlight);
|
background: var(--rpg-highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Character header with emoji and name */
|
/* Character header with emoji and name - now inside header row */
|
||||||
.rpg-character-header {
|
.rpg-character-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: clamp(4px, 0.5vw, 6px);
|
gap: clamp(4px, 0.5vw, 6px);
|
||||||
flex-wrap: nowrap; /* Prevent wrapping */
|
flex-wrap: nowrap; /* Prevent wrapping */
|
||||||
position: relative;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rpg-character-emoji {
|
.rpg-character-emoji {
|
||||||
@@ -2329,6 +2337,40 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
|
|||||||
transform: scale(0.95);
|
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 */
|
/* Character traits/status line and custom fields */
|
||||||
.rpg-character-traits,
|
.rpg-character-traits,
|
||||||
.rpg-character-field {
|
.rpg-character-field {
|
||||||
@@ -2340,12 +2382,20 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
|
|||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Placeholder for empty editable character fields */
|
/* Empty field placeholder using data-placeholder attribute */
|
||||||
.rpg-character-field.rpg-editable:empty::before {
|
.rpg-editable.rpg-empty-field:empty::before {
|
||||||
content: 'Click to edit...';
|
content: attr(data-placeholder);
|
||||||
color: var(--rpg-highlight);
|
color: var(--rpg-highlight);
|
||||||
opacity: 0.5;
|
opacity: 0.4;
|
||||||
font-style: italic;
|
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 */
|
/* Character stats display */
|
||||||
|
|||||||
+2
-2
@@ -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.
|
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>
|
</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;">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div style="margin-top: 20px; text-align: center;">
|
<div style="margin-top: 20px; text-align: center;">
|
||||||
|
|||||||
Reference in New Issue
Block a user