fix: robust LLM JSON parsing across all AI components
- Created shared lib/llm-parse.ts with extractJson(), parseLlmJson(), flattenValue(), and ensureArray() utilities - Strips markdown code fences (\`\`\`json ... \`\`\`) before parsing - Handles nested objects where strings were expected (flattens them) - Handles arrays that come back as strings or objects - Updated all AI prompts to explicitly request raw JSON with no fences - All 5 AI components now use parseLlmJson() instead of raw regex - ItemForge shows raw LLM response in expandable <details> when parsing fails - Added 'IMPORTANT: Return ONLY raw JSON' instruction to all prompts - Full production build passing
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse";
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
@@ -26,21 +27,22 @@ export function EncounterBuilder() {
|
||||
prompt: `Generate a D&D 5e encounter for a party of ${partySize} level-${partyLevel} characters in a ${terrain.toLowerCase()} setting.
|
||||
|
||||
Provide the response as a JSON object with exactly these keys:
|
||||
- "monsters": an array of 2-4 monster descriptions with quantities (e.g. "3x Goblin Scouts")
|
||||
- "monsters": an array of 2-4 monster descriptions with quantities (e.g. "3x Goblin Scouts") — each must be a string, not an object
|
||||
- "terrain": a brief terrain description with environmental features
|
||||
- "difficulty": one of Easy/Medium/Hard/Deadly
|
||||
- "loot": a brief treasure description`,
|
||||
system: "You are a D&D encounter designer. Always respond with valid JSON only.",
|
||||
- "loot": a brief treasure description
|
||||
|
||||
IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO extra text. Start with { and end with }.`,
|
||||
system: "You are a D&D encounter designer. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.",
|
||||
temperature: 0.9,
|
||||
max_tokens: 400,
|
||||
},
|
||||
});
|
||||
const jsonMatch = result.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]) as Encounter;
|
||||
const parsed = parseLlmJson<Encounter>(result);
|
||||
if (parsed) {
|
||||
setEncounter(parsed);
|
||||
} else {
|
||||
setError("LLM did not return valid JSON. Raw:\n" + result);
|
||||
setError("Could not parse LLM response. Try again or check your LLM connection.");
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
@@ -116,7 +118,7 @@ Provide the response as a JSON object with exactly these keys:
|
||||
<div>
|
||||
<span className="text-[var(--color-text-secondary)] text-[10px] font-medium">Monsters</span>
|
||||
<ul className="list-disc list-inside text-[var(--color-text-primary)] text-xs mt-0.5">
|
||||
{encounter.monsters?.map((m, i) => <li key={i}>{m}</li>)}
|
||||
{ensureArray(encounter.monsters).map((m, i) => <li key={i}>{flattenValue(m)}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { parseLlmJson, flattenValue } from "../lib/llm-parse";
|
||||
|
||||
interface ItemResponse {
|
||||
name: string;
|
||||
rarity: string;
|
||||
type: string;
|
||||
description: string;
|
||||
mechanical: string;
|
||||
mechanical: string | Record<string, unknown>;
|
||||
lore: string;
|
||||
}
|
||||
|
||||
@@ -18,6 +19,7 @@ export function ItemForge() {
|
||||
const [itemType, setItemType] = useState("Wondrous Item");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [item, setItem] = useState<ItemResponse | null>(null);
|
||||
const [rawResponse, setRawResponse] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@@ -25,6 +27,7 @@ export function ItemForge() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setItem(null);
|
||||
setRawResponse(null);
|
||||
try {
|
||||
const result = await invoke<string>("generate", {
|
||||
req: {
|
||||
@@ -33,23 +36,29 @@ Rarity: ${rarity}
|
||||
Type: ${itemType}
|
||||
${prompt ? `Additional details: ${prompt}` : ""}
|
||||
|
||||
Provide the response as a JSON object with exactly these keys:
|
||||
- "name": the item's evocative name
|
||||
- "rarity": "${rarity}"
|
||||
- "type": "${itemType}"
|
||||
- "description": a vivid 2-3 sentence physical description
|
||||
- "mechanical": the item's game mechanics (what it does, charges, etc.)
|
||||
- "lore": a 1-2 sentence piece of lore or history about the item`,
|
||||
system: "You are a creative D&D item designer. Always respond with valid JSON only.",
|
||||
IMPORTANT: Return ONLY a raw JSON object, with NO markdown fences, NO code blocks, NO extra text.
|
||||
The response must start with { and end with }.
|
||||
|
||||
The JSON must have exactly these keys:
|
||||
- "name": the item's evocative name (string)
|
||||
- "rarity": "${rarity}" (string)
|
||||
- "type": "${itemType}" (string)
|
||||
- "description": a vivid 2-3 sentence physical description (string)
|
||||
- "mechanical": the item's game mechanics as a single descriptive paragraph (string, NOT an object)
|
||||
- "lore": a 1-2 sentence piece of lore or history (string)`,
|
||||
system: "You are a creative D&D item designer. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.",
|
||||
temperature: 0.85,
|
||||
max_tokens: 500,
|
||||
},
|
||||
});
|
||||
const jsonMatch = result.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
setItem(JSON.parse(jsonMatch[0]) as ItemResponse);
|
||||
|
||||
setRawResponse(result);
|
||||
const parsed = parseLlmJson<ItemResponse>(result);
|
||||
|
||||
if (parsed) {
|
||||
setItem(parsed);
|
||||
} else {
|
||||
setError("LLM did not return valid JSON.\n" + result);
|
||||
setError("Could not parse LLM response as JSON. Raw response shown below.");
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
@@ -66,6 +75,9 @@ Provide the response as a JSON object with exactly these keys:
|
||||
Artifact: "#e6cc80",
|
||||
};
|
||||
|
||||
const mechanicalText = item ? flattenValue(item.mechanical) : "";
|
||||
const hasParseError = !item && rawResponse;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -123,32 +135,48 @@ Provide the response as a JSON object with exactly these keys:
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-heading text-lg font-bold" style={{ color: rarityColor[item.rarity] || "var(--color-gold-bright)" }}>
|
||||
{item.name}
|
||||
{item.name || "Unknown Item"}
|
||||
</h3>
|
||||
<div className="flex gap-2 text-xs text-[var(--color-text-dim)]">
|
||||
<span>{item.rarity}</span>
|
||||
<span>{item.rarity || rarity}</span>
|
||||
<span>•</span>
|
||||
<span>{item.type}</span>
|
||||
<span>{item.type || itemType}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-[var(--color-text-secondary)] text-[10px] font-medium uppercase tracking-wider">Description</span>
|
||||
<p className="text-[var(--color-text-primary)] text-sm mt-0.5 leading-relaxed">{item.description}</p>
|
||||
<p className="text-[var(--color-text-primary)] text-sm mt-0.5 leading-relaxed">
|
||||
{flattenValue(item.description)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-[var(--color-bg-deep)] border border-[var(--color-border-glass)] p-3">
|
||||
<span className="text-[var(--color-gold-bright)] text-[10px] font-medium uppercase tracking-wider">Mechanics</span>
|
||||
<p className="text-[var(--color-text-primary)] text-sm mt-0.5">{item.mechanical}</p>
|
||||
<p className="text-[var(--color-text-primary)] text-sm mt-0.5">{mechanicalText}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-[var(--color-text-secondary)] text-[10px] font-medium uppercase tracking-wider">Lore</span>
|
||||
<p className="text-[var(--color-text-secondary)] text-sm mt-0.5 italic">{item.lore}</p>
|
||||
<p className="text-[var(--color-text-secondary)] text-sm mt-0.5 italic">
|
||||
{flattenValue(item.lore)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show raw response if JSON parsing failed */}
|
||||
{hasParseError && (
|
||||
<details className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)]">
|
||||
<summary className="px-3 py-2 text-xs text-[var(--color-text-dim)] cursor-pointer hover:text-[var(--color-text-secondary)]">
|
||||
Raw LLM response (click to expand)
|
||||
</summary>
|
||||
<pre className="px-3 pb-3 text-xs text-[var(--color-text-secondary)] overflow-x-auto whitespace-pre-wrap">
|
||||
{rawResponse}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse";
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
@@ -32,20 +33,21 @@ Alignment: ${alignment}
|
||||
|
||||
Provide the response as a JSON object with exactly these keys:
|
||||
- "bio": a 2-3 sentence backstory
|
||||
- "personality": an array of 3-5 personality traits
|
||||
- "goals": an array of 2-3 goals or motivations`;
|
||||
- "personality": an array of 3-5 personality traits (each trait must be a string, not an object)
|
||||
- "goals": an array of 2-3 goals or motivations (each goal must be a string, not an object)
|
||||
|
||||
IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO extra text. Start with { and end with }.`;
|
||||
|
||||
const result = await invoke<string>("generate", {
|
||||
req: { prompt, system: "You are a creative D&D dungeon master. Always respond with valid JSON only.", temperature: 0.8, max_tokens: 512 },
|
||||
req: { prompt, system: "You are a creative D&D dungeon master. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.", temperature: 0.8, max_tokens: 512 },
|
||||
});
|
||||
|
||||
// Try to parse JSON from the response
|
||||
const jsonMatch = result.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]) as NpcResponse;
|
||||
const parsed = parseLlmJson<NpcResponse>(result);
|
||||
if (parsed) {
|
||||
setNpc(parsed);
|
||||
} else {
|
||||
setError("LLM did not return valid JSON. Raw response:\n" + result);
|
||||
setError("Could not parse LLM response. Try again or check your LLM connection.");
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
@@ -120,9 +122,9 @@ Provide the response as a JSON object with exactly these keys:
|
||||
<div>
|
||||
<span className="text-[var(--color-text-secondary)] text-xs font-medium">Personality:</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{npc.personality?.map((p, i) => (
|
||||
{ensureArray(npc.personality).map((p, i) => (
|
||||
<span key={i} className="rounded-full bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)] px-2 py-0.5 text-xs">
|
||||
{p}
|
||||
{flattenValue(p)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -130,7 +132,7 @@ Provide the response as a JSON object with exactly these keys:
|
||||
<div>
|
||||
<span className="text-[var(--color-text-secondary)] text-xs font-medium">Goals:</span>
|
||||
<ul className="list-disc list-inside text-[var(--color-text-primary)] text-xs mt-1">
|
||||
{npc.goals?.map((g, i) => <li key={i}>{g}</li>)}
|
||||
{ensureArray(npc.goals).map((g, i) => <li key={i}>{flattenValue(g)}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseLlmJson } from "../lib/llm-parse";
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
@@ -39,17 +40,19 @@ Provide the response as a JSON object with exactly these keys:
|
||||
- "hook": 1-2 sentences describing how the party gets involved
|
||||
- "steps": an array of 3-5 quest steps, each with "title" and "description" (2-3 sentences each), and optionally "choice" (a meaningful decision the party faces)
|
||||
- "twist": a surprise revelation or complication
|
||||
- "reward": what the party gains on completion`,
|
||||
system: "You are a creative D&D quest designer. Always respond with valid JSON only.",
|
||||
- "reward": what the party gains on completion
|
||||
|
||||
IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO extra text. Start with { and end with }.`,
|
||||
system: "You are a creative D&D quest designer. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.",
|
||||
temperature: 0.85,
|
||||
max_tokens: 700,
|
||||
},
|
||||
});
|
||||
const jsonMatch = result.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
setQuest(JSON.parse(jsonMatch[0]) as QuestResponse);
|
||||
const parsed = parseLlmJson<QuestResponse>(result);
|
||||
if (parsed) {
|
||||
setQuest(parsed);
|
||||
} else {
|
||||
setError("LLM did not return valid JSON.\n" + result);
|
||||
setError("Could not parse LLM response. Try again or check your LLM connection.");
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse";
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
@@ -28,20 +29,22 @@ export function WorldBuilder() {
|
||||
Provide the response as a JSON object with exactly these keys:
|
||||
- "name": the world's name
|
||||
- "description": a 2-3 sentence overview
|
||||
- "regions": an array of 3-4 region names with brief descriptions
|
||||
- "landmarks": an array of 2-3 notable landmarks
|
||||
- "conflicts": an array of 2-3 major conflicts or tensions
|
||||
- "cultures": an array of 2-3 distinct cultures or peoples`,
|
||||
system: "You are a creative world-building DM. Always respond with valid JSON only.",
|
||||
- "regions": an array of 3-4 region names with brief descriptions (each must be a string)
|
||||
- "landmarks": an array of 2-3 notable landmarks (each must be a string)
|
||||
- "conflicts": an array of 2-3 major conflicts or tensions (each must be a string)
|
||||
- "cultures": an array of 2-3 distinct cultures or peoples (each must be a string)
|
||||
|
||||
IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO extra text. Start with { and end with }.`,
|
||||
system: "You are a creative world-building DM. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.",
|
||||
temperature: 0.9,
|
||||
max_tokens: 600,
|
||||
},
|
||||
});
|
||||
const jsonMatch = result.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
setWorld(JSON.parse(jsonMatch[0]) as WorldResponse);
|
||||
const parsed = parseLlmJson<WorldResponse>(result);
|
||||
if (parsed) {
|
||||
setWorld(parsed);
|
||||
} else {
|
||||
setError("LLM did not return valid JSON.\n" + result);
|
||||
setError("Could not parse LLM response. Try again or check your LLM connection.");
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
@@ -96,9 +99,9 @@ Provide the response as a JSON object with exactly these keys:
|
||||
🏔 Regions
|
||||
</h4>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{world.regions.map((r, i) => (
|
||||
{ensureArray(world.regions).map((r, i) => (
|
||||
<div key={i} className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] px-3 py-2 text-sm text-[var(--color-text-primary)]">
|
||||
{r}
|
||||
{flattenValue(r)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -112,14 +115,14 @@ Provide the response as a JSON object with exactly these keys:
|
||||
🏛 Landmarks
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{world.landmarks.map((l, i) => (
|
||||
{ensureArray(world.landmarks).map((l, i) => (
|
||||
<span key={i} className="rounded-full bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)] px-3 py-1 text-xs">
|
||||
{l}
|
||||
{flattenValue(l)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)})
|
||||
|
||||
{/* Conflicts */}
|
||||
{world.conflicts?.length > 0 && (
|
||||
@@ -128,14 +131,14 @@ Provide the response as a JSON object with exactly these keys:
|
||||
⚔ Conflicts
|
||||
</h4>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{world.conflicts.map((c, i) => (
|
||||
{ensureArray(world.conflicts).map((c, i) => (
|
||||
<div key={i} className="rounded-lg bg-[var(--color-bg-surface)] border-l-2 border-[var(--color-danger)] px-3 py-2 text-sm text-[var(--color-text-primary)]">
|
||||
{c}
|
||||
{flattenValue(c)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)})
|
||||
|
||||
{/* Cultures */}
|
||||
{world.cultures?.length > 0 && (
|
||||
@@ -144,14 +147,14 @@ Provide the response as a JSON object with exactly these keys:
|
||||
👥 Cultures
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{world.cultures.map((c, i) => (
|
||||
{ensureArray(world.cultures).map((c, i) => (
|
||||
<span key={i} className="rounded-full bg-[var(--color-bg-surface)] border border-[var(--color-info)]/30 text-[var(--color-info)] px-3 py-1 text-xs">
|
||||
{c}
|
||||
{flattenValue(c)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Parse LLM output into JSON, handling common LLM quirks:
|
||||
* 1. Markdown code fences (```json ... ``` or ``` ... ```)
|
||||
* 2. Leading/trailing text around the JSON
|
||||
* 3. Nested objects where strings were expected (flatten them)
|
||||
*/
|
||||
|
||||
/** Strip markdown code fences and extract the JSON object */
|
||||
export function extractJson(raw: string): string | null {
|
||||
// Remove markdown code fences: ```json ... ``` or ``` ... ```
|
||||
let cleaned = raw.trim();
|
||||
|
||||
// Try to extract from code fence
|
||||
const fenceMatch = cleaned.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
|
||||
if (fenceMatch) {
|
||||
cleaned = fenceMatch[1].trim();
|
||||
}
|
||||
|
||||
// Find the outermost { ... } brace pair
|
||||
const start = cleaned.indexOf("{");
|
||||
const end = cleaned.lastIndexOf("}");
|
||||
if (start !== -1 && end > start) {
|
||||
return cleaned.substring(start, end + 1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parse JSON from LLM output, stripping fences and handling errors */
|
||||
export function parseLlmJson<T>(raw: string): T | null {
|
||||
const jsonStr = extractJson(raw);
|
||||
if (!jsonStr) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(jsonStr) as T;
|
||||
} catch (e) {
|
||||
console.error("JSON parse error:", e, "Input:", jsonStr);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten nested objects into display-friendly strings.
|
||||
* If a value is an object, stringify it nicely.
|
||||
* If a value is an array of objects, join them.
|
||||
*/
|
||||
export function flattenValue(val: unknown): string {
|
||||
if (val === null || val === undefined) return "";
|
||||
if (typeof val === "string") return val;
|
||||
if (typeof val === "number") return String(val);
|
||||
if (typeof val === "boolean") return String(val);
|
||||
if (Array.isArray(val)) {
|
||||
return val.map(flattenValue).join("\n");
|
||||
}
|
||||
if (typeof val === "object") {
|
||||
// Pretty-format the object as key: value pairs
|
||||
return Object.entries(val as Record<string, unknown>)
|
||||
.map(([k, v]) => {
|
||||
const formatted = typeof v === "string" ? v : JSON.stringify(v);
|
||||
return `**${formatKey(k)}**: ${formatted}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
return String(val);
|
||||
}
|
||||
|
||||
/** Convert camelCase or snake_case keys to Title Case */
|
||||
function formatKey(key: string): string {
|
||||
return key
|
||||
.replace(/_/g, " ")
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** Safely get an array from a field that might be a string or array */
|
||||
export function ensureArray(val: unknown): string[] {
|
||||
if (Array.isArray(val)) {
|
||||
return val.map((v) => (typeof v === "string" ? v : JSON.stringify(v)));
|
||||
}
|
||||
if (typeof val === "string") {
|
||||
// Try to parse as JSON array
|
||||
try {
|
||||
const parsed = JSON.parse(val);
|
||||
if (Array.isArray(parsed)) return parsed.map((v: unknown) => String(v));
|
||||
} catch {
|
||||
// Not JSON, return as single-element array
|
||||
}
|
||||
return [val];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
Reference in New Issue
Block a user