From 28802b1ad79d025125b86fa0a65dcb8932f12266 Mon Sep 17 00:00:00 2001 From: ARIA Date: Sun, 12 Jul 2026 15:24:58 +0200 Subject: [PATCH] Fixes #16: Code quality enhancements Part 3 - Consolidate settings defaults: config.js is now single source of truth, state.js imports from config.js (eliminates ~300 lines of duplication) - Validator factory: createStringValidator() unifies sanitizeLocationName/ sanitizeItemName with configurable rules (blocked names, max length, rejects) - Refactor promptBuilder: Extract getCharacterCardsInfo into buildNarratorCardInfo, buildGroupCardInfo, buildSingleCardInfo, appendCharacterFields - Error handling: jsonRepair.js silent failures now log to console.debug - Testing: Fix ESM support (babel.config.json + jest.config.cjs transform), add security.test.js with 24 tests for validator factory and security utilities - Total: 78 tests passing (was 54) --- __tests__/security.test.js | 196 ++++++++++++++ babel.config.json | 9 + jest.config.cjs | 4 + src/core/config.js | 298 +++++++++++++++++++-- src/core/state.js | 339 +----------------------- src/systems/generation/promptBuilder.js | 138 +++++----- src/utils/jsonRepair.js | 5 +- src/utils/security.js | 124 +++++---- 8 files changed, 649 insertions(+), 464 deletions(-) create mode 100644 __tests__/security.test.js create mode 100644 babel.config.json diff --git a/__tests__/security.test.js b/__tests__/security.test.js new file mode 100644 index 0000000..f3b8c2b --- /dev/null +++ b/__tests__/security.test.js @@ -0,0 +1,196 @@ +import { createStringValidator, sanitizeLocationName, sanitizeItemName, validateStoredInventory, cleanItemString, MAX_ITEMS_PER_SECTION } from '../src/utils/security.js'; + +describe('createStringValidator', () => { + test('creates a validator that trims whitespace', () => { + const validator = createStringValidator({ + label: 'test', + maxLength: 100, + checkBlockedNames: false + }); + expect(validator(' hello ')).toBe('hello'); + }); + + test('returns null for non-string input', () => { + const validator = createStringValidator({ + label: 'test', + maxLength: 100 + }); + expect(validator(null)).toBeNull(); + expect(validator(undefined)).toBeNull(); + expect(validator(123)).toBeNull(); + expect(validator('')).toBeNull(); + }); + + test('truncates strings exceeding maxLength', () => { + const validator = createStringValidator({ + label: 'test', + maxLength: 5 + }); + const result = validator('hello world'); + expect(result).toBe('hello'); + expect(result.length).toBe(5); + }); + + test('rejects blocked property names when checkBlockedNames is true', () => { + const validator = createStringValidator({ + label: 'property', + maxLength: 100, + checkBlockedNames: true + }); + expect(validator('__proto__')).toBeNull(); + expect(validator('constructor')).toBeNull(); + expect(validator('prototype')).toBeNull(); + }); + + test('allows blocked property names when checkBlockedNames is false', () => { + const validator = createStringValidator({ + label: 'item', + maxLength: 100, + checkBlockedNames: false + }); + // __proto__ is allowed when checkBlockedNames is false + expect(validator('__proto__')).toBe('__proto__'); + }); + + test('rejects additional values via additionalRejects', () => { + const validator = createStringValidator({ + label: 'item', + maxLength: 100, + additionalRejects: ['none', 'null', 'undefined'], + checkBlockedNames: false + }); + expect(validator('none')).toBeNull(); + expect(validator('NONE')).toBeNull(); + expect(validator('None')).toBeNull(); + expect(validator('Sword')).toBe('Sword'); + }); + + test('is case-insensitive for blocked names', () => { + const validator = createStringValidator({ + label: 'test', + maxLength: 100, + checkBlockedNames: true + }); + expect(validator('__PROTO__')).toBeNull(); + expect(validator('__Proto__')).toBeNull(); + expect(validator('CONSTRUCTOR')).toBeNull(); + }); + + test('returns valid input unchanged', () => { + const validator = createStringValidator({ + label: 'test', + maxLength: 100 + }); + expect(validator('ValidName')).toBe('ValidName'); + expect(validator('another-valid-name')).toBe('another-valid-name'); + }); +}); + +describe('sanitizeLocationName', () => { + test('allows valid location names', () => { + expect(sanitizeLocationName('Home')).toBe('Home'); + expect(sanitizeLocationName('Tavern')).toBe('Tavern'); + expect(sanitizeLocationName('Forest Clearing')).toBe('Forest Clearing'); + }); + + test('blocks dangerous property names', () => { + expect(sanitizeLocationName('__proto__')).toBeNull(); + expect(sanitizeLocationName('constructor')).toBeNull(); + expect(sanitizeLocationName('prototype')).toBeNull(); + }); + + test('truncates overly long names', () => { + const longName = 'A'.repeat(250); + const result = sanitizeLocationName(longName); + expect(result.length).toBe(200); + }); + + test('returns null for invalid input', () => { + expect(sanitizeLocationName(null)).toBeNull(); + expect(sanitizeLocationName('')).toBeNull(); + expect(sanitizeLocationName(' ')).toBeNull(); + }); +}); + +describe('sanitizeItemName', () => { + test('allows valid item names', () => { + expect(sanitizeItemName('Sword')).toBe('Sword'); + expect(sanitizeItemName('Health Potion')).toBe('Health Potion'); + }); + + test('rejects "none" as an item name', () => { + expect(sanitizeItemName('none')).toBeNull(); + expect(sanitizeItemName('None')).toBeNull(); + expect(sanitizeItemName('NONE')).toBeNull(); + }); + + test('truncates overly long names', () => { + const longName = 'A'.repeat(600); + const result = sanitizeItemName(longName); + expect(result.length).toBe(500); + }); + + test('returns null for invalid input', () => { + expect(sanitizeItemName(null)).toBeNull(); + expect(sanitizeItemName('')).toBeNull(); + }); +}); + +describe('validateStoredInventory', () => { + test('returns cleaned inventory object', () => { + const input = { Home: 'Sword, Shield' }; + const result = validateStoredInventory(input); + expect(result).toEqual({ Home: 'Sword, Shield' }); + }); + + test('removes dangerous keys', () => { + const input = { Home: 'Sword' }; + // __proto__ as a key is blocked by sanitizeLocationName + Object.setPrototypeOf(input, Object.create(null)); + input['__proto__'] = 'malicious'; + const result = validateStoredInventory(input); + // Result should not have __proto__ as an own property + expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(false); + expect(result).toHaveProperty('Home'); + }); + + test('returns empty object for invalid input', () => { + expect(validateStoredInventory(null)).toEqual({}); + expect(validateStoredInventory(undefined)).toEqual({}); + expect(validateStoredInventory('string')).toEqual({}); + expect(validateStoredInventory([])).toEqual({}); + }); + + test('skips non-string values', () => { + const input = { Home: 123, Barn: 'Hay' }; + const result = validateStoredInventory(input); + expect(result).not.toHaveProperty('Home'); + expect(result).toHaveProperty('Barn'); + }); +}); + +describe('cleanItemString', () => { + test('returns clean item string', () => { + expect(cleanItemString('Sword, Shield')).toBe('Sword, Shield'); + }); + + test('item sanitizer does not block __proto__ (by design - checkBlockedNames is false)', () => { + // sanitizeItemName has checkBlockedNames: false, so __proto__ is allowed as item name + // Only sanitizeLocationName blocks __proto__ (for object keys) + const result = cleanItemString('Sword, Shield'); + expect(result).toContain('Sword'); + }); + + test('strips markdown formatting', () => { + const result = cleanItemString('**Sword**, *Shield*'); + expect(result).not.toContain('**'); + expect(result).not.toContain('*'); + }); +}); + +describe('MAX_ITEMS_PER_SECTION', () => { + test('is a positive number', () => { + expect(MAX_ITEMS_PER_SECTION).toBeGreaterThan(0); + expect(typeof MAX_ITEMS_PER_SECTION).toBe('number'); + }); +}); diff --git a/babel.config.json b/babel.config.json new file mode 100644 index 0000000..dca1c94 --- /dev/null +++ b/babel.config.json @@ -0,0 +1,9 @@ +{ + "presets": [ + ["@babel/preset-env", { + "targets": { + "node": "current" + } + }] + ] +} diff --git a/jest.config.cjs b/jest.config.cjs index bfb2c78..9466f74 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -1,4 +1,8 @@ module.exports = { testEnvironment: 'node', testMatch: ['**/__tests__/**/*.test.js'], + transform: { + '^.+\\.js$': 'babel-jest', + }, + transformIgnorePatterns: ['/node_modules/'], }; diff --git a/src/core/config.js b/src/core/config.js index f7f2794..8a008d9 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -13,17 +13,19 @@ export const extensionName = 'third-party/rpg-companion-sillytavern'; * This supports both global (public/extensions) and user-specific (data/default-user/extensions) installations */ const currentScriptPath = import.meta.url; -const isUserExtension = currentScriptPath.includes('/data/') || currentScriptPath.includes('\\data\\'); +const isUserExtension = currentScriptPath.includes('/data/') || currentScriptPath.includes('\\\\data\\\\'); export const extensionFolderPath = isUserExtension ? `data/default-user/extensions/${extensionName}` : `scripts/extensions/${extensionName}`; /** - * Default extension settings + * Default extension settings — single source of truth for all setting defaults. + * Imported by state.js to initialize extensionSettings. */ export const defaultSettings = { + settingsVersion: 6, // Version number for settings migrations enabled: true, - autoUpdate: true, + autoUpdate: false, updateDepth: 4, // How many messages to include in the context generationMode: 'together', // 'separate' or 'together' - whether to generate with main response or separately showUserStats: true, @@ -33,37 +35,93 @@ export const defaultSettings = { enableThoughtBasedExpressions: false, hideDefaultExpressionDisplay: false, showInventory: true, // Show inventory section (v2 system) + showEquipment: true, // Show equipment section showQuests: true, // Show quests section - showLockIcons: true, // Show lock/unlock icons on tracker items showThoughtsInChat: true, // Show thoughts overlay in chat thoughtsInChatStyle: 'corner', // 'corner' or 'inline' + narratorMode: false, // Use character card as narrator instead of fixed character references + customNarratorPrompt: '', // Custom narrator mode prompt text (empty = use default) + customContextInstructionsPrompt: '', // Custom context instructions prompt text (empty = use default) enableHtmlPrompt: false, // Enable immersive HTML prompt injection + customHtmlPrompt: '', // Custom HTML prompt text (empty = use default) + enableDialogueColoring: false, // Enable dialogue coloring prompt injection + customDialogueColoringPrompt: '', // Custom dialogue coloring prompt text (empty = use default) + enableDeceptionSystem: false, // Enable deception tracking with tags + customDeceptionPrompt: '', // Custom deception prompt text (empty = use default) + enableOmniscienceFilter: false, // Enable omniscience filter with tags + customOmnisciencePrompt: '', // Custom omniscience filter prompt text (empty = use default) + enableCYOA: false, // Enable "Choose Your Own Adventure" formatting with action choices + customCYOAPrompt: '', // Custom CYOA prompt text (empty = use default) enableSpotifyMusic: false, // Enable Spotify music integration (asks AI for Spotify URLs) customSpotifyPrompt: '', // Custom Spotify prompt text (empty = use default) - // Controls when the extension skips injecting tracker instructions/examples/HTML - // into generations that appear to be user-injected instructions. Valid values: - // - 'none' -> never skip (legacy behavior: always inject) - // - 'guided' -> skip for any guided / instruct or quiet_prompt generation - // - 'impersonation' -> skip only for impersonation-style guided generations - // This setting helps compatibility with other extensions like GuidedGenerations. - skipInjectionsForGuided: 'none', - enablePlotButtons: true, // Show plot progression buttons above chat input - saveTrackerHistory: false, // Save tracker data in chat history for each message + + enableDynamicWeather: true, // Enable dynamic weather effects based on Info Box weather field (v2: enabled by default) + weatherBackground: true, // Show weather effects in background (behind chat) + weatherForeground: false, // Show weather effects in foreground (on top of chat) + dismissedHolidayPromo: false, // User dismissed the holiday promotion banner + showHtmlToggle: true, // Show Immersive HTML toggle in main panel + showDialogueColoringToggle: true, // Show Dialogue Coloring toggle in main panel (enabled by default) + showDeceptionToggle: true, // Show Deception System toggle in main panel + showOmniscienceToggle: true, // Show Omniscience Filter toggle in main panel + showCYOAToggle: true, // Show CYOA toggle in main panel + showSpotifyToggle: true, // Show Spotify Music toggle in main panel + + showDynamicWeatherToggle: true, // Show Dynamic Weather Effects toggle in main panel + showNarratorMode: true, // Show Narrator Mode toggle in main panel + showAutoAvatars: true, // Show Auto-generate Avatars toggle in main panel + skipInjectionsForGuided: 'none', // skip injections for instruct injections and quiet prompts (GuidedGenerations compatibility) + enableRandomizedPlot: true, // Show randomized plot progression button above chat input + enableNaturalPlot: true, // Show natural plot progression button above chat input + // History persistence settings - inject selected tracker data into historical messages + historyPersistence: { + enabled: false, // Master toggle for history persistence feature + messageCount: 5, // Number of messages to include (0 = all available) + injectionPosition: 'assistant_message_end', // 'user_message_end', 'assistant_message_end', 'extra_user_message', 'extra_assistant_message' + contextPreamble: '', // Optional custom preamble text (empty = use default short one) + sendAllEnabledOnRefresh: false // If true, sends all enabled stats from preset instead of only persistInHistory-enabled stats on Refresh RPG Info + }, panelPosition: 'right', // 'left', 'right', or 'top' theme: 'default', // Theme: default, sci-fi, fantasy, cyberpunk, custom customColors: { bg: '#1a1a2e', + bgOpacity: 100, accent: '#16213e', + accentOpacity: 100, text: '#eaeaea', - highlight: '#e94560' + textOpacity: 100, + highlight: '#e94560', + highlightOpacity: 100 }, statBarColorLow: '#cc3333', // Color for low stat values (red) + statBarColorLowOpacity: 100, statBarColorHigh: '#33cc66', // Color for high stat values (green) + statBarColorHighOpacity: 100, enableAnimations: true, // Enable smooth animations for stats and content updates mobileFabPosition: { top: 'calc(var(--topBarBlockSize) + 60px)', right: '12px' }, // Saved position for mobile FAB button + // Mobile FAB widget display options (8-position system around the button) + mobileFabWidgets: { + enabled: true, // Master toggle for FAB widgets + weatherIcon: { enabled: true, position: 0 }, // Weather emoji (☀️, 🌧️, etc.) + weatherDesc: { enabled: true, position: 1 }, // Weather description text + clock: { enabled: true, position: 2 }, // Current time display + date: { enabled: true, position: 3 }, // Date display + location: { enabled: true, position: 4 }, // Location name + stats: { enabled: true, position: 5 }, // All stats as compact numbers + attributes: { enabled: true, position: 6 } // Compact RPG attributes display + }, + // Desktop strip widget display options (shown in collapsed panel strip) + desktopStripWidgets: { + enabled: true, // Master toggle for strip widgets (enabled by default) + weatherIcon: { enabled: true }, // Weather emoji (☀️, 🌧️, etc.) + clock: { enabled: true }, // Current time display + date: { enabled: true }, // Date display + location: { enabled: true }, // Location name + stats: { enabled: true }, // All stats as compact numbers + attributes: { enabled: true } // Compact RPG attributes display + }, userStats: { health: 100, satiety: 100, @@ -72,14 +130,162 @@ export const defaultSettings = { arousal: 0, mood: '😐', conditions: 'None', - /** @type {InventoryV2} */ + skills: [], inventory: { version: 2, onPerson: "None", + clothing: "None", stored: {}, assets: "None" + }, + equipment: { + items: [], // Array of {id, name, type, slot, stats: {str: 2, dex: 1, ...}, description} + slots: { + helmet: null, + ring1: null, + ring2: null, + ring3: null, + ring4: null, + ring5: null, + ring6: null, + ring7: null, + ring8: null, + ring9: null, + ring10: null, + necklace: null, + bodyArmor: null, + pants: null, + shoes: null, + gloves: null, + accessory1: null, + accessory2: null, + accessory3: null + } } }, + statNames: { + health: 'Health', + satiety: 'Satiety', + energy: 'Energy', + hygiene: 'Hygiene', + arousal: 'Arousal' + }, + // Tracker customization configuration + trackerConfig: { + userStats: { + // Stats display mode: 'percentage' or 'number' + statsDisplayMode: 'percentage', + // Array of custom stats (allows add/remove/rename) + customStats: [ + { id: 'health', name: 'Health', enabled: true, persistInHistory: false, maxValue: 100 }, + { id: 'satiety', name: 'Satiety', enabled: true, persistInHistory: false, maxValue: 100 }, + { id: 'energy', name: 'Energy', enabled: true, persistInHistory: false, maxValue: 100 }, + { id: 'hygiene', name: 'Hygiene', enabled: true, persistInHistory: false, maxValue: 100 }, + { id: 'arousal', name: 'Arousal', enabled: true, persistInHistory: false, maxValue: 100 } + ], + // RPG Attributes (customizable D&D-style attributes) + showRPGAttributes: true, + showLevel: true, // Show/hide level in UI and prompts + alwaysSendAttributes: false, // If true, always send attributes; if false, only send with dice rolls + rpgAttributes: [ + { id: 'str', name: 'STR', enabled: true, persistInHistory: false }, + { id: 'dex', name: 'DEX', enabled: true, persistInHistory: false }, + { id: 'con', name: 'CON', enabled: true, persistInHistory: false }, + { id: 'int', name: 'INT', enabled: true, persistInHistory: false }, + { id: 'wis', name: 'WIS', enabled: true, persistInHistory: false }, + { id: 'cha', name: 'CHA', enabled: true, persistInHistory: false } + ], + // Status section config + statusSection: { + enabled: true, + showMoodEmoji: true, + customFields: ['Conditions'], // User can edit what to track + persistInHistory: false // Persist status in historical messages + }, + // Optional skills field + skillsSection: { + enabled: false, + label: 'Skills', // User-editable + customFields: [], // Array of skill names + persistInHistory: false // Persist skills in historical messages + }, + // Inventory persistence + inventoryPersistInHistory: false, // Persist inventory in historical messages + // Quests persistence + questsPersistInHistory: false // Persist quests in historical messages + }, + infoBox: { + widgets: { + date: { enabled: true, format: 'Weekday, Month, Year', persistInHistory: true }, // Date enabled by default for history + weather: { enabled: true, persistInHistory: true }, // Weather enabled by default for history + temperature: { enabled: true, unit: 'C', persistInHistory: false }, // 'C' or 'F' + time: { enabled: true, persistInHistory: true }, // Time enabled by default for history + location: { enabled: true, persistInHistory: true }, // Location enabled by default for history + recentEvents: { enabled: true, persistInHistory: false } + } + }, + presentCharacters: { + // Fixed fields (always shown) + showEmoji: true, + showName: true, + // Relationship fields configuration + relationships: { + enabled: true, + // Relationship to emoji mapping (shown on character portraits) + relationshipEmojis: { + 'Lover': '❤️', + 'Friend': '⭐', + 'Ally': '🤝', + 'Enemy': '⚔️', + 'Neutral': '⚖️' + } + }, + // Legacy fields kept for backward compatibility + relationshipFields: ['Lover', 'Friend', 'Ally', 'Enemy', 'Neutral'], + relationshipEmojis: { + 'Lover': '❤️', + 'Friend': '⭐', + 'Ally': '🤝', + 'Enemy': '⚔️', + 'Neutral': '⚖️' + }, + // Custom fields (appearance, demeanor, etc. - shown after relationship, separated by |) + customFields: [ + { id: 'appearance', name: 'Appearance', enabled: true, description: 'Visible physical appearance (clothing, hair, notable features)', persistInHistory: false }, + { id: 'demeanor', name: 'Demeanor', enabled: true, description: 'Observable demeanor or emotional state', persistInHistory: false } + ], + // Thoughts configuration (separate line) + thoughts: { + enabled: true, + name: 'Thoughts', + description: 'Internal Monologue (in first person from character\'s POV, up to three sentences long)', + persistInHistory: false + }, + // Character stats toggle (optional feature) + characterStats: { + enabled: false, + customStats: [ + { id: 'health', name: 'Health', enabled: true }, + { id: 'arousal', name: 'Arousal', enabled: true } + ] + } + } + }, + quests: { + main: "None", // Current main quest title + optional: [] // Array of optional quest titles + }, + infoBox: JSON.stringify({ + date: { value: new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) }, + weather: { emoji: '☀️', forecast: 'Clear skies' }, + temperature: { value: 20, unit: 'C' }, + time: { start: '00:00', end: '00:00' }, + location: { value: 'Unknown Location' } + }, null, 2), + characterThoughts: JSON.stringify({ + characters: [] + }, null, 2), + level: 1, // User's character level classicStats: { str: 10, dex: 10, @@ -89,5 +295,65 @@ export const defaultSettings = { cha: 10 }, lastDiceRoll: null, // Store last dice roll result - collapsedInventoryLocations: [] // Array of collapsed storage location names + showDiceDisplay: true, // Show the "Last Roll" display in the panel + collapsedInventoryLocations: [], // Array of collapsed storage location names + inventoryViewModes: { + onPerson: 'list', // 'list' or 'grid' view mode for On Person section + stored: 'list', // 'list' or 'grid' view mode for Stored section + assets: 'list' // 'list' or 'grid' view mode for Assets section + }, + npcAvatars: {}, // Store custom avatar images for NPCs (key: character name, value: base64 data URI) + // Combat encounter settings + encounterSettings: { + enabled: true, // Show Start Encounter button above chat input + historyDepth: 8, // Number of recent messages to include in combat initialization + autoSaveLogs: false // Save detailed combat logs to file + }, + // Auto avatar generation settings + autoGenerateAvatars: true, // Master toggle for auto-generating avatars + avatarLLMCustomInstruction: '', // Custom instruction for LLM prompt generation + // External API settings for 'external' generation mode + externalApiSettings: { + baseUrl: '', // OpenAI-compatible API base URL (e.g., "https://api.openai.com/v1") + // apiKey is NOT stored here for security. It is stored in localStorage('rpg_companion_api_key') + model: '', // Model identifier (e.g., "gpt-4o-mini") + maxTokens: 8192, // Maximum tokens for generation + temperature: 0.7 // Temperature setting for generation + }, + // Lock state for tracker items (v3 JSON format feature) + lockedItems: { + stats: [], // Array of locked stat IDs (e.g., ["health", "satiety"]) + skills: [], // Array of locked skill names (e.g., ["Cooking", "Swordsmanship"]) + inventory: { + onPerson: [], // Array of locked item indices (e.g., [0, 2]) + clothing: [], // Array of locked item indices + stored: {}, // Object with location keys, each containing array of locked indices (e.g., {"Home": [0, 1]}) + assets: [] // Array of locked asset indices + }, + quests: { + main: false, // Boolean for main quest lock + optional: [] // Array of locked optional quest indices (e.g., [0, 2]) + }, + infoBox: { + date: false, // Boolean for date widget lock + weather: false, // Boolean for weather widget lock + temperature: false, // Boolean for temperature widget lock + time: false, // Boolean for time widget lock + location: false, // Boolean for location widget lock + recentEvents: false // Boolean for recent events widget lock + }, + characters: {} // Object mapping character names to their locked fields (e.g., {"Sarah": {relationship: true, thoughts: false}}) + }, + // Preset management for tracker configurations + presetManager: { + // Map of preset ID to preset data (contains name and trackerConfig) + presets: {}, + // Map of character/group entity to preset ID (e.g., "char_0": "preset_123", "group_abc": "preset_456") + // Note: This is stored separately and NOT exported with presets + characterAssociations: {}, + // Currently active preset ID + activePresetId: null, + // Default preset ID (used when no character association exists) + defaultPresetId: null + } }; diff --git a/src/core/state.js b/src/core/state.js index ee16594..ac687d2 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -3,347 +3,16 @@ * Centralizes all extension state variables */ +import { defaultSettings } from './config.js'; + // Type imports /** @typedef {import('../types/inventory.js').InventoryV2} InventoryV2 */ /** * Extension settings - persisted to SillyTavern settings + * Initialized from config.js defaultSettings (single source of truth) */ -export let extensionSettings = { - settingsVersion: 6, // Version number for settings migrations - enabled: true, - autoUpdate: false, - updateDepth: 4, // How many messages to include in the context - generationMode: 'together', // 'separate' or 'together' - whether to generate with main response or separately - showUserStats: true, - showInfoBox: true, - showCharacterThoughts: true, - showAlternatePresentCharactersPanel: false, - enableThoughtBasedExpressions: false, - hideDefaultExpressionDisplay: false, - showInventory: true, // Show inventory section (v2 system) - showEquipment: true, // Show equipment section - showQuests: true, // Show quests section - showThoughtsInChat: true, // Show thoughts overlay in chat - thoughtsInChatStyle: 'corner', // 'corner' or 'inline' - narratorMode: false, // Use character card as narrator instead of fixed character references - customNarratorPrompt: '', // Custom narrator mode prompt text (empty = use default) - customContextInstructionsPrompt: '', // Custom context instructions prompt text (empty = use default) - enableHtmlPrompt: false, // Enable immersive HTML prompt injection - customHtmlPrompt: '', // Custom HTML prompt text (empty = use default) - enableDialogueColoring: false, // Enable dialogue coloring prompt injection - customDialogueColoringPrompt: '', // Custom dialogue coloring prompt text (empty = use default) - enableDeceptionSystem: false, // Enable deception tracking with tags - customDeceptionPrompt: '', // Custom deception prompt text (empty = use default) - enableOmniscienceFilter: false, // Enable omniscience filter with tags - customOmnisciencePrompt: '', // Custom omniscience filter prompt text (empty = use default) - enableCYOA: false, // Enable "Choose Your Own Adventure" formatting with action choices - customCYOAPrompt: '', // Custom CYOA prompt text (empty = use default) - enableSpotifyMusic: false, // Enable Spotify music integration (asks AI for Spotify URLs) - customSpotifyPrompt: '', // Custom Spotify prompt text (empty = use default) - - enableDynamicWeather: true, // Enable dynamic weather effects based on Info Box weather field (v2: enabled by default) - weatherBackground: true, // Show weather effects in background (behind chat) - weatherForeground: false, // Show weather effects in foreground (on top of chat) - dismissedHolidayPromo: false, // User dismissed the holiday promotion banner - showHtmlToggle: true, // Show Immersive HTML toggle in main panel - showDialogueColoringToggle: true, // Show Dialogue Coloring toggle in main panel (enabled by default) - showDeceptionToggle: true, // Show Deception System toggle in main panel - showOmniscienceToggle: true, // Show Omniscience Filter toggle in main panel - showCYOAToggle: true, // Show CYOA toggle in main panel - showSpotifyToggle: true, // Show Spotify Music toggle in main panel - - showDynamicWeatherToggle: true, // Show Dynamic Weather Effects toggle in main panel - showNarratorMode: true, // Show Narrator Mode toggle in main panel - showAutoAvatars: true, // Show Auto-generate Avatars toggle in main panel - skipInjectionsForGuided: 'none', // skip injections for instruct injections and quiet prompts (GuidedGenerations compatibility) - enableRandomizedPlot: true, // Show randomized plot progression button above chat input - enableNaturalPlot: true, // Show natural plot progression button above chat input - // History persistence settings - inject selected tracker data into historical messages - historyPersistence: { - enabled: false, // Master toggle for history persistence feature - messageCount: 5, // Number of messages to include (0 = all available) - injectionPosition: 'assistant_message_end', // 'user_message_end', 'assistant_message_end', 'extra_user_message', 'extra_assistant_message' - contextPreamble: '', // Optional custom preamble text (empty = use default short one) - sendAllEnabledOnRefresh: false // If true, sends all enabled stats from preset instead of only persistInHistory-enabled stats on Refresh RPG Info - }, - panelPosition: 'right', // 'left', 'right', or 'top' - theme: 'default', // Theme: default, sci-fi, fantasy, cyberpunk, custom - customColors: { - bg: '#1a1a2e', - bgOpacity: 100, - accent: '#16213e', - accentOpacity: 100, - text: '#eaeaea', - textOpacity: 100, - highlight: '#e94560', - highlightOpacity: 100 - }, - statBarColorLow: '#cc3333', // Color for low stat values (red) - statBarColorLowOpacity: 100, - statBarColorHigh: '#33cc66', // Color for high stat values (green) - statBarColorHighOpacity: 100, - enableAnimations: true, // Enable smooth animations for stats and content updates - mobileFabPosition: { - top: 'calc(var(--topBarBlockSize) + 60px)', - right: '12px' - }, // Saved position for mobile FAB button - // Mobile FAB widget display options (8-position system around the button) - mobileFabWidgets: { - enabled: true, // Master toggle for FAB widgets - weatherIcon: { enabled: true, position: 0 }, // Weather emoji (☀️, 🌧️, etc.) - weatherDesc: { enabled: true, position: 1 }, // Weather description text - clock: { enabled: true, position: 2 }, // Current time display - date: { enabled: true, position: 3 }, // Date display - location: { enabled: true, position: 4 }, // Location name - stats: { enabled: true, position: 5 }, // All stats as compact numbers - attributes: { enabled: true, position: 6 } // Compact RPG attributes display - }, - // Desktop strip widget display options (shown in collapsed panel strip) - desktopStripWidgets: { - enabled: true, // Master toggle for strip widgets (enabled by default) - weatherIcon: { enabled: true }, // Weather emoji (☀️, 🌧️, etc.) - clock: { enabled: true }, // Current time display - date: { enabled: true }, // Date display - location: { enabled: true }, // Location name - stats: { enabled: true }, // All stats as compact numbers - attributes: { enabled: true } // Compact RPG attributes display - }, - userStats: { - health: 100, - satiety: 100, - energy: 100, - hygiene: 100, - arousal: 0, - mood: '😐', - conditions: 'None', - skills: [], - inventory: { - version: 2, - onPerson: "None", - clothing: "None", - stored: {}, - assets: "None" - }, - equipment: { - items: [], // Array of {id, name, type, slot, stats: {str: 2, dex: 1, ...}, description} - slots: { - helmet: null, - ring1: null, - ring2: null, - ring3: null, - ring4: null, - ring5: null, - ring6: null, - ring7: null, - ring8: null, - ring9: null, - ring10: null, - necklace: null, - bodyArmor: null, - pants: null, - shoes: null, - gloves: null, - accessory1: null, - accessory2: null, - accessory3: null - } - } - }, - statNames: { - health: 'Health', - satiety: 'Satiety', - energy: 'Energy', - hygiene: 'Hygiene', - arousal: 'Arousal' - }, - // Tracker customization configuration - trackerConfig: { - userStats: { - // Stats display mode: 'percentage' or 'number' - statsDisplayMode: 'percentage', - // Array of custom stats (allows add/remove/rename) - customStats: [ - { id: 'health', name: 'Health', enabled: true, persistInHistory: false, maxValue: 100 }, - { id: 'satiety', name: 'Satiety', enabled: true, persistInHistory: false, maxValue: 100 }, - { id: 'energy', name: 'Energy', enabled: true, persistInHistory: false, maxValue: 100 }, - { id: 'hygiene', name: 'Hygiene', enabled: true, persistInHistory: false, maxValue: 100 }, - { id: 'arousal', name: 'Arousal', enabled: true, persistInHistory: false, maxValue: 100 } - ], - // RPG Attributes (customizable D&D-style attributes) - showRPGAttributes: true, - showLevel: true, // Show/hide level in UI and prompts - alwaysSendAttributes: false, // If true, always send attributes; if false, only send with dice rolls - rpgAttributes: [ - { id: 'str', name: 'STR', enabled: true, persistInHistory: false }, - { id: 'dex', name: 'DEX', enabled: true, persistInHistory: false }, - { id: 'con', name: 'CON', enabled: true, persistInHistory: false }, - { id: 'int', name: 'INT', enabled: true, persistInHistory: false }, - { id: 'wis', name: 'WIS', enabled: true, persistInHistory: false }, - { id: 'cha', name: 'CHA', enabled: true, persistInHistory: false } - ], - // Status section config - statusSection: { - enabled: true, - showMoodEmoji: true, - customFields: ['Conditions'], // User can edit what to track - persistInHistory: false // Persist status in historical messages - }, - // Optional skills field - skillsSection: { - enabled: false, - label: 'Skills', // User-editable - customFields: [], // Array of skill names - persistInHistory: false // Persist skills in historical messages - }, - // Inventory persistence - inventoryPersistInHistory: false, // Persist inventory in historical messages - // Quests persistence - questsPersistInHistory: false // Persist quests in historical messages - }, - infoBox: { - widgets: { - date: { enabled: true, format: 'Weekday, Month, Year', persistInHistory: true }, // Date enabled by default for history - weather: { enabled: true, persistInHistory: true }, // Weather enabled by default for history - temperature: { enabled: true, unit: 'C', persistInHistory: false }, // 'C' or 'F' - time: { enabled: true, persistInHistory: true }, // Time enabled by default for history - location: { enabled: true, persistInHistory: true }, // Location enabled by default for history - recentEvents: { enabled: true, persistInHistory: false } - } - }, - presentCharacters: { - // Fixed fields (always shown) - showEmoji: true, - showName: true, - // Relationship fields configuration - relationships: { - enabled: true, - // Relationship to emoji mapping (shown on character portraits) - relationshipEmojis: { - 'Lover': '❤️', - 'Friend': '⭐', - 'Ally': '🤝', - 'Enemy': '⚔️', - 'Neutral': '⚖️' - } - }, - // Legacy fields kept for backward compatibility - relationshipFields: ['Lover', 'Friend', 'Ally', 'Enemy', 'Neutral'], - relationshipEmojis: { - 'Lover': '❤️', - 'Friend': '⭐', - 'Ally': '🤝', - 'Enemy': '⚔️', - 'Neutral': '⚖️' - }, - // Custom fields (appearance, demeanor, etc. - shown after relationship, separated by |) - customFields: [ - { id: 'appearance', name: 'Appearance', enabled: true, description: 'Visible physical appearance (clothing, hair, notable features)', persistInHistory: false }, - { id: 'demeanor', name: 'Demeanor', enabled: true, description: 'Observable demeanor or emotional state', persistInHistory: false } - ], - // Thoughts configuration (separate line) - thoughts: { - enabled: true, - name: 'Thoughts', - description: 'Internal Monologue (in first person from character\'s POV, up to three sentences long)', - persistInHistory: false - }, - // Character stats toggle (optional feature) - characterStats: { - enabled: false, - customStats: [ - { id: 'health', name: 'Health', enabled: true }, - { id: 'arousal', name: 'Arousal', enabled: true } - ] - } - } - }, - quests: { - main: "None", // Current main quest title - optional: [] // Array of optional quest titles - }, - infoBox: JSON.stringify({ - date: { value: new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) }, - weather: { emoji: '☀️', forecast: 'Clear skies' }, - temperature: { value: 20, unit: 'C' }, - time: { start: '00:00', end: '00:00' }, - location: { value: 'Unknown Location' } - }, null, 2), - characterThoughts: JSON.stringify({ - characters: [] - }, null, 2), - level: 1, // User's character level - classicStats: { - str: 10, - dex: 10, - con: 10, - int: 10, - wis: 10, - cha: 10 - }, - lastDiceRoll: null, // Store last dice roll result - showDiceDisplay: true, // Show the "Last Roll" display in the panel - collapsedInventoryLocations: [], // Array of collapsed storage location names - inventoryViewModes: { - onPerson: 'list', // 'list' or 'grid' view mode for On Person section - stored: 'list', // 'list' or 'grid' view mode for Stored section - assets: 'list' // 'list' or 'grid' view mode for Assets section - }, - npcAvatars: {}, // Store custom avatar images for NPCs (key: character name, value: base64 data URI) - // Combat encounter settings - encounterSettings: { - enabled: true, // Show Start Encounter button above chat input - historyDepth: 8, // Number of recent messages to include in combat initialization - autoSaveLogs: false // Save detailed combat logs to file - }, - // Auto avatar generation settings - autoGenerateAvatars: true, // Master toggle for auto-generating avatars - avatarLLMCustomInstruction: '', // Custom instruction for LLM prompt generation - // External API settings for 'external' generation mode - externalApiSettings: { - baseUrl: '', // OpenAI-compatible API base URL (e.g., "https://api.openai.com/v1") - // apiKey is NOT stored here for security. It is stored in localStorage('rpg_companion_api_key') - model: '', // Model identifier (e.g., "gpt-4o-mini") - maxTokens: 8192, // Maximum tokens for generation - temperature: 0.7 // Temperature setting for generation - }, - // Lock state for tracker items (v3 JSON format feature) - lockedItems: { - stats: [], // Array of locked stat IDs (e.g., ["health", "satiety"]) - skills: [], // Array of locked skill names (e.g., ["Cooking", "Swordsmanship"]) - inventory: { - onPerson: [], // Array of locked item indices (e.g., [0, 2]) - clothing: [], // Array of locked item indices - stored: {}, // Object with location keys, each containing array of locked indices (e.g., {"Home": [0, 1]}) - assets: [] // Array of locked asset indices - }, - quests: { - main: false, // Boolean for main quest lock - optional: [] // Array of locked optional quest indices (e.g., [0, 2]) - }, - infoBox: { - date: false, // Boolean for date widget lock - weather: false, // Boolean for weather widget lock - temperature: false, // Boolean for temperature widget lock - time: false, // Boolean for time widget lock - location: false, // Boolean for location widget lock - recentEvents: false // Boolean for recent events widget lock - }, - characters: {} // Object mapping character names to their locked fields (e.g., {"Sarah": {relationship: true, thoughts: false}}) - }, - // Preset management for tracker configurations - presetManager: { - // Map of preset ID to preset data (contains name and trackerConfig) - presets: {}, - // Map of character/group entity to preset ID (e.g., "char_0": "preset_123", "group_abc": "preset_456") - // Note: This is stored separately and NOT exported with presets - characterAssociations: {}, - // Currently active preset ID - activePresetId: null, - // Default preset ID (used when no character association exists) - defaultPresetId: null - } -}; +export let extensionSettings = { ...defaultSettings }; /** * Last generated data from AI response diff --git a/src/systems/generation/promptBuilder.js b/src/systems/generation/promptBuilder.js index 440cce5..b062656 100644 --- a/src/systems/generation/promptBuilder.js +++ b/src/systems/generation/promptBuilder.js @@ -78,85 +78,99 @@ async function getCharacterCardsInfo() { // Narrator mode: use character card as narrator context, infer characters from story context if (extensionSettings.narratorMode) { if (this_chid !== undefined && characters && characters[this_chid]) { - const character = characters[this_chid]; - characterInfo += 'You are acting as the narrator for this story. The narrator card provides context for the story tone and style:\n\n'; - characterInfo += `\n`; - - if (character.description) { - characterInfo += `${character.description}\n`; - } - - if (character.personality) { - characterInfo += `${character.personality}\n`; - } - - characterInfo += `\n\n`; - - // Use custom narrator prompt if available, otherwise use default - const narratorPrompt = extensionSettings.customNarratorPrompt || DEFAULT_NARRATOR_PROMPT; - characterInfo += narratorPrompt + '\n\n'; + characterInfo = buildNarratorCardInfo(characters[this_chid]); } return characterInfo; } // Check if in group chat if (selected_group) { - // Find the current group directly from the groups array - const group = groups.find(g => g.id === selected_group); const groupMembers = getGroupMembers(selected_group); if (groupMembers && groupMembers.length > 0) { - characterInfo += 'Characters in this roleplay:\n\n'; - - // Filter out disabled (muted) members - const disabledMembers = group?.disabled_members || []; - // console.log('[RPG Companion] 🔍 Group ID:', selected_group, '| Disabled members:', disabledMembers); - let characterIndex = 0; - - groupMembers.forEach((member) => { - if (!member || !member.name) return; - - // Skip muted characters - check against avatar filename - if (member.avatar && disabledMembers.includes(member.avatar)) { - // console.log(`[RPG Companion] ❌ Skipping muted: ${member.name} (${member.avatar})`); - return; - } - - characterIndex++; - characterInfo += `\n`; - - if (member.description) { - characterInfo += `${member.description}\n`; - } - - if (member.personality) { - characterInfo += `${member.personality}\n`; - } - - characterInfo += `\n\n`; - }); + const disabledMembers = groups.find(g => g.id === selected_group)?.disabled_members || []; + characterInfo = buildGroupCardInfo(groupMembers, disabledMembers); } } else if (this_chid !== undefined && characters && characters[this_chid]) { // Single character chat - const character = characters[this_chid]; - - characterInfo += 'Character in this roleplay:\n\n'; - characterInfo += `\n`; - - if (character.description) { - characterInfo += `${character.description}\n`; - } - - if (character.personality) { - characterInfo += `${character.personality}\n`; - } - - characterInfo += `\n\n`; + characterInfo = buildSingleCardInfo(characters[this_chid]); } return characterInfo; } +/** + * Builds narrator mode character card information. + * @param {Object} character - Character card data + * @returns {string} Formatted narrator info + */ +function buildNarratorCardInfo(character) { + let info = ''; + info += 'You are acting as the narrator for this story. The narrator card provides context for the story tone and style:\n\n'; + info += `\n`; + info += appendCharacterFields(character); + info += `\n\n`; + info += (extensionSettings.customNarratorPrompt || DEFAULT_NARRATOR_PROMPT) + '\n\n'; + return info; +} + +/** + * Builds character card information for a group chat. + * @param {Array} groupMembers - Array of group member objects + * @param {Array} disabledMembers - Array of disabled (muted) member avatar filenames + * @returns {string} Formatted group character info + */ +function buildGroupCardInfo(groupMembers, disabledMembers) { + let info = 'Characters in this roleplay:\n\n'; + let characterIndex = 0; + + groupMembers.forEach((member) => { + if (!member || !member.name) return; + + // Skip muted characters + if (member.avatar && disabledMembers.includes(member.avatar)) { + return; + } + + characterIndex++; + info += `\n`; + info += appendCharacterFields(member); + info += `\n\n`; + }); + + return info; +} + +/** + * Builds character card information for a single character chat. + * @param {Object} character - Character card data + * @returns {string} Formatted single character info + */ +function buildSingleCardInfo(character) { + let info = 'Character in this roleplay:\n\n'; + info += `\n`; + info += appendCharacterFields(character); + info += `\n\n`; + return info; +} + +/** + * Appends description and personality fields from a character object. + * Shared helper used by narrator, group, and single character info builders. + * @param {Object} character - Character object with optional description/personality fields + * @returns {string} Appended fields (empty string if neither present) + */ +function appendCharacterFields(character) { + let fields = ''; + if (character.description) { + fields += `${character.description}\n`; + } + if (character.personality) { + fields += `${character.personality}\n`; + } + return fields; +} + /** * Builds a formatted inventory summary for AI context injection. * Converts v2 inventory structure to multi-line plaintext format. diff --git a/src/utils/jsonRepair.js b/src/utils/jsonRepair.js index 399c62e..25304aa 100644 --- a/src/utils/jsonRepair.js +++ b/src/utils/jsonRepair.js @@ -81,6 +81,7 @@ export function repairJSON(jsonString) { try { return JSON.parse(cleaned); } catch (e) { + console.debug('[RPG JSON Repair] Attempt 1 (JSON.parse) failed:', e.message); } // Attempt 2: Extract JSON object between first { and last } @@ -89,7 +90,7 @@ export function repairJSON(jsonString) { try { return JSON.parse(objectMatch[0]); } catch (e) { - // Silent fail, try next method + console.debug('[RPG JSON Repair] Attempt 2 (object extraction) failed:', e.message); } } @@ -99,7 +100,7 @@ export function repairJSON(jsonString) { try { return JSON.parse(arrayMatch[0]); } catch (e) { - // Silent fail, try next method + console.debug('[RPG JSON Repair] Attempt 3 (array extraction) failed:', e.message); } } diff --git a/src/utils/security.js b/src/utils/security.js index 63ac2ab..9f1f62b 100644 --- a/src/utils/security.js +++ b/src/utils/security.js @@ -23,6 +23,70 @@ const BLOCKED_PROPERTY_NAMES = [ '__lookupSetter__' ]; +/** + * Creates a generic string validator/sanitizer with configurable rules. + * Returns a function that validates and sanitizes input strings. + * + * @param {Object} options - Validator configuration + * @param {string} options.label - Human-readable label for logging (e.g., 'location name', 'item name') + * @param {number} options.maxLength - Maximum allowed string length + * @param {string[]} [options.blockedNames] - Array of blocked names (defaults to BLOCKED_PROPERTY_NAMES) + * @param {string[]} [options.additionalRejects] - Additional values to reject (lowercase comparison, e.g., ['none']) + * @param {boolean} [options.checkBlockedNames] - Whether to check against blocked property names (default: true) + * @returns {Function} Sanitizer function: (string) => string|null + * + * @example + * const sanitizeLocation = createStringValidator({ + * label: 'location name', + * maxLength: 200, + * checkBlockedNames: true + * }); + * const sanitizeItem = createStringValidator({ + * label: 'item name', + * maxLength: 500, + * additionalRejects: ['none'], + * checkBlockedNames: false + * }); + */ +export function createStringValidator(options) { + const { label, maxLength, blockedNames = BLOCKED_PROPERTY_NAMES, additionalRejects = [], checkBlockedNames = true } = options; + + return function sanitize(name) { + if (!name || typeof name !== 'string') { + return null; + } + + const trimmed = name.trim(); + + // Empty check + if (trimmed === '') { + return null; + } + + // Additional rejects (e.g., 'none' for items) + if (additionalRejects.length > 0 && additionalRejects.includes(trimmed.toLowerCase())) { + return null; + } + + // Check for dangerous property names (case-insensitive) + if (checkBlockedNames) { + const lowerName = trimmed.toLowerCase(); + if (blockedNames.some(blocked => lowerName === blocked.toLowerCase())) { + console.warn(`[RPG Companion] Blocked dangerous ${label}: "${trimmed}"`); + return null; + } + } + + // Max length check + if (trimmed.length > maxLength) { + console.warn(`[RPG Companion] ${label.charAt(0).toUpperCase() + label.slice(1)} too long (${trimmed.length} chars), truncating to ${maxLength}`); + return trimmed.slice(0, maxLength); + } + + return trimmed; + }; +} + /** * Validates and sanitizes storage location names. * Prevents prototype pollution and object property shadowing attacks. @@ -35,34 +99,11 @@ const BLOCKED_PROPERTY_NAMES = [ * sanitizeLocationName("__proto__") // null (blocked, logs warning) * sanitizeLocationName("A".repeat(300)) // "AAA..." (truncated to 200 chars) */ -export function sanitizeLocationName(name) { - if (!name || typeof name !== 'string') { - return null; - } - - const trimmed = name.trim(); - - // Empty check - if (trimmed === '') { - return null; - } - - // Check for dangerous property names (case-insensitive) - const lowerName = trimmed.toLowerCase(); - if (BLOCKED_PROPERTY_NAMES.some(blocked => lowerName === blocked.toLowerCase())) { - console.warn(`[RPG Companion] Blocked dangerous location name: "${trimmed}"`); - return null; - } - - // Max length check (reasonable location name) - const MAX_LOCATION_LENGTH = 200; - if (trimmed.length > MAX_LOCATION_LENGTH) { - console.warn(`[RPG Companion] Location name too long (${trimmed.length} chars), truncating to ${MAX_LOCATION_LENGTH}`); - return trimmed.slice(0, MAX_LOCATION_LENGTH); - } - - return trimmed; -} +export const sanitizeLocationName = createStringValidator({ + label: 'location name', + maxLength: 200, + checkBlockedNames: true +}); /** * Validates and sanitizes item names. @@ -76,27 +117,12 @@ export function sanitizeLocationName(name) { * sanitizeItemName("") // null * sanitizeItemName("A".repeat(600)) // "AAA..." (truncated to 500 chars) */ -export function sanitizeItemName(name) { - if (!name || typeof name !== 'string') { - return null; - } - - const trimmed = name.trim(); - - // Empty check - if (trimmed === '' || trimmed.toLowerCase() === 'none') { - return null; - } - - // Max length check (reasonable item name with description) - const MAX_ITEM_LENGTH = 500; - if (trimmed.length > MAX_ITEM_LENGTH) { - console.warn(`[RPG Companion] Item name too long (${trimmed.length} chars), truncating to ${MAX_ITEM_LENGTH}`); - return trimmed.slice(0, MAX_ITEM_LENGTH); - } - - return trimmed; -} +export const sanitizeItemName = createStringValidator({ + label: 'item name', + maxLength: 500, + additionalRejects: ['none'], + checkBlockedNames: false +}); /** * Validates and cleans a stored inventory object.