import { useEffect, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; import { useToast } from "./Toast"; interface LlmConfig { api_url: string; api_key: string; model: string; temperature: number; max_tokens: number; top_p: number; image_api_url: string; embed_model: string; } interface DataDirInfo { current: string; configured: string | null; default: string; } export function SettingsPanel() { const [config, setConfig] = useState(null); const [loading, setLoading] = useState(false); const [saved, setSaved] = useState(false); const [showKey, setShowKey] = useState(false); const [error, setError] = useState(""); const [testing, setTesting] = useState(false); const [conn, setConn] = useState<{ ok: boolean; models: string[]; error: string } | null>(null); const [dataDir, setDataDir] = useState(null); const [relocating, setRelocating] = useState(false); // ponytail: probe the sd-server so a non-technical DM gets a green check, same pattern as the LLM test. const [imgTesting, setImgTesting] = useState(false); const [imgConn, setImgConn] = useState<{ ok: boolean; error: string } | null>(null); // ponytail: advanced SQLite viewer — read-only, two DBs (lore.db / generations.db). const [sqlDb, setSqlDb] = useState<"lore" | "generations">("generations"); const [sqlInput, setSqlInput] = useState("SELECT name, type FROM sqlite_master WHERE type='table' ORDER BY name"); const [sqlResult, setSqlResult] = useState<{ columns: string[]; rows: (string | number | null)[][] } | null>(null); const [sqlError, setSqlError] = useState(""); const [sqlLoading, setSqlLoading] = useState(false); const { addToast } = useToast(); // ponytail: auto-load on mount so users don't see a gate before the form. useEffect(() => { (async () => { try { const c = await invoke("get_llm_config"); setConfig(c); } catch (e) { setError(String(e)); } try { setDataDir(await invoke("get_data_dir")); } catch { // non-fatal — data dir control just stays hidden } })(); }, []); // ponytail: pick a folder, copy existing campaign data into it, persist the // preference. The open SQLite connections stay on the old paths until the // DM restarts, so we tell them to relaunch. async function chooseDataDir() { try { const picked = await open({ directory: true, multiple: false, title: "Choose where DM-Pal saves campaign data" }); if (!picked || typeof picked !== "string") return; setRelocating(true); const info = await invoke("set_data_dir", { newDir: picked }); setDataDir(info); addToast(`Campaign data will save to ${picked} — restart DM-Pal to apply. Existing data was copied there.`, "success"); } catch (e) { addToast(`Couldn't set data location: ${e}`, "error"); } setRelocating(false); } async function resetDataDir() { try { await invoke("reset_data_dir"); setDataDir(await invoke("get_data_dir")); addToast("Reverted to default location — restart DM-Pal to apply", "info"); } catch (e) { addToast(`Couldn't reset: ${e}`, "error"); } } // ponytail: run a read-only query against the selected DB. The backend gates // on SELECT/PRAGMA/WITH, so this is safe to expose in Settings. async function runSql() { setSqlLoading(true); setSqlError(""); setSqlResult(null); try { const res = await invoke<{ columns: string[]; rows: (string | number | null)[][] }>("sql_query", { req: { db: sqlDb, sql: sqlInput } }); setSqlResult(res); if (res.rows.length === 0) setSqlError("(0 rows)"); } catch (e) { setSqlError(String(e)); } setSqlLoading(false); } function quickQuery(q: string) { setSqlInput(q); // run after the state flushes setTimeout(runSql, 0); } function switchDb(db: "lore" | "generations") { setSqlDb(db); setSqlResult(null); setSqlError(""); const q = "SELECT name, type FROM sqlite_master WHERE type='table' ORDER BY name"; setSqlInput(q); // ponytail: runSql reads sqlDb from closure (stale here), so inline the // invoke with the new db to avoid a second render round-trip. setSqlLoading(true); invoke<{ columns: string[]; rows: (string | number | null)[][] }>("sql_query", { req: { db, sql: q } }) .then((res) => { setSqlResult(res); if (res.rows.length === 0) setSqlError("(0 rows)"); }) .catch((e) => setSqlError(String(e))) .finally(() => setSqlLoading(false)); } async function saveConfig() { if (!config) return; setLoading(true); try { await invoke("set_llm_config", { config }); setSaved(true); setTimeout(() => setSaved(false), 2000); addToast("Settings saved", "success"); } catch (e) { addToast(`Save failed: ${e}`, "error"); } setLoading(false); } // ponytail: probe the configured endpoint for a model list. Ollama uses // /api/tags, OpenAI-compatible uses /v1/models. The Rust side branches. async function testConnection() { if (!config) return; setTesting(true); setConn(null); try { // Save first so the backend uses the latest URL/key. await invoke("set_llm_config", { config }); const result = await invoke<{ ok: boolean; models: string[]; error: string }>("test_connection"); setConn(result); if (result.ok) addToast(`Connected — ${result.models.length} models`, "success"); else addToast(`Connection failed: ${result.error}`, "error"); } catch (e) { setConn({ ok: false, models: [], error: String(e) }); addToast(`Connection failed: ${e}`, "error"); } setTesting(false); } // ponytail: probe the configured sd-server. Save first so it uses the latest URL. async function testImage() { if (!config) return; setImgTesting(true); setImgConn(null); try { await invoke("set_llm_config", { config }); const r = await invoke<{ ok: boolean; error: string }>("test_image_connection"); setImgConn(r); if (r.ok) addToast("sd-server reachable", "success"); else addToast(`Image server not reachable: ${r.error}`, "error"); } catch (e) { setImgConn({ ok: false, error: String(e) }); addToast(`Image server not reachable: ${e}`, "error"); } setImgTesting(false); } // ponytail: provider presets fill in the API URL pattern + default model. const PRESETS: { label: string; url: string; model: string; key?: boolean }[] = [ { label: "Ollama", url: "http://localhost:11434", model: "llama3.2" }, { label: "LM Studio", url: "http://localhost:1234/v1", model: "local-model" }, { label: "OpenAI", url: "https://api.openai.com", model: "gpt-4o-mini", key: true }, { label: "Custom", url: "", model: "" }, ]; function applyPreset(p: { url: string; model: string }) { if (!config) return; setConfig({ ...config, api_url: p.url, model: p.model }); setConn(null); } // ponytail: reset to the backend's default config. The Rust Default impl // is the single source of truth; we just set an empty config and let the // backend re-default on save — but we can't send a partial, so mirror the // known defaults here and toast. const DEFAULTS: LlmConfig = { api_url: "http://localhost:11434", api_key: "", model: "llama3.2", temperature: 0.7, max_tokens: 512, top_p: 0.9, image_api_url: "http://localhost:1234", embed_model: "nomic-embed-text", }; function resetDefaults() { setConfig({ ...DEFAULTS }); setConn(null); addToast("Reset to defaults — click Save to apply", "info"); } // ponytail: export/import the whole config as JSON. Export downloads a file; // import reads a file and merges into the form (Save still required to persist). function exportConfig() { if (!config) return; const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "dm-pal-config.json"; a.click(); URL.revokeObjectURL(url); addToast("Config exported", "success"); } function importConfig(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const parsed = JSON.parse(String(reader.result)) as Partial; setConfig((c) => ({ ...c, ...parsed } as LlmConfig)); setConn(null); addToast("Config imported — click Save to apply", "info"); } catch { addToast("Invalid config file", "error"); } }; reader.readAsText(file); e.target.value = ""; } if (!config) { return (

Loading settings…

{error && (

Failed to load: {error}

)}
); } return (
{/* Storage - where campaign data (lore db, generations db, generated images) lives. */} {dataDir ? ( <>

Storage

Where the lore database, generations database, and generated images are saved.

{dataDir.current}
{dataDir.configured ? ( dataDir.configured !== dataDir.current ? (
⏳ Pending move to {dataDir.configured} - restart DM-Pal to apply.
) : null ) : null}
) : null}

LLM Connection

{/* Provider presets */}
{PRESETS.map((p) => ( ))}
setConfig({ ...config, api_url: e.target.value })} placeholder="http://localhost:11434" />
setConfig({ ...config, api_key: e.target.value })} placeholder="sk-... (leave blank for local)" />
setConfig({ ...config, model: e.target.value })} placeholder="llama3.2" list="model-list" /> {conn?.models.map((m) => (
{/* Connection test */}
{conn && ( {conn.ok ? `✓ ${conn.models.length} models` : `✕ ${conn.error}`} )}
{conn?.ok && conn.models.length > 0 && (
{conn.models.map((m) => ( ))}
)}

Image Server

Base URL of a stable-diffusion.cpp `sd-server` (A1111-compatible API). Start it with your chosen model, e.g.
sd-server -m model.safetensors

setConfig({ ...config, image_api_url: e.target.value })} placeholder="http://localhost:1234" /> {/* ponytail: mirror the LLM test button so a non-technical DM gets a green check */}
{imgConn && ( {imgConn.ok ? "✓ sd-server reachable" : `✕ ${imgConn.error}`} )}

New to this? Open the Image tab → "First time? Set up image generation" for a full step-by-step.

Embedding Model (Lore RAG)

setConfig({ ...config, embed_model: e.target.value })} placeholder="nomic-embed-text" />

Text Model

setConfig({ ...config, temperature: parseFloat(e.target.value) }) } className="accent-[var(--color-gold-bright)]" />
setConfig({ ...config, max_tokens: parseInt(e.target.value) }) } className="accent-[var(--color-gold-bright)]" />
{/* Advanced — read-only SQLite viewer for inspecting the backend DBs. */}
{ if ((e.target as HTMLDetailsElement).open && !sqlResult && !sqlLoading) runSql(); }} > Advanced · SQLite viewer

Read-only inspection of the local databases (lore.db, generations.db). Only SELECT / PRAGMA / WITH statements run. Embedding vectors show as <blob N bytes>.