adding the first run wizard again
This commit is contained in:
+47
-22
@@ -31,6 +31,7 @@ import { QuestDesigner } from "./components/QuestDesigner";
|
|||||||
import { Soundboard } from "./components/Soundboard";
|
import { Soundboard } from "./components/Soundboard";
|
||||||
import { LorePanel } from "./components/LorePanel";
|
import { LorePanel } from "./components/LorePanel";
|
||||||
import { ImageGenerator } from "./components/ImageGenerator";
|
import { ImageGenerator } from "./components/ImageGenerator";
|
||||||
|
import { FirstRunWizard } from "./components/FirstRunWizard";
|
||||||
import { ToastContainer } from "./components/Toast";
|
import { ToastContainer } from "./components/Toast";
|
||||||
import { CommandPalette } from "./components/CommandPalette";
|
import { CommandPalette } from "./components/CommandPalette";
|
||||||
import { ShortcutHelp } from "./components/ShortcutHelp";
|
import { ShortcutHelp } from "./components/ShortcutHelp";
|
||||||
@@ -172,6 +173,7 @@ export default function App() {
|
|||||||
// to clear it. State lives here so the rehydrate→switch→consume dance
|
// to clear it. State lives here so the rehydrate→switch→consume dance
|
||||||
// doesn't depend on a shared store.
|
// doesn't depend on a shared store.
|
||||||
const [prefill, setPrefill] = useState<Generation | null>(null);
|
const [prefill, setPrefill] = useState<Generation | null>(null);
|
||||||
|
const [showWizard, setShowWizard] = useState(false);
|
||||||
|
|
||||||
function rehydrate(kind: GenerationKind, data: Generation) {
|
function rehydrate(kind: GenerationKind, data: Generation) {
|
||||||
const target = KIND_TO_VIEW[kind];
|
const target = KIND_TO_VIEW[kind];
|
||||||
@@ -180,6 +182,11 @@ export default function App() {
|
|||||||
setView(target);
|
setView(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const completeWizard = () => {
|
||||||
|
localStorage.setItem('dm-pal-wizard-completed', 'true');
|
||||||
|
setShowWizard(false);
|
||||||
|
};
|
||||||
|
|
||||||
// ⌘K / Ctrl-K opens the command palette; ⌘H opens the History page; Esc closes the palette.
|
// ⌘K / Ctrl-K opens the command palette; ⌘H opens the History page; Esc closes the palette.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
@@ -237,6 +244,20 @@ export default function App() {
|
|||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [paletteOpen, helpOpen]);
|
}, [paletteOpen, helpOpen]);
|
||||||
|
|
||||||
|
// Check if we need to show the first-run wizard
|
||||||
|
useEffect(() => {
|
||||||
|
const completed = localStorage.getItem('dm-pal-wizard-completed');
|
||||||
|
if (!completed && view !== 'settings') {
|
||||||
|
setShowWizard(true);
|
||||||
|
}
|
||||||
|
}, [view]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (view === 'settings') {
|
||||||
|
setShowWizard(false);
|
||||||
|
}
|
||||||
|
}, [view]);
|
||||||
|
|
||||||
const isOnDashboard = view === "dashboard";
|
const isOnDashboard = view === "dashboard";
|
||||||
const groupedNav = useMemo(
|
const groupedNav = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -376,28 +397,32 @@ export default function App() {
|
|||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="flex-1 overflow-auto" id="main-content">
|
<main className="flex-1 overflow-auto" id="main-content">
|
||||||
<ErrorBoundary>
|
{showWizard ? (
|
||||||
{isOnDashboard ? (
|
<FirstRunWizard onComplete={completeWizard} />
|
||||||
<Dashboard onNavigate={setView} />
|
) : (
|
||||||
) : view === "history" ? (
|
<ErrorBoundary>
|
||||||
<HistoryView onRehydrate={(k, d) => rehydrate(k, d)} />
|
{isOnDashboard ? (
|
||||||
) : (
|
<Dashboard onNavigate={setView} />
|
||||||
<div className={`${TOOL_META[view]?.maxWidth ?? DEFAULT_MAX_WIDTH} mx-auto p-6`}>
|
) : view === "history" ? (
|
||||||
{view === "settings" ? (
|
<HistoryView onRehydrate={(k, d) => rehydrate(k, d)} />
|
||||||
<div className="glass-card p-6">
|
) : (
|
||||||
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
|
<div className={`${TOOL_META[view]?.maxWidth ?? DEFAULT_MAX_WIDTH} mx-auto p-6`}>
|
||||||
Settings
|
{view === "settings" ? (
|
||||||
</h2>
|
<div className="glass-card p-6">
|
||||||
<SettingsPanel />
|
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
|
||||||
</div>
|
Settings
|
||||||
) : (
|
</h2>
|
||||||
<div className="glass-card p-6">
|
<SettingsPanel />
|
||||||
{renderView(view, prefill, () => setPrefill(null), rehydrate)}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<div className="glass-card p-6">
|
||||||
</div>
|
{renderView(view, prefill, () => setPrefill(null), rehydrate)}
|
||||||
)}
|
</div>
|
||||||
</ErrorBoundary>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ErrorBoundary>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
|
||||||
|
export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
|
||||||
|
const [step, setStep] = useState("welcome");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [models, setModels] = useState<string[]>([]);
|
||||||
|
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||||
|
// ponytail: wizard defaults to local Ollama; no API-key UI here (Settings covers remote).
|
||||||
|
const apiUrl = "http://localhost:11434";
|
||||||
|
const apiKey = "";
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step === "check") {
|
||||||
|
checkOllama();
|
||||||
|
}
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
|
async function checkOllama() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
// Test connection to the default Ollama URL
|
||||||
|
const result = await invoke<{ ok: boolean; models: string[]; error: string }>(
|
||||||
|
"test_connection",
|
||||||
|
{ config: { api_url: apiUrl, api_key: apiKey, model: "", temperature: 0.7, max_tokens: 512, top_p: 0.9, image_model: "", embed_model: "" } }
|
||||||
|
);
|
||||||
|
if (result.ok) {
|
||||||
|
setModels(result.models);
|
||||||
|
setStep("model");
|
||||||
|
} else {
|
||||||
|
setError(result.error || "Failed to connect to Ollama");
|
||||||
|
setStep("error");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
setStep("error");
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pullModel(model: string) {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
// We don't have a direct pull command, but we can suggest the user to run `ollama pull` in terminal.
|
||||||
|
// For now, we'll just set the model in config and hope it exists.
|
||||||
|
// Alternatively, we could invoke a generate command to trigger a pull? Not sure.
|
||||||
|
// We'll just set the config and complete.
|
||||||
|
await invoke("set_llm_config", {
|
||||||
|
config: {
|
||||||
|
api_url: apiUrl,
|
||||||
|
api_key: apiKey,
|
||||||
|
model: model,
|
||||||
|
temperature: 0.7,
|
||||||
|
max_tokens: 512,
|
||||||
|
top_p: 0.9,
|
||||||
|
image_model: "x/flux2-klein:4b",
|
||||||
|
embed_model: "nomic-embed-text",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
addToast(`Model ${model} selected. You may need to run 'ollama pull ${model}' if not already downloaded.`, "success");
|
||||||
|
setStep("done");
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
setStep("error");
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function skip() {
|
||||||
|
// Skip wizard and go to settings
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
function back() {
|
||||||
|
if (step === "model") {
|
||||||
|
setStep("welcome");
|
||||||
|
} else if (step === "error") {
|
||||||
|
setStep("welcome");
|
||||||
|
} else if (step === "done") {
|
||||||
|
// Should not happen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (step === "welcome") {
|
||||||
|
setStep("check");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === "model") {
|
||||||
|
if (!selectedModel) {
|
||||||
|
setError("Please select a model");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await pullModel(selectedModel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === "done") {
|
||||||
|
onComplete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === "welcome") {
|
||||||
|
return (
|
||||||
|
<div className="glass-card p-6 max-w-md w-full mx-auto">
|
||||||
|
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
|
||||||
|
Welcome to DM-Pal
|
||||||
|
</h2>
|
||||||
|
<p className="text-[var(--color-text-primary)] text-sm mb-4">
|
||||||
|
To generate content, DM-Pal needs a language model. Let's set up a local model using Ollama (recommended for privacy and offline use).
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setStep("check")}
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "Checking…" : "Check for Ollama"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={skip}
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-4 py-2 text-sm hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Skip for now (go to Settings)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-[var(--color-danger)] text-sm mt-4">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === "check") {
|
||||||
|
return (
|
||||||
|
<div className="glass-card p-6 max-w-md w-full mx-auto">
|
||||||
|
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
|
||||||
|
Checking for Ollama
|
||||||
|
</h2>
|
||||||
|
<p className="text-[var(--color-text-primary)] text-sm mb-4">
|
||||||
|
We're checking if Ollama is running at {apiUrl}...
|
||||||
|
</p>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="w-4 h-4 border-2 border-[var(--color-gold-bright)] border-t-transparent rounded-full animate-spin" />
|
||||||
|
<p className="text-[var(--color-text-dim)] text-xs">Checking…</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{error ? (
|
||||||
|
<p className="text-[var(--color-danger)] text-sm">{error}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-[var(--color-success)] text-sm">Ollama is running!</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setStep("model")}
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Continue to model selection
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={skip}
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-4 py-2 text-sm hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Skip for now (go to Settings)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === "model") {
|
||||||
|
return (
|
||||||
|
<div className="glass-card p-6 max-w-md w-full mx-auto">
|
||||||
|
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
|
||||||
|
Select a Model
|
||||||
|
</h2>
|
||||||
|
<p className="text-[var(--color-text-primary)] text-sm mb-4">
|
||||||
|
Ollama is running. Choose a model to use for text generation.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Model</label>
|
||||||
|
<select
|
||||||
|
value={selectedModel}
|
||||||
|
onChange={(e) => setSelectedModel(e.target.value)}
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
{models.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
{m === "llama3.2" && " (recommended)"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading || !selectedModel}
|
||||||
|
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "Setting model…" : "Set Model"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={back}
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-4 py-2 text-sm hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={skip}
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-4 py-2 text-sm hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Skip for now (go to Settings)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-[var(--color-danger)] text-sm mt-4">{error}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-[var(--color-text-dim)] text-sm mt-4">
|
||||||
|
If you don't see your model, you may need to download it first. In a terminal, run:{' '}
|
||||||
|
<code className="font-mono text-[var(--color-gold-bright)]">ollama pull llama3.2</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === "done") {
|
||||||
|
return (
|
||||||
|
<div className="glass-card p-6 max-w-md w-full mx-auto">
|
||||||
|
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
|
||||||
|
Setup Complete
|
||||||
|
</h2>
|
||||||
|
<p className="text-[var(--color-text-primary)] text-sm mb-4">
|
||||||
|
DM-Pal is now configured to use {selectedModel} for text generation.
|
||||||
|
Image generation uses x/flux2-klein:4b (macOS-only via Ollama).
|
||||||
|
Embedding model for lore search is set to nomic-embed-text.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => onComplete()}
|
||||||
|
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Go to Dashboard
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// Go to settings to review
|
||||||
|
// We'll just complete and let the user navigate to settings if they want
|
||||||
|
onComplete();
|
||||||
|
}}
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-4 py-2 text-sm hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Review Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === "error") {
|
||||||
|
return (
|
||||||
|
<div className="glass-card p-6 max-w-md w-full mx-auto">
|
||||||
|
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
|
||||||
|
Error
|
||||||
|
</h2>
|
||||||
|
<p className="text-[var(--color-text-primary)] text-sm mb-4">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setStep("welcome")}
|
||||||
|
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={skip}
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-4 py-2 text-sm hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Skip for now (go to Settings)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { addGeneration, extractTitle, type Generation } from "../lib/generations
|
|||||||
import { usePrefillEffect } from "../lib/usePrefill";
|
import { usePrefillEffect } from "../lib/usePrefill";
|
||||||
import { usePersistentState } from "../lib/usePersistentState";
|
import { usePersistentState } from "../lib/usePersistentState";
|
||||||
import { WorldMap } from "./WorldMap";
|
import { WorldMap } from "./WorldMap";
|
||||||
|
import { GeneratedImage } from "./GeneratedImage";
|
||||||
|
|
||||||
interface WorldResponse {
|
interface WorldResponse {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -31,6 +32,8 @@ export function WorldBuilder({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
const [world, setWorld] = usePersistentState<WorldResponse | null>("world.last", null);
|
const [world, setWorld] = usePersistentState<WorldResponse | null>("world.last", null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
const [mapPrompt, setMapPrompt] = useState<string | null>(null);
|
||||||
|
const [mapNonce, setMapNonce] = useState(0);
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
// ponytail: record a dragged pin's position into the world so it persists.
|
// ponytail: record a dragged pin's position into the world so it persists.
|
||||||
@@ -98,6 +101,10 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
`World: ${parsed.name || theme}`,
|
`World: ${parsed.name || theme}`,
|
||||||
`${parsed.name || "World"} (${theme}). ${parsed.description}\nRegions: ${regions}\nLandmarks: ${landmarks}\nConflicts: ${conflicts}\nCultures: ${cultures}`,
|
`${parsed.name || "World"} (${theme}). ${parsed.description}\nRegions: ${regions}\nLandmarks: ${landmarks}\nConflicts: ${conflicts}\nCultures: ${cultures}`,
|
||||||
);
|
);
|
||||||
|
// Generate map image prompt
|
||||||
|
const mapPrompt = `fantasy map of ${parsed.name}, ${parsed.description}, parchment texture, ink cartography, top-down`;
|
||||||
|
setMapPrompt(mapPrompt);
|
||||||
|
setMapNonce((n) => n + 1);
|
||||||
} else {
|
} else {
|
||||||
addToast("LLM returned an unparseable response", "error");
|
addToast("LLM returned an unparseable response", "error");
|
||||||
}
|
}
|
||||||
@@ -157,6 +164,11 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
{world.description}
|
{world.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{mapPrompt && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<GeneratedImage prompt={mapPrompt} nonce={mapNonce} className="w-full" aspect="aspect-[3/2]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Map + side details. Two-pane on wide screens; stacked on narrow. */}
|
{/* Map + side details. Two-pane on wide screens; stacked on narrow. */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// ponytail: reuses the in-source demo() self-check so there's one set of
|
||||||
|
// assertions, not two. Run: node scripts/check-encounter-budget.ts (or any
|
||||||
|
// TS runner). No test framework, no @types/node dependency.
|
||||||
|
import { demo } from "./encounter-budget";
|
||||||
|
|
||||||
|
demo();
|
||||||
Reference in New Issue
Block a user