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)
This commit is contained in:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"presets": [
|
||||||
|
["@babel/preset-env", {
|
||||||
|
"targets": {
|
||||||
|
"node": "current"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
testMatch: ['**/__tests__/**/*.test.js'],
|
testMatch: ['**/__tests__/**/*.test.js'],
|
||||||
|
transform: {
|
||||||
|
'^.+\\.js$': 'babel-jest',
|
||||||
|
},
|
||||||
|
transformIgnorePatterns: ['/node_modules/'],
|
||||||
};
|
};
|
||||||
|
|||||||
+282
-16
@@ -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
|
* This supports both global (public/extensions) and user-specific (data/default-user/extensions) installations
|
||||||
*/
|
*/
|
||||||
const currentScriptPath = import.meta.url;
|
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
|
export const extensionFolderPath = isUserExtension
|
||||||
? `data/default-user/extensions/${extensionName}`
|
? `data/default-user/extensions/${extensionName}`
|
||||||
: `scripts/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 = {
|
export const defaultSettings = {
|
||||||
|
settingsVersion: 6, // Version number for settings migrations
|
||||||
enabled: true,
|
enabled: true,
|
||||||
autoUpdate: true,
|
autoUpdate: false,
|
||||||
updateDepth: 4, // How many messages to include in the context
|
updateDepth: 4, // How many messages to include in the context
|
||||||
generationMode: 'together', // 'separate' or 'together' - whether to generate with main response or separately
|
generationMode: 'together', // 'separate' or 'together' - whether to generate with main response or separately
|
||||||
showUserStats: true,
|
showUserStats: true,
|
||||||
@@ -33,37 +35,93 @@ export const defaultSettings = {
|
|||||||
enableThoughtBasedExpressions: false,
|
enableThoughtBasedExpressions: false,
|
||||||
hideDefaultExpressionDisplay: false,
|
hideDefaultExpressionDisplay: false,
|
||||||
showInventory: true, // Show inventory section (v2 system)
|
showInventory: true, // Show inventory section (v2 system)
|
||||||
|
showEquipment: true, // Show equipment section
|
||||||
showQuests: true, // Show quests section
|
showQuests: true, // Show quests section
|
||||||
showLockIcons: true, // Show lock/unlock icons on tracker items
|
|
||||||
showThoughtsInChat: true, // Show thoughts overlay in chat
|
showThoughtsInChat: true, // Show thoughts overlay in chat
|
||||||
thoughtsInChatStyle: 'corner', // 'corner' or 'inline'
|
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
|
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 <lie> tags
|
||||||
|
customDeceptionPrompt: '', // Custom deception prompt text (empty = use default)
|
||||||
|
enableOmniscienceFilter: false, // Enable omniscience filter with <ofilter> tags
|
||||||
|
customOmnisciencePrompt: '', // Custom omniscience filter prompt text (empty = use default)
|
||||||
|
enableCYOA: false, // Enable "Choose Your Own Adventure" formatting with action choices
|
||||||
|
customCYOAPrompt: '', // Custom CYOA prompt text (empty = use default)
|
||||||
enableSpotifyMusic: false, // Enable Spotify music integration (asks AI for Spotify URLs)
|
enableSpotifyMusic: false, // Enable Spotify music integration (asks AI for Spotify URLs)
|
||||||
customSpotifyPrompt: '', // Custom Spotify prompt text (empty = use default)
|
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:
|
enableDynamicWeather: true, // Enable dynamic weather effects based on Info Box weather field (v2: enabled by default)
|
||||||
// - 'none' -> never skip (legacy behavior: always inject)
|
weatherBackground: true, // Show weather effects in background (behind chat)
|
||||||
// - 'guided' -> skip for any guided / instruct or quiet_prompt generation
|
weatherForeground: false, // Show weather effects in foreground (on top of chat)
|
||||||
// - 'impersonation' -> skip only for impersonation-style guided generations
|
dismissedHolidayPromo: false, // User dismissed the holiday promotion banner
|
||||||
// This setting helps compatibility with other extensions like GuidedGenerations.
|
showHtmlToggle: true, // Show Immersive HTML toggle in main panel
|
||||||
skipInjectionsForGuided: 'none',
|
showDialogueColoringToggle: true, // Show Dialogue Coloring toggle in main panel (enabled by default)
|
||||||
enablePlotButtons: true, // Show plot progression buttons above chat input
|
showDeceptionToggle: true, // Show Deception System toggle in main panel
|
||||||
saveTrackerHistory: false, // Save tracker data in chat history for each message
|
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'
|
panelPosition: 'right', // 'left', 'right', or 'top'
|
||||||
theme: 'default', // Theme: default, sci-fi, fantasy, cyberpunk, custom
|
theme: 'default', // Theme: default, sci-fi, fantasy, cyberpunk, custom
|
||||||
customColors: {
|
customColors: {
|
||||||
bg: '#1a1a2e',
|
bg: '#1a1a2e',
|
||||||
|
bgOpacity: 100,
|
||||||
accent: '#16213e',
|
accent: '#16213e',
|
||||||
|
accentOpacity: 100,
|
||||||
text: '#eaeaea',
|
text: '#eaeaea',
|
||||||
highlight: '#e94560'
|
textOpacity: 100,
|
||||||
|
highlight: '#e94560',
|
||||||
|
highlightOpacity: 100
|
||||||
},
|
},
|
||||||
statBarColorLow: '#cc3333', // Color for low stat values (red)
|
statBarColorLow: '#cc3333', // Color for low stat values (red)
|
||||||
|
statBarColorLowOpacity: 100,
|
||||||
statBarColorHigh: '#33cc66', // Color for high stat values (green)
|
statBarColorHigh: '#33cc66', // Color for high stat values (green)
|
||||||
|
statBarColorHighOpacity: 100,
|
||||||
enableAnimations: true, // Enable smooth animations for stats and content updates
|
enableAnimations: true, // Enable smooth animations for stats and content updates
|
||||||
mobileFabPosition: {
|
mobileFabPosition: {
|
||||||
top: 'calc(var(--topBarBlockSize) + 60px)',
|
top: 'calc(var(--topBarBlockSize) + 60px)',
|
||||||
right: '12px'
|
right: '12px'
|
||||||
}, // Saved position for mobile FAB button
|
}, // 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: {
|
userStats: {
|
||||||
health: 100,
|
health: 100,
|
||||||
satiety: 100,
|
satiety: 100,
|
||||||
@@ -72,14 +130,162 @@ export const defaultSettings = {
|
|||||||
arousal: 0,
|
arousal: 0,
|
||||||
mood: '😐',
|
mood: '😐',
|
||||||
conditions: 'None',
|
conditions: 'None',
|
||||||
/** @type {InventoryV2} */
|
skills: [],
|
||||||
inventory: {
|
inventory: {
|
||||||
version: 2,
|
version: 2,
|
||||||
onPerson: "None",
|
onPerson: "None",
|
||||||
|
clothing: "None",
|
||||||
stored: {},
|
stored: {},
|
||||||
assets: "None"
|
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: {
|
classicStats: {
|
||||||
str: 10,
|
str: 10,
|
||||||
dex: 10,
|
dex: 10,
|
||||||
@@ -89,5 +295,65 @@ export const defaultSettings = {
|
|||||||
cha: 10
|
cha: 10
|
||||||
},
|
},
|
||||||
lastDiceRoll: null, // Store last dice roll result
|
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
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+4
-335
@@ -3,347 +3,16 @@
|
|||||||
* Centralizes all extension state variables
|
* Centralizes all extension state variables
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { defaultSettings } from './config.js';
|
||||||
|
|
||||||
// Type imports
|
// Type imports
|
||||||
/** @typedef {import('../types/inventory.js').InventoryV2} InventoryV2 */
|
/** @typedef {import('../types/inventory.js').InventoryV2} InventoryV2 */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extension settings - persisted to SillyTavern settings
|
* Extension settings - persisted to SillyTavern settings
|
||||||
|
* Initialized from config.js defaultSettings (single source of truth)
|
||||||
*/
|
*/
|
||||||
export let extensionSettings = {
|
export let extensionSettings = { ...defaultSettings };
|
||||||
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 <lie> tags
|
|
||||||
customDeceptionPrompt: '', // Custom deception prompt text (empty = use default)
|
|
||||||
enableOmniscienceFilter: false, // Enable omniscience filter with <ofilter> tags
|
|
||||||
customOmnisciencePrompt: '', // Custom omniscience filter prompt text (empty = use default)
|
|
||||||
enableCYOA: false, // Enable "Choose Your Own Adventure" formatting with action choices
|
|
||||||
customCYOAPrompt: '', // Custom CYOA prompt text (empty = use default)
|
|
||||||
enableSpotifyMusic: false, // Enable Spotify music integration (asks AI for Spotify URLs)
|
|
||||||
customSpotifyPrompt: '', // Custom Spotify prompt text (empty = use default)
|
|
||||||
|
|
||||||
enableDynamicWeather: true, // Enable dynamic weather effects based on Info Box weather field (v2: enabled by default)
|
|
||||||
weatherBackground: true, // Show weather effects in background (behind chat)
|
|
||||||
weatherForeground: false, // Show weather effects in foreground (on top of chat)
|
|
||||||
dismissedHolidayPromo: false, // User dismissed the holiday promotion banner
|
|
||||||
showHtmlToggle: true, // Show Immersive HTML toggle in main panel
|
|
||||||
showDialogueColoringToggle: true, // Show Dialogue Coloring toggle in main panel (enabled by default)
|
|
||||||
showDeceptionToggle: true, // Show Deception System toggle in main panel
|
|
||||||
showOmniscienceToggle: true, // Show Omniscience Filter toggle in main panel
|
|
||||||
showCYOAToggle: true, // Show CYOA toggle in main panel
|
|
||||||
showSpotifyToggle: true, // Show Spotify Music toggle in main panel
|
|
||||||
|
|
||||||
showDynamicWeatherToggle: true, // Show Dynamic Weather Effects toggle in main panel
|
|
||||||
showNarratorMode: true, // Show Narrator Mode toggle in main panel
|
|
||||||
showAutoAvatars: true, // Show Auto-generate Avatars toggle in main panel
|
|
||||||
skipInjectionsForGuided: 'none', // skip injections for instruct injections and quiet prompts (GuidedGenerations compatibility)
|
|
||||||
enableRandomizedPlot: true, // Show randomized plot progression button above chat input
|
|
||||||
enableNaturalPlot: true, // Show natural plot progression button above chat input
|
|
||||||
// History persistence settings - inject selected tracker data into historical messages
|
|
||||||
historyPersistence: {
|
|
||||||
enabled: false, // Master toggle for history persistence feature
|
|
||||||
messageCount: 5, // Number of messages to include (0 = all available)
|
|
||||||
injectionPosition: 'assistant_message_end', // 'user_message_end', 'assistant_message_end', 'extra_user_message', 'extra_assistant_message'
|
|
||||||
contextPreamble: '', // Optional custom preamble text (empty = use default short one)
|
|
||||||
sendAllEnabledOnRefresh: false // If true, sends all enabled stats from preset instead of only persistInHistory-enabled stats on Refresh RPG Info
|
|
||||||
},
|
|
||||||
panelPosition: 'right', // 'left', 'right', or 'top'
|
|
||||||
theme: 'default', // Theme: default, sci-fi, fantasy, cyberpunk, custom
|
|
||||||
customColors: {
|
|
||||||
bg: '#1a1a2e',
|
|
||||||
bgOpacity: 100,
|
|
||||||
accent: '#16213e',
|
|
||||||
accentOpacity: 100,
|
|
||||||
text: '#eaeaea',
|
|
||||||
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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Last generated data from AI response
|
* Last generated data from AI response
|
||||||
|
|||||||
@@ -78,83 +78,97 @@ async function getCharacterCardsInfo() {
|
|||||||
// Narrator mode: use character card as narrator context, infer characters from story context
|
// Narrator mode: use character card as narrator context, infer characters from story context
|
||||||
if (extensionSettings.narratorMode) {
|
if (extensionSettings.narratorMode) {
|
||||||
if (this_chid !== undefined && characters && characters[this_chid]) {
|
if (this_chid !== undefined && characters && characters[this_chid]) {
|
||||||
const character = characters[this_chid];
|
characterInfo = buildNarratorCardInfo(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 += `<narrator>\n`;
|
|
||||||
|
|
||||||
if (character.description) {
|
|
||||||
characterInfo += `${character.description}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (character.personality) {
|
|
||||||
characterInfo += `${character.personality}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
characterInfo += `</narrator>\n\n`;
|
|
||||||
|
|
||||||
// Use custom narrator prompt if available, otherwise use default
|
|
||||||
const narratorPrompt = extensionSettings.customNarratorPrompt || DEFAULT_NARRATOR_PROMPT;
|
|
||||||
characterInfo += narratorPrompt + '\n\n';
|
|
||||||
}
|
}
|
||||||
return characterInfo;
|
return characterInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if in group chat
|
// Check if in group chat
|
||||||
if (selected_group) {
|
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);
|
const groupMembers = getGroupMembers(selected_group);
|
||||||
|
|
||||||
if (groupMembers && groupMembers.length > 0) {
|
if (groupMembers && groupMembers.length > 0) {
|
||||||
characterInfo += 'Characters in this roleplay:\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
|
||||||
|
characterInfo = buildSingleCardInfo(characters[this_chid]);
|
||||||
|
}
|
||||||
|
|
||||||
// Filter out disabled (muted) members
|
return characterInfo;
|
||||||
const disabledMembers = group?.disabled_members || [];
|
}
|
||||||
// console.log('[RPG Companion] 🔍 Group ID:', selected_group, '| Disabled members:', disabledMembers);
|
|
||||||
|
/**
|
||||||
|
* 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 += `<narrator>\n`;
|
||||||
|
info += appendCharacterFields(character);
|
||||||
|
info += `</narrator>\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;
|
let characterIndex = 0;
|
||||||
|
|
||||||
groupMembers.forEach((member) => {
|
groupMembers.forEach((member) => {
|
||||||
if (!member || !member.name) return;
|
if (!member || !member.name) return;
|
||||||
|
|
||||||
// Skip muted characters - check against avatar filename
|
// Skip muted characters
|
||||||
if (member.avatar && disabledMembers.includes(member.avatar)) {
|
if (member.avatar && disabledMembers.includes(member.avatar)) {
|
||||||
// console.log(`[RPG Companion] ❌ Skipping muted: ${member.name} (${member.avatar})`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
characterIndex++;
|
characterIndex++;
|
||||||
characterInfo += `<character${characterIndex}="${member.name}">\n`;
|
info += `<character${characterIndex}="${member.name}">\n`;
|
||||||
|
info += appendCharacterFields(member);
|
||||||
if (member.description) {
|
info += `</character${characterIndex}>\n\n`;
|
||||||
characterInfo += `${member.description}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (member.personality) {
|
|
||||||
characterInfo += `${member.personality}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
characterInfo += `</character${characterIndex}>\n\n`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return info;
|
||||||
}
|
}
|
||||||
} else if (this_chid !== undefined && characters && characters[this_chid]) {
|
|
||||||
// Single character chat
|
|
||||||
const character = characters[this_chid];
|
|
||||||
|
|
||||||
characterInfo += 'Character in this roleplay:\n\n';
|
/**
|
||||||
characterInfo += `<character="${character.name}">\n`;
|
* 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 += `<character="${character.name}">\n`;
|
||||||
|
info += appendCharacterFields(character);
|
||||||
|
info += `</character>\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) {
|
if (character.description) {
|
||||||
characterInfo += `${character.description}\n`;
|
fields += `${character.description}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (character.personality) {
|
if (character.personality) {
|
||||||
characterInfo += `${character.personality}\n`;
|
fields += `${character.personality}\n`;
|
||||||
}
|
}
|
||||||
|
return fields;
|
||||||
characterInfo += `</character>\n\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return characterInfo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export function repairJSON(jsonString) {
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(cleaned);
|
return JSON.parse(cleaned);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.debug('[RPG JSON Repair] Attempt 1 (JSON.parse) failed:', e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt 2: Extract JSON object between first { and last }
|
// Attempt 2: Extract JSON object between first { and last }
|
||||||
@@ -89,7 +90,7 @@ export function repairJSON(jsonString) {
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(objectMatch[0]);
|
return JSON.parse(objectMatch[0]);
|
||||||
} catch (e) {
|
} 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 {
|
try {
|
||||||
return JSON.parse(arrayMatch[0]);
|
return JSON.parse(arrayMatch[0]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silent fail, try next method
|
console.debug('[RPG JSON Repair] Attempt 3 (array extraction) failed:', e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+64
-38
@@ -24,18 +24,34 @@ const BLOCKED_PROPERTY_NAMES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates and sanitizes storage location names.
|
* Creates a generic string validator/sanitizer with configurable rules.
|
||||||
* Prevents prototype pollution and object property shadowing attacks.
|
* Returns a function that validates and sanitizes input strings.
|
||||||
*
|
*
|
||||||
* @param {string} name - Location name to validate
|
* @param {Object} options - Validator configuration
|
||||||
* @returns {string|null} Sanitized location name or null if invalid/dangerous
|
* @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
|
* @example
|
||||||
* sanitizeLocationName("Home") // "Home"
|
* const sanitizeLocation = createStringValidator({
|
||||||
* sanitizeLocationName("__proto__") // null (blocked, logs warning)
|
* label: 'location name',
|
||||||
* sanitizeLocationName("A".repeat(300)) // "AAA..." (truncated to 200 chars)
|
* maxLength: 200,
|
||||||
|
* checkBlockedNames: true
|
||||||
|
* });
|
||||||
|
* const sanitizeItem = createStringValidator({
|
||||||
|
* label: 'item name',
|
||||||
|
* maxLength: 500,
|
||||||
|
* additionalRejects: ['none'],
|
||||||
|
* checkBlockedNames: false
|
||||||
|
* });
|
||||||
*/
|
*/
|
||||||
export function sanitizeLocationName(name) {
|
export function createStringValidator(options) {
|
||||||
|
const { label, maxLength, blockedNames = BLOCKED_PROPERTY_NAMES, additionalRejects = [], checkBlockedNames = true } = options;
|
||||||
|
|
||||||
|
return function sanitize(name) {
|
||||||
if (!name || typeof name !== 'string') {
|
if (!name || typeof name !== 'string') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -47,23 +63,48 @@ export function sanitizeLocationName(name) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for dangerous property names (case-insensitive)
|
// Additional rejects (e.g., 'none' for items)
|
||||||
const lowerName = trimmed.toLowerCase();
|
if (additionalRejects.length > 0 && additionalRejects.includes(trimmed.toLowerCase())) {
|
||||||
if (BLOCKED_PROPERTY_NAMES.some(blocked => lowerName === blocked.toLowerCase())) {
|
|
||||||
console.warn(`[RPG Companion] Blocked dangerous location name: "${trimmed}"`);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Max length check (reasonable location name)
|
// Check for dangerous property names (case-insensitive)
|
||||||
const MAX_LOCATION_LENGTH = 200;
|
if (checkBlockedNames) {
|
||||||
if (trimmed.length > MAX_LOCATION_LENGTH) {
|
const lowerName = trimmed.toLowerCase();
|
||||||
console.warn(`[RPG Companion] Location name too long (${trimmed.length} chars), truncating to ${MAX_LOCATION_LENGTH}`);
|
if (blockedNames.some(blocked => lowerName === blocked.toLowerCase())) {
|
||||||
return trimmed.slice(0, MAX_LOCATION_LENGTH);
|
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;
|
return trimmed;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates and sanitizes storage location names.
|
||||||
|
* Prevents prototype pollution and object property shadowing attacks.
|
||||||
|
*
|
||||||
|
* @param {string} name - Location name to validate
|
||||||
|
* @returns {string|null} Sanitized location name or null if invalid/dangerous
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* sanitizeLocationName("Home") // "Home"
|
||||||
|
* sanitizeLocationName("__proto__") // null (blocked, logs warning)
|
||||||
|
* sanitizeLocationName("A".repeat(300)) // "AAA..." (truncated to 200 chars)
|
||||||
|
*/
|
||||||
|
export const sanitizeLocationName = createStringValidator({
|
||||||
|
label: 'location name',
|
||||||
|
maxLength: 200,
|
||||||
|
checkBlockedNames: true
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates and sanitizes item names.
|
* Validates and sanitizes item names.
|
||||||
* Prevents excessively long item names that could cause DoS or UI issues.
|
* Prevents excessively long item names that could cause DoS or UI issues.
|
||||||
@@ -76,27 +117,12 @@ export function sanitizeLocationName(name) {
|
|||||||
* sanitizeItemName("") // null
|
* sanitizeItemName("") // null
|
||||||
* sanitizeItemName("A".repeat(600)) // "AAA..." (truncated to 500 chars)
|
* sanitizeItemName("A".repeat(600)) // "AAA..." (truncated to 500 chars)
|
||||||
*/
|
*/
|
||||||
export function sanitizeItemName(name) {
|
export const sanitizeItemName = createStringValidator({
|
||||||
if (!name || typeof name !== 'string') {
|
label: 'item name',
|
||||||
return null;
|
maxLength: 500,
|
||||||
}
|
additionalRejects: ['none'],
|
||||||
|
checkBlockedNames: false
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates and cleans a stored inventory object.
|
* Validates and cleans a stored inventory object.
|
||||||
|
|||||||
Reference in New Issue
Block a user