diff --git a/README.md b/README.md index d9c1d3f..db88ca9 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,11 @@ An immersive RPG extension for browsers that tracks character stats, scene infor ## ๐Ÿ†• What's New -### v3.6.0 +### v3.6.1 -- You can now choose whether stats are displayed as percentages or numbers. -- Added collapsed strip widgets for desktop. -- Added new effects for the dynamic weather. -- Changed the displayed clock format in the Info Box. -- Fixed customized status field to work. -- Fixed date format toggles. -- Minor CSS and bug fixes. +- Fixed the bugs in the encounter system where you couldn't use the buttons after performing any custom action. +- Improved combat actions and made them dynamic, depending on the current situation. +- Added Russian as a supported language. **Special thanks to all the other contributors for this project:** Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte, Chungchandev, Joenunezb, Amauragis, and Tomt610. diff --git a/manifest.json b/manifest.json index c8ae75e..348307d 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,6 @@ "js": "index.js", "css": "style.css", "author": "Marinara", - "version": "3.6.0", + "version": "3.6.1", "homePage": "https://github.com/SpicyMarinara/rpg-companion-sillytavern" } diff --git a/settings.html b/settings.html index 79fc70f..5492ef5 100644 --- a/settings.html +++ b/settings.html @@ -48,7 +48,7 @@
- v3.6.0 + v3.6.1
diff --git a/src/systems/generation/encounterPrompts.js b/src/systems/generation/encounterPrompts.js index e08051b..2e041d1 100644 --- a/src/systems/generation/encounterPrompts.js +++ b/src/systems/generation/encounterPrompts.js @@ -121,7 +121,7 @@ export async function buildEncounterInitPrompt() { // console.log('[RPG Companion] World info result:', { worldInfoString, length: worldInfoString?.length }); - if (worldInfoString && worldInfoString.trim()) { + if (worldInfoString && typeof worldInfoString === 'string' && worldInfoString.trim()) { systemMessage += worldInfoString.trim(); worldInfoAdded = true; // console.log('[RPG Companion] โœ… Added world info from getWorldInfoPrompt'); @@ -258,6 +258,7 @@ export async function buildEncounterInitPrompt() { initInstruction += `The combat starts now.\n\n`; initInstruction += `Based on everything above, generate the initial combat state. Analyze who is in the party fighting alongside ${userName} (if anyone), and who the enemies are. Replace placeholders in [brackets] and X with actual values. Return ONLY a JSON object with the following structure:\n\n`; + initInstruction += `FORMAT:\n`; initInstruction += `{\n`; initInstruction += ` "party": [\n`; initInstruction += ` {\n`; @@ -268,7 +269,7 @@ export async function buildEncounterInitPrompt() { initInstruction += ` {"name": "Attack", "type": "single-target|AoE|both"},\n`; initInstruction += ` {"name": "Skill1", "type": "single-target|AoE|both"}\n`; initInstruction += ` ],\n`; - initInstruction += ` "items": ["Item1", "Item2"],\n`; + initInstruction += ` "items": ["Item Name x3", "Another Item x1"],\n`; initInstruction += ` "statuses": [],\n`; initInstruction += ` "isPlayer": true\n`; initInstruction += ` }\n`; @@ -302,11 +303,14 @@ export async function buildEncounterInitPrompt() { initInstruction += ` - "single-target": Can only target one character (enemy or ally)\n`; initInstruction += ` - "AoE": Area of Effect - targets all enemies, but some AoE attacks (like storms, explosions) can also harm allies if the attack is indiscriminate\n`; initInstruction += ` - "both": Player can choose to target a single enemy OR use as AoE\n`; + initInstruction += `- For items array: Include quantities using format "Item Name xN" (e.g., "Health Potion x3", "Bomb x1")\n`; + initInstruction += ` - If only one item exists, you can use "Item Name x1" or just "Item Name"\n`; + initInstruction += ` - Items will be consumed when used - the quantity will decrease in future turns\n`; initInstruction += `- Statuses array: May start empty, but don't have to if characters applied them before the combat\n`; initInstruction += ` - Each status has a format: {"name": "Status Name", "emoji": "๐Ÿ’€", "duration": X}\n`; initInstruction += ` - Examples: Poisoned (๐Ÿงช), Burning (๐Ÿ”ฅ), Blessed (โœจ), Stunned (๐Ÿ’ซ), Weakened (โฌ‡๏ธ), Strengthened (โฌ†๏ธ)\n\n`; initInstruction += `The styleNotes object will be used to visually style the combat window - choose ONE value from each category that best fits the environment described in the chat history.\n\n`; - initInstruction += `Use the user's current stats, inventory, and skills to populate the party data. For ${userName}'s attacks array, include their available skills. For items, include usable items from their inventory. Set HP based on their current Health stat if available.\n\n`; + initInstruction += `Use the user's current stats, inventory, and skills to populate the party data. For ${userName}'s attacks array, include their available skills. For items, include usable items from their inventory WITH QUANTITIES (e.g., "Health Potion x3"). Set HP based on their current Health stat if available.\n\n`; initInstruction += `Ensure all party members and enemies have realistic HP values based on the setting and their descriptions. Return ONLY the JSON object, no other text.`; // Only add the instruction if it has meaningful content @@ -364,7 +368,7 @@ export async function buildCombatActionPrompt(action, combatStats) { const result = await getWorldInfoFn(chatForWI, 8000, false); const worldInfoString = result?.worldInfoString || result; - if (worldInfoString && worldInfoString.trim()) { + if (worldInfoString && typeof worldInfoString === 'string' && worldInfoString.trim()) { systemMessage += worldInfoString.trim(); worldInfoAdded = true; } @@ -483,12 +487,25 @@ export async function buildCombatActionPrompt(action, combatStats) { stateMessage += `Party Members:\n`; combatStats.party.forEach(member => { stateMessage += `- ${member.name}${member.isPlayer ? ' (Player)' : ''}: ${member.hp}/${member.maxHp} HP\n`; - if (member.attacks && member.attacks.length > 0) { - stateMessage += ` Attacks: ${member.attacks.map(a => typeof a === 'string' ? a : a.name).join(', ')}\n`; - } - if (member.items && member.items.length > 0) { - stateMessage += ` Items: ${member.items.join(', ')}\n`; + + // For the player, use playerActions if available, otherwise fall back to member data + if (member.isPlayer && currentEncounter.playerActions) { + if (currentEncounter.playerActions.attacks && currentEncounter.playerActions.attacks.length > 0) { + stateMessage += ` Attacks: ${currentEncounter.playerActions.attacks.map(a => typeof a === 'string' ? a : a.name).join(', ')}\n`; + } + if (currentEncounter.playerActions.items && currentEncounter.playerActions.items.length > 0) { + stateMessage += ` Items: ${currentEncounter.playerActions.items.join(', ')}\n`; + } + } else { + // For non-player party members, use their own data + if (member.attacks && member.attacks.length > 0) { + stateMessage += ` Attacks: ${member.attacks.map(a => typeof a === 'string' ? a : a.name).join(', ')}\n`; + } + if (member.items && member.items.length > 0) { + stateMessage += ` Items: ${member.items.join(', ')}\n`; + } } + if (member.statuses && member.statuses.length > 0) { const validStatuses = member.statuses.filter(s => s && (s.emoji || s.name)); if (validStatuses.length > 0) { @@ -515,11 +532,39 @@ export async function buildCombatActionPrompt(action, combatStats) { }); stateMessage += `\n${userName}'s Action: ${action}\n\n`; - stateMessage += `Respond with the exact JSON object as below, containing ONLY these specified values. Remember to consider the user's party and their moves. DO NOT regenerate character descriptions, sprites, or environment:\n`; + stateMessage += `Respond with the exact JSON object as below, containing ONLY these specified values. Remember to consider the user's party and their moves. DO NOT regenerate character descriptions, sprites, or environment.\n\n`; + stateMessage += `IMPORTANT - Update ${userName}'s attacks and items arrays based on what happens in combat:\n`; + stateMessage += `- ${userName}'s action is already specified above - do NOT regenerate it. Only update ${userName}'s attacks/items arrays if their action consumed resources (used item, lost ability, etc.).\n`; + stateMessage += `- If they use an item, decrement its quantity ("Health Potion x3" becomes "Health Potion x2"). If quantity reaches 0, remove the item entirely.\n`; + stateMessage += `- If they gain or lose an ability due to status effects, add or remove it from their attacks array.\n`; + stateMessage += ` Examples: Disarmed โ†’ remove weapon attacks. Bound โ†’ remove all attacks or set to []. Freed โ†’ restore attacks.\n`; + stateMessage += `- If they pick up a weapon/item during combat, add it to their items or attacks array.\n`; + stateMessage += `- If environmental changes enable new actions (near water โ†’ "Splash Attack"), add them. If they disable actions (fire goes out โ†’ remove "Ignite"), remove them.\n`; + stateMessage += `- Status effects should persist and decrease duration each turn. Remove statuses when duration reaches 0.\n\n`; + stateMessage += `FORMAT:\n`; stateMessage += `{\n`; stateMessage += ` "combatStats": {\n`; - stateMessage += ` "party": [{ "name": "Name", "hp": X, "maxHp": X, "statuses": [...] }],\n`; - stateMessage += ` "enemies": [{ "name": "Name", "hp": X, "maxHp": X, "statuses": [...] }]\n`; + stateMessage += ` "party": [\n`; + stateMessage += ` {\n`; + stateMessage += ` "name": "Name",\n`; + stateMessage += ` "hp": X,\n`; + stateMessage += ` "maxHp": X,\n`; + stateMessage += ` "statuses": [{"name": "Status", "emoji": "๐Ÿ’€", "duration": X}],\n`; + stateMessage += ` "isPlayer": true|false\n`; + stateMessage += ` }\n`; + stateMessage += ` ],\n`; + stateMessage += ` "enemies": [\n`; + stateMessage += ` {\n`; + stateMessage += ` "name": "Name",\n`; + stateMessage += ` "hp": X,\n`; + stateMessage += ` "maxHp": X,\n`; + stateMessage += ` "statuses": [{"name": "Status", "emoji": "๐Ÿ’€", "duration": X}]\n`; + stateMessage += ` }\n`; + stateMessage += ` ]\n`; + stateMessage += ` },\n`; + stateMessage += ` "playerActions": {\n`; + stateMessage += ` "attacks": [{"name": "Attack", "type": "single-target|AoE|both"}],\n`; + stateMessage += ` "items": ["Item Name x3", "Another Item x1"]\n`; stateMessage += ` },\n`; stateMessage += ` "enemyActions": [{ "enemyName": "Name", "action": "what they do", "target": "target" }],\n`; stateMessage += ` "partyActions": [{ "memberName": "Name", "action": "what they do", "target": "target" }],\n`; @@ -587,7 +632,7 @@ export async function buildCombatSummaryPrompt(combatLog, result) { const result = await getWorldInfoFn(chatForWI, 8000, false); const worldInfoString = result?.worldInfoString || result; - if (worldInfoString && worldInfoString.trim()) { + if (worldInfoString && typeof worldInfoString === 'string' && worldInfoString.trim()) { systemMessage += worldInfoString.trim(); worldInfoAdded = true; } @@ -659,7 +704,9 @@ export async function buildCombatSummaryPrompt(combatLog, result) { summaryMessage += `\n\nProvide a narrative summary of the entire fight in a way that fits the style from the chat history. Start with [FIGHT CONCLUDED] on the first line, then provide the description.\n\n`; summaryMessage += `Write with intent in ${tense} tense ${person}-person ${narration} from ${pov}'s point of view.\n`; summaryMessage += `Build novel prose. Break patterns from your previous responses by varying sentence structures, rhythms, formats, and openings. If you last started with a narration, begin this one with dialogue; if with an action, switch to an internal thought. Track whether a sentence shape, cadence, sensory detail, or a descriptor appeared recently, and replace it with a different one or skip it entirely. If you mentioned a physical feature, mannerism, or expression once, move on. Don't fixate on the same traits every turn. Well-placed humor, unconventional similes, and subverted turns of phrase are encouraged. Avoid GPTisms, purple prose, and anaphoras, especially the negatives with "no/not/wasn't/didn't." Refrain from describing actions or emotions via negation; focus on what does happen, not what doesn't. Minimize asterisks, ellipses, and em-dashes. Explicit content is allowed. Never end on handover cues; finish naturally.\n\n`; - summaryMessage += `Express ${userName}'s actions and dialogue using indirect speech (e.g., "${userName} swung their sword" or "${userName} asked for help"). The summary should be 2-4 paragraphs and capture the essence of the battle.\n\n`; + summaryMessage += `Dialogue Guidelines:\n`; + summaryMessage += `- Include ALL dialogue lines spoken by enemies and NPC party members during the encounter in direct quotes.\n`; + summaryMessage += `- Never quote ${userName} directly. Express their actions and dialogue using ONLY indirect speech (e.g., "${userName} swung their sword" or "${userName} asked for help").\n\n`; // If in Together mode and trackers are enabled, add tracker update instructions if (extensionSettings.generationMode === 'together' && (extensionSettings.showUserStats || extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts)) { @@ -721,6 +768,12 @@ export async function buildCombatSummaryPrompt(combatLog, result) { */ export function parseEncounterJSON(response) { try { + // Ensure response is a string + if (!response || typeof response !== 'string') { + console.error('[RPG Companion] parseEncounterJSON received non-string input:', typeof response); + return null; + } + // Remove code blocks if present let cleaned = response.trim(); @@ -736,6 +789,9 @@ export function parseEncounterJSON(response) { if (firstBrace !== -1 && lastBrace !== -1) { cleaned = cleaned.substring(firstBrace, lastBrace + 1); + } else { + console.error('[RPG Companion] No JSON object found in response'); + return null; } // Try to parse directly first diff --git a/src/systems/generation/parser.js b/src/systems/generation/parser.js index d2feb7b..cf75328 100644 --- a/src/systems/generation/parser.js +++ b/src/systems/generation/parser.js @@ -198,7 +198,9 @@ export function parseResponse(responseText) { if (depth === 0) { // Found complete JSON object const jsonContent = cleanedResponse.substring(i, j).trim(); - extractedObjects.push(jsonContent); + if (jsonContent) { + extractedObjects.push(jsonContent); + } i = j; } else { i++; @@ -307,6 +309,9 @@ export function parseResponse(responseText) { for (let idx = 0; idx < jsonMatches.length; idx++) { const match = jsonMatches[idx]; const jsonContent = match[1].trim(); + + if (!jsonContent) continue; + // console.log(`[RPG Parser] Parsing JSON block ${idx + 1}:`, jsonContent.substring(0, 100) + '...'); const parsed = repairJSON(jsonContent); @@ -363,6 +368,9 @@ export function parseResponse(responseText) { debugLog('[RPG Parser] Found JSON blocks within XML tags'); for (const match of xmlJsonMatches) { const jsonContent = match[1].trim(); + + if (!jsonContent) continue; + const parsed = repairJSON(jsonContent); if (parsed) { @@ -524,7 +532,7 @@ export function parseUserStats(statsText) { // Check if this is v3 JSON format - try to parse it first let statsData = null; const trimmed = statsText.trim(); - if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + if (trimmed && (trimmed.startsWith('{') || trimmed.startsWith('['))) { statsData = repairJSON(statsText); if (statsData) { debugLog('[RPG Parser] โœ“ Parsed as v3 JSON format'); diff --git a/src/systems/ui/encounterUI.js b/src/systems/ui/encounterUI.js index 60afc1c..e5a6760 100644 --- a/src/systems/ui/encounterUI.js +++ b/src/systems/ui/encounterUI.js @@ -397,7 +397,7 @@ export class EncounterModal { - ${this.renderPlayerControls(combatData.party)} + ${this.renderPlayerControls(combatData.party, currentEncounter.playerActions)} `; @@ -599,7 +599,7 @@ export class EncounterModal { if (member.isPlayer && user_avatar) { avatarIcon = `${member.name}`; } else { - const avatarUrl = this.getPartyMemberAvatar(member.name); + const avatarUrl = this.getCharacterAvatar(member.name); if (avatarUrl) { avatarIcon = `${member.name}`; } @@ -657,12 +657,16 @@ export class EncounterModal { * @param {Array} party - Party data * @returns {string} HTML for controls */ - renderPlayerControls(party) { + renderPlayerControls(party, playerActions = null) { const player = party.find(m => m.isPlayer); if (!player || player.hp <= 0) { return '

You have been defeated...

'; } + // Use playerActions if provided, otherwise fall back to player data + const attacks = playerActions?.attacks || player.attacks || []; + const items = playerActions?.items || player.items || []; + return `

Your Actions

@@ -670,7 +674,7 @@ export class EncounterModal {

Attacks

- ${player.attacks.map(attack => { + ${attacks.map(attack => { // Support both old string format and new object format const attackName = typeof attack === 'string' ? attack : attack.name; const attackType = typeof attack === 'string' ? 'single-target' : (attack.type || 'single-target'); @@ -688,10 +692,10 @@ export class EncounterModal { }).join('')}
- ${player.items && player.items.length > 0 ? ` + ${items && items.length > 0 ? `

Items

- ${player.items.map(item => ` + ${items.map(item => ` @@ -718,21 +722,27 @@ export class EncounterModal { * @param {Array} party - Party data for reference */ attachControlListeners(party) { - // Attack and item buttons - this.modal.querySelectorAll('.rpg-encounter-action-btn').forEach(btn => { - btn.addEventListener('click', async (e) => { - const actionType = e.currentTarget.dataset.action; - const value = e.currentTarget.dataset.value; - const attackType = e.currentTarget.dataset.attackType; + // Only attach once - event delegation on the modal means listeners persist + if (this._listenersAttached) { + return; + } + + // Store handlers as instance properties so we can remove them if needed + this._actionHandler = async (e) => { + // Handle action buttons (attack/item) + const actionBtn = e.target.closest('.rpg-encounter-action-btn'); + if (actionBtn && !actionBtn.disabled && !this.isProcessing) { + const actionType = actionBtn.dataset.action; + const value = actionBtn.dataset.value; + const attackType = actionBtn.dataset.attackType; const context = getContext(); const userName = context.name1; let actionText = ''; if (actionType === 'attack') { - // Show target selection for attacks const target = await this.showTargetSelection(attackType, currentEncounter.combatStats); - if (!target) return; // User cancelled + if (!target) return; if (target === 'all-enemies') { actionText = `${userName} uses ${value} targeting all enemies!`; @@ -740,40 +750,46 @@ export class EncounterModal { actionText = `${userName} uses ${value} on ${target}!`; } } else if (actionType === 'item') { - // Show target selection for items (default to single-target) const target = await this.showTargetSelection('single-target', currentEncounter.combatStats); - if (!target) return; // User cancelled + if (!target) return; actionText = `${userName} uses ${value} on ${target}!`; } await this.processCombatAction(actionText); - }); - }); + return; + } - // Custom action submit - const customInput = this.modal.querySelector('#rpg-encounter-custom-input'); - const customSubmit = this.modal.querySelector('#rpg-encounter-custom-submit'); - - const submitCustomAction = async () => { - const action = customInput.value.trim(); - if (!action) return; - - await this.processCombatAction(action); - customInput.value = ''; + // Handle custom submit button + const submitBtn = e.target.closest('#rpg-encounter-custom-submit'); + if (submitBtn && !submitBtn.disabled && !this.isProcessing) { + const input = this.modal.querySelector('#rpg-encounter-custom-input'); + if (input) { + const action = input.value.trim(); + if (action) { + await this.processCombatAction(action); + input.value = ''; + } + } + } }; - if (customSubmit) { - customSubmit.addEventListener('click', submitCustomAction); - } - - if (customInput) { - customInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') { - submitCustomAction(); + this._keypressHandler = async (e) => { + const input = e.target.closest('#rpg-encounter-custom-input'); + if (input && e.key === 'Enter' && !this.isProcessing) { + const action = input.value.trim(); + if (action) { + await this.processCombatAction(action); + input.value = ''; } - }); - } + } + }; + + // Attach to the modal itself (which never gets replaced) + this.modal.addEventListener('click', this._actionHandler); + this.modal.addEventListener('keypress', this._keypressHandler); + + this._listenersAttached = true; } /** @@ -820,7 +836,8 @@ export class EncounterModal { // Update encounter state updateCurrentEncounter({ - combatStats: result.combatStats + combatStats: result.combatStats, + playerActions: result.playerActions }); // Collect log entries in order: enemy actions, party actions, then narration @@ -935,16 +952,75 @@ export class EncounterModal { } }); - // Re-render controls if player died + // Re-render controls if player died OR if player's actions changed const player = combatStats.party.find(m => m.isPlayer); + const controlsContainer = this.modal.querySelector('.rpg-encounter-controls'); + if (player && player.hp <= 0) { - const controlsContainer = this.modal.querySelector('.rpg-encounter-controls'); if (controlsContainer) { controlsContainer.innerHTML = '

You have been defeated...

'; } + } else if (currentEncounter.playerActions && controlsContainer) { + // Check if actions have changed by comparing with previous state + const actionsChanged = this.haveActionsChanged(currentEncounter.playerActions); + + if (actionsChanged) { + // Store the new actions for next comparison + this._previousPlayerActions = { + attacks: currentEncounter.playerActions.attacks ? JSON.parse(JSON.stringify(currentEncounter.playerActions.attacks)) : [], + items: currentEncounter.playerActions.items ? [...currentEncounter.playerActions.items] : [] + }; + + // Re-render the entire controls section with new actions + const newControlsHTML = this.renderPlayerControls(combatStats.party, currentEncounter.playerActions); + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = newControlsHTML; + const newControls = tempDiv.firstElementChild; + + if (newControls) { + controlsContainer.replaceWith(newControls); + } + } } } + /** + * Checks if player's available actions have changed + * @param {Object} playerActions - Current player actions data with attacks and items + * @returns {boolean} True if actions changed + */ + haveActionsChanged(playerActions) { + if (!this._previousPlayerActions) { + // First time - store initial actions + this._previousPlayerActions = { + attacks: playerActions.attacks ? JSON.parse(JSON.stringify(playerActions.attacks)) : [], + items: playerActions.items ? [...playerActions.items] : [] + }; + return false; + } + + const currentAttacks = playerActions.attacks || []; + const currentItems = playerActions.items || []; + const prevAttacks = this._previousPlayerActions.attacks || []; + const prevItems = this._previousPlayerActions.items || []; + + // Check if attacks changed + if (currentAttacks.length !== prevAttacks.length) return true; + for (let i = 0; i < currentAttacks.length; i++) { + const curr = typeof currentAttacks[i] === 'string' ? currentAttacks[i] : currentAttacks[i].name; + const prev = typeof prevAttacks[i] === 'string' ? prevAttacks[i] : prevAttacks[i].name; + if (curr !== prev) return true; + } + + // Check if items changed + if (currentItems.length !== prevItems.length) return true; + for (let i = 0; i < currentItems.length; i++) { + if (currentItems[i] !== prevItems[i]) return true; + } + + return false; + } + /** * Adds multiple log entries sequentially with animation * @param {Array} entries - Array of {message, type} objects diff --git a/src/utils/jsonRepair.js b/src/utils/jsonRepair.js index 5d08599..898bc32 100644 --- a/src/utils/jsonRepair.js +++ b/src/utils/jsonRepair.js @@ -11,13 +11,17 @@ * @returns {object|null} Repaired JSON object or null if repair fails */ export function repairJSON(jsonString) { - if (!jsonString || typeof jsonString !== 'string') { - console.warn('[RPG JSON Repair] Invalid input:', typeof jsonString); + if (typeof jsonString !== 'string') { + console.warn('[RPG JSON Repair] Invalid input type:', typeof jsonString); return null; } let cleaned = jsonString.trim(); + if (!cleaned) { + return null; + } + // Remove markdown code fences cleaned = cleaned.replace(/```json\s*/gi, ''); cleaned = cleaned.replace(/```\s*/g, ''); @@ -147,7 +151,8 @@ export function extractJSONFromText(text) { // Try to extract from ```json code fence const fenceMatch = text.match(/```json\s*([\s\S]*?)```/i); if (fenceMatch && fenceMatch[1]) { - return fenceMatch[1].trim(); + const trimmed = fenceMatch[1].trim(); + if (trimmed) return trimmed; } // Try to extract from ``` code fence (without json label) @@ -155,20 +160,20 @@ export function extractJSONFromText(text) { if (genericFenceMatch && genericFenceMatch[1]) { const content = genericFenceMatch[1].trim(); // Check if it looks like JSON (starts with { or [) - if (content.startsWith('{') || content.startsWith('[')) { + if (content && (content.startsWith('{') || content.startsWith('['))) { return content; } } // Try to find standalone JSON object const objectMatch = text.match(/\{[\s\S]*\}/); - if (objectMatch) { + if (objectMatch && objectMatch[0].trim()) { return objectMatch[0]; } // Try to find standalone JSON array const arrayMatch = text.match(/\[[\s\S]*\]/); - if (arrayMatch) { + if (arrayMatch && arrayMatch[0].trim()) { return arrayMatch[0]; }