import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse"; import { useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { GeneratedImage } from "./GeneratedImage"; import { addToLore } from "../lib/lore"; import { useToast } from "./Toast"; import { addGeneration, extractTitle, type Generation } from "../lib/generations"; import { usePrefillEffect } from "../lib/usePrefill"; import { RACES, BACKGROUNDS, ALIGNMENTS, randomName } from "../lib/npc-data"; import { Dice5 } from "lucide-react"; interface NpcResponse { bio: string; personality: string[]; goals: string[]; } interface Props { prefill?: Generation | null; onPrefillConsumed?: () => void; } export function NpcGenerator({ prefill, onPrefillConsumed }: Props = {}) { const [race, setRace] = useState("Dwarf"); const [background, setBackground] = useState("Guild Artisan"); const [alignment, setAlignment] = useState("Chaotic Good"); const [name, setName] = useState(""); const [npc, setNpc] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [portraitNonce, setPortraitNonce] = useState(0); const { addToast } = useToast(); usePrefillEffect(prefill ?? null, "npc", () => onPrefillConsumed?.(), (g) => { const parsed = parseLlmJson(g.data); if (parsed) { setNpc(parsed); if (g.title) setName(g.title); addToast(`Loaded "${g.title}" from history`, "info"); } }); // ponytail: portrait prompt mirrors the NPC inputs; FLUX.2 renders readable // text so we drop the name in-image when one is given. const portraitPrompt = npc ? `fantasy oil-painting portrait of a ${race} ${background.toLowerCase()}, ${alignment.toLowerCase()} demeanor, ${ensureArray(npc.personality).slice(0, 2).map(flattenValue).join(", ")}, dramatic lighting, 1024x1024${name ? `, name "${name}" rendered as a caption` : ""}` : null; async function generate() { setLoading(true); setError(""); setNpc(null); try { const prompt = `Create a detailed NPC for a fantasy RPG: ${name ? `Name: ${name}` : "Name: (generate one)"} Race: ${race} Background: ${background} 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 (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("generate", { 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, ragQuery: `${race} ${background} ${alignment} NPC` }, }); // Try to parse JSON from the response const parsed = parseLlmJson(result); if (parsed) { setNpc(parsed); addToast("NPC generated", "success"); // ponytail: persist to history so the user can re-open it later. const title = name.trim() || extractTitle("npc", result, `${race} ${background}`); const source = `${race} ${background} (${alignment})${name ? `, "${name}"` : ""}`; void addGeneration({ kind: "npc", title, data: result, source }); // ponytail: ingest every generated NPC into lore so later generations // stay consistent with the growing cast. const persona = ensureArray(parsed.personality).map(flattenValue).join(", "); const goals = ensureArray(parsed.goals).map(flattenValue).join("; "); void addToLore( `NPC: ${name || race + " " + background}`, `${name || "An unnamed NPC"} — a ${race} ${background} (${alignment}). ${parsed.bio}\nPersonality: ${persona}\nGoals: ${goals}`, ); } else { setError("Could not parse LLM response. Try again or check your LLM connection."); addToast("LLM returned an unparseable response", "error"); } } catch (e) { setError(String(e)); addToast(`NPC generation failed: ${e}`, "error"); } setLoading(false); } return (
{/* Controls */}
setName(e.target.value)} placeholder="Random" />
{/* Result */} {error && (
{error}
)} {npc && (

{name || "NPC"}

{npc.bio}

Personality:
{ensureArray(npc.personality).map((p, i) => ( {flattenValue(p)} ))}
Goals:
    {ensureArray(npc.goals).map((g, i) =>
  • {flattenValue(g)}
  • )}
)}
); }