Improve LLM client immutability and CI model defaults. #9

Merged
keligrubb merged 1 commits from feature/openwebui-ci-ollama-client into main 2026-04-15 02:45:25 +00:00
7 changed files with 93 additions and 77 deletions
+1
View File
@@ -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
+11 -9
View File
@@ -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.
---
+5 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+22 -22
View File
@@ -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 {
+9 -7
View File
@@ -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 () => {