diff --git a/src/App.tsx b/src/App.tsx index 2e96b07..5cc6a6e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,6 +31,7 @@ import { QuestDesigner } from "./components/QuestDesigner"; import { Soundboard } from "./components/Soundboard"; import { LorePanel } from "./components/LorePanel"; import { ImageGenerator } from "./components/ImageGenerator"; +import { FirstRunWizard } from "./components/FirstRunWizard"; import { ToastContainer } from "./components/Toast"; import { CommandPalette } from "./components/CommandPalette"; 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 // doesn't depend on a shared store. const [prefill, setPrefill] = useState(null); + const [showWizard, setShowWizard] = useState(false); function rehydrate(kind: GenerationKind, data: Generation) { const target = KIND_TO_VIEW[kind]; @@ -180,6 +182,11 @@ export default function App() { 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. useEffect(() => { function onKey(e: KeyboardEvent) { @@ -237,6 +244,20 @@ export default function App() { return () => window.removeEventListener("keydown", onKey); }, [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 groupedNav = useMemo( () => ({ @@ -376,28 +397,32 @@ export default function App() { {/* Main Content */}
- - {isOnDashboard ? ( - - ) : view === "history" ? ( - rehydrate(k, d)} /> - ) : ( -
- {view === "settings" ? ( -
-

- Settings -

- -
- ) : ( -
- {renderView(view, prefill, () => setPrefill(null), rehydrate)} -
- )} -
- )} -
+ {showWizard ? ( + + ) : ( + + {isOnDashboard ? ( + + ) : view === "history" ? ( + rehydrate(k, d)} /> + ) : ( +
+ {view === "settings" ? ( +
+

+ Settings +

+ +
+ ) : ( +
+ {renderView(view, prefill, () => setPrefill(null), rehydrate)} +
+ )} +
+ )} +
+ )}
diff --git a/src/components/FirstRunWizard.tsx b/src/components/FirstRunWizard.tsx new file mode 100644 index 0000000..3d0ac63 --- /dev/null +++ b/src/components/FirstRunWizard.tsx @@ -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([]); + const [selectedModel, setSelectedModel] = useState(""); + // 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 ( +
+

+ Welcome to DM-Pal +

+

+ To generate content, DM-Pal needs a language model. Let's set up a local model using Ollama (recommended for privacy and offline use). +

+
+ + +
+ {error && ( +

{error}

+ )} +
+ ); + } + + if (step === "check") { + return ( +
+

+ Checking for Ollama +

+

+ We're checking if Ollama is running at {apiUrl}... +

+ {loading ? ( +
+
+

Checking…

+
+ ) : ( + <> + {error ? ( +

{error}

+ ) : ( +

Ollama is running!

+ )} +
+ + +
+ + )} +
+ ); + } + + if (step === "model") { + return ( +
+

+ Select a Model +

+

+ Ollama is running. Choose a model to use for text generation. +

+
+
+ + +
+
+ + + +
+
+ {error && ( +

{error}

+ )} +

+ If you don't see your model, you may need to download it first. In a terminal, run:{' '} + ollama pull llama3.2 +

+
+ ); + } + + if (step === "done") { + return ( +
+

+ Setup Complete +

+

+ 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. +

+
+ + +
+
+ ); + } + + if (step === "error") { + return ( +
+

+ Error +

+

+ {error} +

+
+ + +
+
+ ); + } + + return null; +} \ No newline at end of file diff --git a/src/components/WorldBuilder.tsx b/src/components/WorldBuilder.tsx index 347e1df..4b5f4c9 100644 --- a/src/components/WorldBuilder.tsx +++ b/src/components/WorldBuilder.tsx @@ -7,6 +7,7 @@ import { addGeneration, extractTitle, type Generation } from "../lib/generations import { usePrefillEffect } from "../lib/usePrefill"; import { usePersistentState } from "../lib/usePersistentState"; import { WorldMap } from "./WorldMap"; +import { GeneratedImage } from "./GeneratedImage"; interface WorldResponse { name: string; @@ -31,6 +32,8 @@ export function WorldBuilder({ prefill, onPrefillConsumed }: Props = {}) { const [world, setWorld] = usePersistentState("world.last", null); const [loading, setLoading] = useState(false); const [selected, setSelected] = useState(null); + const [mapPrompt, setMapPrompt] = useState(null); + const [mapNonce, setMapNonce] = useState(0); const { addToast } = useToast(); // 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}`, `${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 { 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}

+ {mapPrompt && ( +
+ +
+ )} {/* Map + side details. Two-pane on wide screens; stacked on narrow. */}
diff --git a/src/lib/encounter-budget.test.ts b/src/lib/encounter-budget.test.ts new file mode 100644 index 0000000..39b2500 --- /dev/null +++ b/src/lib/encounter-budget.test.ts @@ -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();