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
|
||||
|
||||
### 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,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
@@ -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
@@ -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
@@ -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:",
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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>');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 */
|
||||
.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
@@ -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;">
|
||||
|
||||
Reference in New Issue
Block a user