diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 78095bf..9b1ffaa 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -15,6 +15,7 @@ jobs: env: OLLAMA_API_URL: https://ai.keligrubb.com/api/chat/completions OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }} + OLLAMA_MODEL: qwen3.5-122b-a10b COMFYUI_URL: http://192.168.1.124:8188 steps: - name: Checkout diff --git a/README.md b/README.md index eba9503..a686e5d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![status-badge](https://ci.keligrubb.com/api/badges/2/status.svg)](https://ci.keligrubb.com/repos/2) -Scrollsmith is a Node.js tool for generating Dungeons & Dragons one-page dungeon PDFs automatically. It uses an Ollama LLM server to create dungeon content, proofreads and refines it, then formats it into a structured PDF with maps, rooms, encounters, treasure, and NPCs. +Scrollsmith is a Node.js tool for generating Dungeons & Dragons one-page dungeon PDFs automatically. It calls an LLM (Open WebUI `/api/chat/completions` or Ollama `/api/generate`, inferred from `OLLAMA_API_URL`), proofreads and refines the result, then builds a structured PDF with maps, rooms, encounters, treasure, and NPCs. --- @@ -14,20 +14,21 @@ Scrollsmith is a Node.js tool for generating Dungeons & Dragons one-page dungeon 3. JSON conversion: output strictly valid JSON for PDF generation - Automatically generates a PDF named after the dungeon title - PDF layout includes three columns: map & hooks, rooms, encounters & treasure & NPCs -- Easy to integrate with a local Ollama server +- Open WebUI or Ollama --- ## Requirements - Node.js 22+ -- Ollama server running and accessible +- LLM endpoint (see below) - Gitea Releases (optional) for PDF uploads - `.env` file with: ```env -OLLAMA_API_URL=http://localhost:3000/api/chat/completions +OLLAMA_API_URL=https://your-openwebui-host/api/chat/completions OLLAMA_API_KEY=your_api_key_here +OLLAMA_MODEL=qwen3.5-122b-a10b COMFYUI_URL=http://192.168.1.124:8188 ``` @@ -56,17 +57,18 @@ OLLAMA_API_URL=http://localhost:11434/api/generate ### Open WebUI API For Open WebUI API calls, set: ```env -OLLAMA_API_URL=http://localhost:3000/api/chat/completions +OLLAMA_API_URL=https://your-openwebui-host/api/chat/completions OLLAMA_API_KEY=your_open_webui_api_key +OLLAMA_MODEL=qwen3.5-122b-a10b ``` -> Note: The API type is automatically inferred from the endpoint URL. If the URL contains `/api/chat/completions`, it uses Open WebUI API. If it contains `/api/generate`, it uses direct Ollama API. No `OLLAMA_API_TYPE` environment variable is required. +> Note: The API type is inferred from the URL (`/api/chat/completions` vs `/api/generate`); no `OLLAMA_API_TYPE` variable. --- ## Usage -1. Make sure your Ollama server is running and `.env` is configured. +1. Configure `.env` and ensure the LLM endpoint is reachable. 2. Run: ```bash @@ -81,7 +83,7 @@ Optional: update the map path in `index.js` if you have a local dungeon map. ## Project structure -- **`index.js`** – Entry point: loads env, initializes the Ollama model, runs dungeon generation, image generation, and PDF output. +- **`index.js`** – Entry point: env, model init, dungeon + image + PDF. - **`src/`** – Application modules: - `dungeonGenerator.js` – LLM-backed dungeon content generation and validation. - `dungeonTemplate.js` – HTML template and layout for the PDF. @@ -105,7 +107,7 @@ Optional: update the map path in `index.js` if you have a local dungeon map. ## Notes -* Make sure your Ollama server is accessible and API key is valid. +* Open WebUI needs a valid `OLLAMA_API_KEY` when the server requires it. * Dungeon generation can take a few minutes depending on your LLM response time. --- diff --git a/index.js b/index.js index 7843de4..8fd8aa3 100644 --- a/index.js +++ b/index.js @@ -2,7 +2,7 @@ import "dotenv/config"; import { generateDungeon } from "./src/dungeonGenerator.js"; import { generateDungeonImages } from "./src/imageGenerator.js"; import { generatePDF } from "./src/generatePDF.js"; -import { OLLAMA_MODEL, initializeModel } from "./src/ollamaClient.js"; +import { initializeModel } from "./src/ollamaClient.js"; // Utility to create a filesystem-safe filename from the dungeon title function slugify(text) { @@ -18,18 +18,15 @@ function slugify(text) { throw new Error("OLLAMA_API_URL environment variable is required"); } console.log("Using Ollama API URL:", process.env.OLLAMA_API_URL); - - // Initialize model (will fetch default from API or use fallback) - await initializeModel(); - console.log("Using Ollama model:", OLLAMA_MODEL); + + const model = await initializeModel(); + console.log("Using Ollama model:", model); // Generate the dungeon data const dungeonData = await generateDungeon(); - // Generate dungeon map image (uses dungeonData.flavor) const mapPath = await generateDungeonImages(dungeonData); - - dungeonData.map = mapPath; + if (mapPath) dungeonData.map = mapPath; // Generate PDF filename based on the title const filename = `${slugify(dungeonData.title)}.pdf`; diff --git a/src/imageGenerator.js b/src/imageGenerator.js index acba029..aedfc14 100644 --- a/src/imageGenerator.js +++ b/src/imageGenerator.js @@ -2,7 +2,7 @@ import sharp from 'sharp'; import path from "path"; import { mkdir, writeFile } from "fs/promises"; import { fileURLToPath } from "url"; -import { callOllama, OLLAMA_MODEL } from "./ollamaClient.js"; +import { callOllama } from "./ollamaClient.js"; const COMFYUI_ENABLED = process.env.COMFYUI_ENABLED !== 'false'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -63,7 +63,7 @@ Input: ${flavor} Output:`, - OLLAMA_MODEL, + undefined, 3, "Generate Visual Prompt" ); @@ -239,22 +239,22 @@ export async function generateDungeonImages({ flavor }) { return path.join(__dirname, "dungeon_upscaled.png"); } - const finalPrompt = await generateVisualPrompt(flavor); - console.log("Engineered visual prompt:\n", finalPrompt); + try { + const finalPrompt = await generateVisualPrompt(flavor); + console.log("Engineered visual prompt:\n", finalPrompt); - const baseFilename = `dungeon.png`; - const upscaledFilename = `dungeon_upscaled.png`; + const baseFilename = `dungeon.png`; + const upscaledFilename = `dungeon_upscaled.png`; - const filepath = await generateImageViaComfyUI(finalPrompt, baseFilename); - if (!filepath) { - throw new Error("Failed to generate dungeon image."); + const filepath = await generateImageViaComfyUI(finalPrompt, baseFilename); + if (!filepath) return null; + + const upscaledPath = await upscaleImage(filepath, upscaledFilename, 1456, 1024); + if (!upscaledPath) return null; + + return upscaledPath; + } catch (err) { + console.warn("Map image failed:", err.message); + return null; } - - // Upscale 2x (half of A4 at 300dpi) - const upscaledPath = await upscaleImage(filepath, upscaledFilename, 1456, 1024); - if (!upscaledPath) { - throw new Error("Failed to upscale dungeon image."); - } - - return upscaledPath; } diff --git a/src/ollamaClient.js b/src/ollamaClient.js index 5a9e282..0543d74 100644 --- a/src/ollamaClient.js +++ b/src/ollamaClient.js @@ -1,10 +1,16 @@ import { cleanText } from "./textUtils.js"; const OLLAMA_API_URL = process.env.OLLAMA_API_URL; -export let OLLAMA_MODEL = process.env.OLLAMA_MODEL || "gemma3:latest"; + +export const OLLAMA_MODEL = "qwen3.5-122b-a10b"; + +function effectiveModel(explicit) { + if (explicit !== undefined && explicit !== null) return explicit; + return process.env.OLLAMA_MODEL || OLLAMA_MODEL; +} export async function initializeModel() { - if (process.env.OLLAMA_MODEL) return; + if (process.env.OLLAMA_MODEL) return process.env.OLLAMA_MODEL; try { const apiUrl = process.env.OLLAMA_API_URL; const isOpenWebUI = apiUrl?.includes("/api/chat/completions"); @@ -16,17 +22,20 @@ export async function initializeModel() { const res = await fetch(url, { headers }); if (res.ok) { const data = await res.json(); - const model = isOpenWebUI + const model = isOpenWebUI ? data.data?.[0]?.id || data.data?.[0]?.name : data.models?.[0]?.name; if (model) { - OLLAMA_MODEL = model; + process.env.OLLAMA_MODEL = model; console.log(`Using default model: ${model}`); + return model; } } } catch { - console.warn(`Could not fetch default model, using: ${OLLAMA_MODEL}`); + // fall through to warn below } + console.warn(`Could not fetch default model, using: ${OLLAMA_MODEL}`); + return OLLAMA_MODEL; } export { cleanText }; @@ -45,8 +54,10 @@ async function sleep(ms) { async function callOllamaBase(prompt, model, retries, stepName, apiType) { const isUsingOpenWebUI = apiType === "open-webui"; const isUsingOllamaChat = apiType === "ollama-chat"; + const resolvedModel = effectiveModel(model); + const attempts = Array.from({ length: retries }, (_, index) => index + 1); - for (let attempt = 1; attempt <= retries; attempt++) { + for (const attempt of attempts) { try { const promptCharCount = prompt.length; const promptWordCount = prompt.split(/\s+/).length; @@ -58,14 +69,17 @@ async function callOllamaBase(prompt, model, retries, stepName, apiType) { `Prompt: ${promptCharCount} chars, ~${promptWordCount} words`, ); - const headers = { "Content-Type": "application/json" }; - if (isUsingOpenWebUI && process.env.OLLAMA_API_KEY) { - headers["Authorization"] = `Bearer ${process.env.OLLAMA_API_KEY}`; - } + const headers = + isUsingOpenWebUI && process.env.OLLAMA_API_KEY + ? { + "Content-Type": "application/json", + "Authorization": `Bearer ${process.env.OLLAMA_API_KEY}`, + } + : { "Content-Type": "application/json" }; const body = isUsingOpenWebUI || isUsingOllamaChat - ? { model, messages: [{ role: "user", content: prompt }] } - : { model, prompt, stream: false }; + ? { model: resolvedModel, messages: [{ role: "user", content: prompt }] } + : { model: resolvedModel, prompt, stream: false }; const response = await fetch(OLLAMA_API_URL, { method: "POST", @@ -108,7 +122,7 @@ async function callOllamaBase(prompt, model, retries, stepName, apiType) { export async function callOllama( prompt, - model = OLLAMA_MODEL, + model, retries = 5, stepName = "unknown", ) { @@ -118,7 +132,7 @@ export async function callOllama( export async function callOllamaExplicit( prompt, - model = OLLAMA_MODEL, + model, retries = 5, stepName = "unknown", apiType = "ollama-generate", diff --git a/test/integration/dungeonGeneration.test.js b/test/integration/dungeonGeneration.test.js index 9615921..2c8b4dd 100644 --- a/test/integration/dungeonGeneration.test.js +++ b/test/integration/dungeonGeneration.test.js @@ -6,46 +6,46 @@ import path from "path"; const hasOllama = !!process.env.OLLAMA_API_URL; -describe.skipIf(!hasOllama)("Dungeon generation (Ollama)", () => { - let dungeonData; +describe.skipIf(!hasOllama)("Dungeon generation (Ollama)", { timeout: 120000 }, () => { + const fixture = {}; beforeAll(async () => { - dungeonData = await generateDungeon(); - }, 120000); + fixture.dungeon = await generateDungeon(); + }); it("generates dungeon data", () => { - expect(dungeonData).toBeDefined(); + expect(fixture.dungeon).toBeDefined(); }); it("title is 2-4 words, no colons", () => { - expect(dungeonData.title).toBeTruthy(); - const words = dungeonData.title.split(/\s+/); + expect(fixture.dungeon.title).toBeTruthy(); + const words = fixture.dungeon.title.split(/\s+/); expect(words.length).toBeGreaterThanOrEqual(2); expect(words.length).toBeLessThanOrEqual(4); - expect(dungeonData.title).not.toContain(":"); + expect(fixture.dungeon.title).not.toContain(":"); }); it("flavor text is ≤60 words", () => { - expect(dungeonData.flavor).toBeTruthy(); - const words = dungeonData.flavor.split(/\s+/); + expect(fixture.dungeon.flavor).toBeTruthy(); + const words = fixture.dungeon.flavor.split(/\s+/); expect(words.length).toBeLessThanOrEqual(60); }); it("hooks have no title prefixes", () => { - expect(dungeonData.hooksRumors).toBeDefined(); - dungeonData.hooksRumors.forEach((hook) => { + expect(fixture.dungeon.hooksRumors).toBeDefined(); + fixture.dungeon.hooksRumors.forEach((hook) => { expect(hook).not.toMatch(/^[^:]+:\s/); }); }); it("has exactly 6 random events", () => { - expect(dungeonData.randomEvents).toBeDefined(); - expect(dungeonData.randomEvents.length).toBe(6); + expect(fixture.dungeon.randomEvents).toBeDefined(); + expect(fixture.dungeon.randomEvents.length).toBe(6); }); it("encounter details do not start with encounter name", () => { - expect(dungeonData.encounters).toBeDefined(); - dungeonData.encounters.forEach((encounter) => { + expect(fixture.dungeon.encounters).toBeDefined(); + fixture.dungeon.encounters.forEach((encounter) => { if (encounter.details) { const detailsLower = encounter.details.toLowerCase(); const nameLower = encounter.name.toLowerCase(); @@ -55,8 +55,8 @@ describe.skipIf(!hasOllama)("Dungeon generation (Ollama)", () => { }); it("treasure descriptions do not start with 'description'", () => { - expect(dungeonData.treasure).toBeDefined(); - dungeonData.treasure.forEach((item) => { + expect(fixture.dungeon.treasure).toBeDefined(); + fixture.dungeon.treasure.forEach((item) => { if (typeof item === "object" && item.description) { expect(item.description.toLowerCase().startsWith("description")).toBe(false); } @@ -64,8 +64,8 @@ describe.skipIf(!hasOllama)("Dungeon generation (Ollama)", () => { }); it("NPC traits do not start with 'description'", () => { - expect(dungeonData.npcs).toBeDefined(); - dungeonData.npcs.forEach((npc) => { + expect(fixture.dungeon.npcs).toBeDefined(); + fixture.dungeon.npcs.forEach((npc) => { if (npc.trait) { expect(npc.trait.toLowerCase().startsWith("description")).toBe(false); } @@ -75,11 +75,11 @@ describe.skipIf(!hasOllama)("Dungeon generation (Ollama)", () => { it("PDF fits on one page", async () => { const testPdfPath = path.join(process.cwd(), "test-output.pdf"); try { - await generatePDF(dungeonData, testPdfPath); + await generatePDF(fixture.dungeon, testPdfPath); const pdfBuffer = await fs.readFile(testPdfPath); const pdfText = pdfBuffer.toString("binary"); const pageCount = (pdfText.match(/\/Type\s*\/Page[^s]/g) || []).length; - const expectedPages = dungeonData.map ? 2 : 1; + const expectedPages = fixture.dungeon.map ? 2 : 1; expect(pageCount).toBeLessThanOrEqual(expectedPages); } finally { try { diff --git a/test/unit/ollamaClient.test.js b/test/unit/ollamaClient.test.js index eca3408..106e123 100644 --- a/test/unit/ollamaClient.test.js +++ b/test/unit/ollamaClient.test.js @@ -203,16 +203,16 @@ describe("initializeModel (mocked fetch)", () => { expect(globalThis.fetch).not.toHaveBeenCalled(); }); - it("leaves OLLAMA_MODEL unchanged when fetch returns not ok", async () => { + it("does not set env when fetch returns not ok", async () => { process.env.OLLAMA_MODEL = ""; - const before = OLLAMA_MODEL; vi.mocked(globalThis.fetch).mockResolvedValueOnce({ ok: false, status: 404, json: () => Promise.resolve({}), }); - await initializeModel(); - expect(OLLAMA_MODEL).toBe(before); + const resolved = await initializeModel(); + expect(resolved).toBe(OLLAMA_MODEL); + expect(process.env.OLLAMA_MODEL).toBe(""); }); it("fetches /api/tags when OLLAMA_MODEL not set", async () => { @@ -222,11 +222,13 @@ describe("initializeModel (mocked fetch)", () => { ok: true, json: () => Promise.resolve({ models: [{ name: "test-model" }] }), }); - await initializeModel(); + const resolved = await initializeModel(); expect(globalThis.fetch).toHaveBeenCalled(); const [url, opts] = vi.mocked(globalThis.fetch).mock.calls[0]; expect(String(url)).toMatch(/\/api\/tags$/); expect(opts?.method || "GET").toBe("GET"); + expect(resolved).toBe("test-model"); + expect(process.env.OLLAMA_MODEL).toBe("test-model"); }); it("fetches /api/v1/models when URL has open-webui path and sets model from data.data id", async () => { @@ -239,7 +241,7 @@ describe("initializeModel (mocked fetch)", () => { await initializeModel(); const [url] = vi.mocked(globalThis.fetch).mock.calls[0]; expect(String(url)).toMatch(/\/api\/v1\/models$/); - expect(OLLAMA_MODEL).toBe("webui-model"); + expect(process.env.OLLAMA_MODEL).toBe("webui-model"); }); it("sets model from data.data[0].name when id missing", async () => { @@ -250,7 +252,7 @@ describe("initializeModel (mocked fetch)", () => { json: () => Promise.resolve({ data: [{ name: "webui-model-name" }] }), }); await initializeModel(); - expect(OLLAMA_MODEL).toBe("webui-model-name"); + expect(process.env.OLLAMA_MODEL).toBe("webui-model-name"); }); it("catches fetch failure and warns", async () => {