feat: json format, et al.

This commit is contained in:
Subarashimo
2025-12-03 14:55:30 +01:00
parent 56349f30e6
commit 0f7fdfcef1
28 changed files with 5692 additions and 237 deletions
+413 -4
View File
@@ -1,11 +1,14 @@
/**
* Parser Module
* Handles parsing of AI responses to extract tracker data
* Supports both legacy text format and new JSON format
*/
import { extensionSettings, FEATURE_FLAGS, addDebugLog } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { saveSettings, saveChatData } from '../../core/persistence.js';
import { extractInventory } from './inventoryParser.js';
import { validateTrackerData, mergeTrackerData } from '../../types/trackerData.js';
import { handleItemRemoved } from '../rendering/skills.js';
/**
* Helper to separate emoji from text in a string
@@ -133,19 +136,296 @@ function debugLog(message, data = null) {
}
}
/**
* Extracts JSON from a code block (handles ```json ... ``` format)
* @param {string} text - Text that may contain JSON code blocks
* @returns {Object|null} Parsed JSON object or null
*/
function extractJSONFromCodeBlock(text) {
if (!text) return null;
// Match ```json ... ``` or ``` ... ``` blocks
const jsonBlockRegex = /```(?:json)?\s*([\s\S]*?)```/gi;
const matches = [...text.matchAll(jsonBlockRegex)];
for (const match of matches) {
const content = match[1].trim();
// Check if content looks like JSON (starts with { or [)
if (content.startsWith('{') || content.startsWith('[')) {
try {
return JSON.parse(content);
} catch (e) {
debugLog('[RPG Parser] JSON parse failed:', e.message);
// Try to fix common JSON issues
const fixed = tryFixJSON(content);
if (fixed) return fixed;
}
}
}
return null;
}
/**
* Attempts to fix common JSON formatting issues
* @param {string} jsonStr - Potentially malformed JSON string
* @returns {Object|null} Fixed JSON object or null
*/
function tryFixJSON(jsonStr) {
try {
// Remove trailing commas
let fixed = jsonStr.replace(/,(\s*[}\]])/g, '$1');
// Fix unquoted keys
fixed = fixed.replace(/([{,]\s*)(\w+)(\s*:)/g, '$1"$2"$3');
return JSON.parse(fixed);
} catch (e) {
return null;
}
}
/**
* Parses JSON tracker data and applies it to extension settings
* @param {Object} jsonData - Parsed JSON tracker data
* @returns {boolean} Whether parsing was successful
*/
export function parseJSONTrackerData(jsonData) {
debugLog('[RPG Parser] ==================== JSON PARSING ====================');
const validation = validateTrackerData(jsonData);
if (!validation.valid) {
debugLog('[RPG Parser] JSON validation failed:', validation.errors);
return false;
}
const trackerConfig = extensionSettings.trackerConfig;
// Parse stats
if (jsonData.stats) {
debugLog('[RPG Parser] Parsing stats:', Object.keys(jsonData.stats));
const customStats = trackerConfig?.userStats?.customStats || [];
for (const [statName, value] of Object.entries(jsonData.stats)) {
// Find matching stat in config
const statConfig = customStats.find(s =>
s.name.toLowerCase() === statName.toLowerCase()
);
if (statConfig && typeof value === 'number') {
// Store in userStats using the stat id
extensionSettings.userStats[statConfig.id] = Math.max(0, Math.min(100, value));
debugLog(`[RPG Parser] Stat ${statConfig.name}: ${value}%`);
}
}
}
// Parse status
if (jsonData.status) {
if (jsonData.status.mood) {
extensionSettings.userStats.mood = jsonData.status.mood;
debugLog('[RPG Parser] Mood:', jsonData.status.mood);
}
if (jsonData.status.fields) {
// Store custom status fields
extensionSettings.userStats.conditions = Object.values(jsonData.status.fields).join(', ') || 'None';
debugLog('[RPG Parser] Status fields:', jsonData.status.fields);
}
}
// Parse infoBox - normalize values and filter out null
if (jsonData.infoBox) {
const infoBox = {};
// Only copy non-null values
for (const [key, val] of Object.entries(jsonData.infoBox)) {
if (val !== null && val !== undefined && val !== 'null') {
infoBox[key] = val;
}
}
// Normalize recentEvents - LLM sometimes returns string instead of array
if (infoBox.recentEvents && typeof infoBox.recentEvents === 'string') {
infoBox.recentEvents = [infoBox.recentEvents];
} else if (!infoBox.recentEvents || !Array.isArray(infoBox.recentEvents)) {
infoBox.recentEvents = [];
}
// Filter out null/empty events from array
infoBox.recentEvents = infoBox.recentEvents.filter(e => e && e !== 'null');
extensionSettings.infoBoxData = infoBox;
debugLog('[RPG Parser] InfoBox:', Object.keys(infoBox));
}
// Parse characters - store for UI rendering
if (jsonData.characters && Array.isArray(jsonData.characters)) {
extensionSettings.charactersData = jsonData.characters;
debugLog('[RPG Parser] Characters:', jsonData.characters.length);
}
// Parse inventory (structured format)
// Handle LLM variations: empty objects {} should become empty arrays []
if (jsonData.inventory) {
const normalizeArray = (val) => {
if (Array.isArray(val)) return val;
if (val && typeof val === 'object' && Object.keys(val).length === 0) return [];
return [];
};
// Get all item names from current inventory BEFORE updating
const getItemNamesFromInventory = (inv) => {
const names = new Set();
if (!inv) return names;
// From onPerson array
if (Array.isArray(inv.onPerson)) {
inv.onPerson.forEach(item => {
const name = typeof item === 'string' ? item : item?.name;
if (name) names.add(name.toLowerCase());
});
}
// From stored locations
if (inv.stored && typeof inv.stored === 'object') {
Object.values(inv.stored).forEach(items => {
if (Array.isArray(items)) {
items.forEach(item => {
const name = typeof item === 'string' ? item : item?.name;
if (name) names.add(name.toLowerCase());
});
}
});
}
// From assets
if (Array.isArray(inv.assets)) {
inv.assets.forEach(item => {
const name = typeof item === 'string' ? item : item?.name;
if (name) names.add(name.toLowerCase());
});
}
return names;
};
const previousItemNames = getItemNamesFromInventory(extensionSettings.inventoryV3);
extensionSettings.inventoryV3 = {
onPerson: normalizeArray(jsonData.inventory.onPerson || jsonData.inventory.items),
stored: jsonData.inventory.stored && typeof jsonData.inventory.stored === 'object'
? jsonData.inventory.stored : {},
assets: normalizeArray(jsonData.inventory.assets)
};
debugLog('[RPG Parser] Inventory parsed - onPerson:', extensionSettings.inventoryV3.onPerson.length);
// Detect removed items and handle skill links
const newItemNames = getItemNamesFromInventory(extensionSettings.inventoryV3);
previousItemNames.forEach(itemName => {
if (!newItemNames.has(itemName)) {
debugLog('[RPG Parser] Item removed by LLM:', itemName);
// Handle item removal - will unlink or delete skills based on settings
handleItemRemoved(itemName);
}
});
// Also update legacy inventory for backwards compatibility
const itemsToString = (items) => {
if (!items || items.length === 0) return 'None';
return items.map(i => i.name).join(', ');
};
extensionSettings.userStats.inventory = {
version: 2,
onPerson: itemsToString(extensionSettings.inventoryV3.onPerson),
stored: Object.fromEntries(
Object.entries(extensionSettings.inventoryV3.stored).map(([k, v]) => [k, itemsToString(v)])
),
assets: itemsToString(extensionSettings.inventoryV3.assets)
};
}
// Parse skills (structured format) - handle array/object/string variations
if (jsonData.skills && typeof jsonData.skills === 'object') {
// Normalize skills - each category should be an array
const normalizedSkills = {};
for (const [category, abilities] of Object.entries(jsonData.skills)) {
if (Array.isArray(abilities)) {
normalizedSkills[category] = abilities;
} else if (typeof abilities === 'string') {
// LLM returned string instead of array - split by comma
normalizedSkills[category] = abilities.split(',').map(a => ({ name: a.trim(), description: '' })).filter(a => a.name);
} else {
normalizedSkills[category] = [];
}
}
extensionSettings.skillsV2 = normalizedSkills;
debugLog('[RPG Parser] Skills parsed:', Object.keys(normalizedSkills));
// Update legacy skills data for backwards compatibility
for (const [category, abilities] of Object.entries(normalizedSkills)) {
if (!extensionSettings.skillsData) extensionSettings.skillsData = {};
const names = abilities.map(a => typeof a === 'string' ? a : (a?.name || '')).filter(n => n);
extensionSettings.skillsData[category] = names.join(', ') || 'None';
}
}
// Parse quests - handle different formats
if (jsonData.quests) {
// Normalize main quest - could be string, object with name, or null
let mainName = 'None';
let mainDesc = '';
if (jsonData.quests.main) {
if (typeof jsonData.quests.main === 'string') {
mainName = jsonData.quests.main;
} else if (jsonData.quests.main.name) {
mainName = jsonData.quests.main.name;
mainDesc = jsonData.quests.main.description || '';
}
}
// Normalize optional quests - could be array of strings or objects
const optionalQuests = Array.isArray(jsonData.quests.optional) ? jsonData.quests.optional : [];
const optionalNames = optionalQuests.map(q => typeof q === 'string' ? q : (q?.name || '')).filter(n => n);
const optionalDescs = optionalQuests.map(q => typeof q === 'string' ? '' : (q?.description || ''));
extensionSettings.quests = {
main: mainName,
mainDescription: mainDesc,
optional: optionalNames,
optionalDescriptions: optionalDescs
};
// Store structured quests too
extensionSettings.questsV2 = jsonData.quests;
debugLog('[RPG Parser] Quests - main:', mainName);
}
saveSettings();
saveChatData();
debugLog('[RPG Parser] JSON parsing complete');
debugLog('[RPG Parser] =======================================================');
return true;
}
/**
* Main entry point for parsing responses - tries JSON first, falls back to text
* @param {string} responseText - The raw AI response
* @returns {boolean} Whether JSON parsing was successful
*/
export function tryParseJSONResponse(responseText) {
const jsonData = extractJSONFromCodeBlock(responseText);
if (jsonData) {
return parseJSONTrackerData(jsonData);
}
debugLog('[RPG Parser] No valid JSON found, falling back to text parsing');
return false;
}
/**
* Parses the model response to extract the different data sections.
* Extracts tracker data from markdown code blocks in the AI response.
* Handles both separate code blocks and combined code blocks gracefully.
*
* @param {string} responseText - The raw AI response text
* @returns {{userStats: string|null, infoBox: string|null, characterThoughts: string|null}} Parsed tracker data
* @returns {{userStats: string|null, infoBox: string|null, characterThoughts: string|null, skills: string|null}} Parsed tracker data
*/
export function parseResponse(responseText) {
const result = {
userStats: null,
infoBox: null,
characterThoughts: null
characterThoughts: null,
skills: null
};
// DEBUG: Log full response for troubleshooting
@@ -213,7 +493,12 @@ export function parseResponse(responseText) {
(content.match(/Health:\s*\d+%/i) && content.match(/Energy:\s*\d+%/i)) ||
// Fallback: inventory-only or quests-only blocks (no stats header)
(content.match(/^(On Person:|Inventory:|Main Quests?:|Optional Quests:)/im) &&
!content.match(/Info Box/i) && !content.match(/Present Characters/i));
!content.match(/Info Box/i) && !content.match(/Present Characters/i) && !content.match(/Skills\s*\n\s*---/i));
// Match Skills section (separate from stats when showSkills is enabled)
const isSkills =
content.match(/Skills\s*\n\s*---/i) &&
!content.match(/Stats\s*\n\s*---/i); // Make sure it's not a combined block
// Match Info Box section - flexible patterns
const isInfoBox =
@@ -234,6 +519,9 @@ export function parseResponse(responseText) {
if (isStats && !result.userStats) {
result.userStats = stripBrackets(content);
debugLog('[RPG Parser] ✓ Matched: Stats section');
} else if (isSkills && !result.skills) {
result.skills = stripBrackets(content);
debugLog('[RPG Parser] ✓ Matched: Skills section');
} else if (isInfoBox && !result.infoBox) {
result.infoBox = stripBrackets(content);
debugLog('[RPG Parser] ✓ Matched: Info Box section');
@@ -249,12 +537,14 @@ export function parseResponse(responseText) {
debugLog('[RPG Parser] - Has info keywords?', !!(content.match(/Date:/i) && content.match(/Location:/i)));
debugLog('[RPG Parser] - Has "Present Characters\\n---"?', !!content.match(/Present Characters\s*\n\s*---/i));
debugLog('[RPG Parser] - Has new format ("- Name" + "Details:")?', !!(content.match(/^-\s+\w+/m) && content.match(/Details:/i)));
debugLog('[RPG Parser] - Has "Skills\\n---"?', !!content.match(/Skills\s*\n\s*---/i));
}
}
}
debugLog('[RPG Parser] ==================== PARSE RESULTS ====================');
debugLog('[RPG Parser] Found Stats:', !!result.userStats);
debugLog('[RPG Parser] Found Skills:', !!result.skills);
debugLog('[RPG Parser] Found Info Box:', !!result.infoBox);
debugLog('[RPG Parser] Found Characters:', !!result.characterThoughts);
debugLog('[RPG Parser] =======================================================');
@@ -425,6 +715,125 @@ export function parseUserStats(statsText) {
}
}
/**
* Parses skills from the separate skills code block.
* Used when showSkills is enabled (skills appear in their own section).
* Format expected:
* Skills
* ---
* Combat: Sword Fighting, Shield Block, Parry
* Stealth: Lockpicking (via Lockpicks), Sneaking, Pickpocketing
* Magic: None
*
* Each skill category contains a comma-separated list of abilities.
* Abilities can be linked to items using "(via ItemName)" format.
*
* @param {string} skillsText - The raw skills text from AI response
*/
export function parseSkills(skillsText) {
debugLog('[RPG Parser] ==================== PARSING SKILLS ====================');
debugLog('[RPG Parser] Skills text length:', skillsText?.length + ' chars');
if (!skillsText || typeof skillsText !== 'string') {
debugLog('[RPG Parser] No skills text to parse');
return;
}
try {
// Initialize data structures if needed
if (!extensionSettings.skillsData) {
extensionSettings.skillsData = {};
}
if (!extensionSettings.skillAbilityLinks) {
extensionSettings.skillAbilityLinks = {};
}
// Get configured skill categories
const configuredCategories = extensionSettings.trackerConfig?.userStats?.skillsSection?.customFields || [];
const lines = skillsText.split('\n');
const newSkillAbilityLinks = {};
for (const line of lines) {
// Skip header lines and notes
if (line.match(/^Skills\s*$/i) || line.match(/^---/) || !line.trim() || line.match(/^\(Note:/i)) {
continue;
}
// Parse skill category line: "CategoryName: ability1, ability2 (via Item), ability3"
const categoryMatch = line.match(/^(.+?):\s*(.*)$/);
if (categoryMatch) {
const categoryName = categoryMatch[1].trim();
const abilitiesText = categoryMatch[2].trim();
// Check if this is a configured category (case-insensitive match)
const matchedCategory = configuredCategories.find(c =>
c.toLowerCase() === categoryName.toLowerCase()
);
if (!matchedCategory) {
debugLog(`[RPG Parser] Skipping unknown skill category: ${categoryName}`);
continue;
}
// Parse abilities (comma-separated)
if (!abilitiesText || abilitiesText.toLowerCase() === 'none') {
extensionSettings.skillsData[matchedCategory] = 'None';
debugLog(`[RPG Parser] Skill category ${matchedCategory}: None`);
continue;
}
// Split by comma and parse each ability
const abilities = [];
const rawAbilities = abilitiesText.split(',').map(a => a.trim()).filter(a => a);
for (const rawAbility of rawAbilities) {
// Check for "(ItemName)" pattern - ability granted by item
// Supports both "AbilityName (ItemName)" and legacy "AbilityName (via ItemName)"
const itemMatch = rawAbility.match(/^(.+?)\s*\((?:via\s+)?(.+?)\)$/i);
if (itemMatch) {
const abilityName = itemMatch[1].trim();
const itemName = itemMatch[2].trim();
abilities.push(abilityName);
// Store the link
if (extensionSettings.enableItemSkillLinks) {
const linkKey = `${matchedCategory}::${abilityName}`;
newSkillAbilityLinks[linkKey] = itemName;
debugLog(`[RPG Parser] Linked: ${abilityName} <- ${itemName}`);
}
} else {
abilities.push(rawAbility);
}
}
// Store abilities for this category
const abilitiesString = abilities.length > 0 ? abilities.join(', ') : 'None';
extensionSettings.skillsData[matchedCategory] = abilitiesString;
debugLog(`[RPG Parser] Skill category ${matchedCategory}: ${abilitiesString}`);
}
}
// Update skill-ability links if item linking is enabled
if (extensionSettings.enableItemSkillLinks && Object.keys(newSkillAbilityLinks).length > 0) {
// Merge new links with existing ones
extensionSettings.skillAbilityLinks = {
...extensionSettings.skillAbilityLinks,
...newSkillAbilityLinks
};
debugLog('[RPG Parser] Skill-ability links updated:', Object.keys(newSkillAbilityLinks).length + ' new links');
}
saveSettings();
debugLog('[RPG Parser] Skills saved successfully');
debugLog('[RPG Parser] =======================================================');
} catch (error) {
console.error('[RPG Companion] Error parsing skills:', error);
debugLog('[RPG Parser] ERROR:', error.message);
}
}
/**
* Helper: Extract code blocks from text
* @param {string} text - Text containing markdown code blocks