Improve LLM client immutability and CI model defaults. (#9)
Replace mutable Ollama model export with a const fallback and initializeModel return value, resolving the model from the environment after optional API discovery. Use a for-of loop over attempt indices instead of let in the retry path. Continue PDF generation when map image generation or upscaling fails, and avoid mutating request headers in place. Document Open WebUI-style URLs in the README, pin OLLAMA_MODEL in the Gitea release workflow, and adjust integration and unit tests for the new initialization behavior. Reviewed-on: #9 Co-authored-by: keligrubb <keligrubb324@gmail.com> Co-committed-by: keligrubb <keligrubb324@gmail.com>
This commit was merged in pull request #9.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[](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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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`;
|
||||
|
||||
+17
-17
@@ -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;
|
||||
}
|
||||
|
||||
+28
-14
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user