Compare commits

..
1 Commits
Author SHA1 Message Date
Madison Grubb ff3af2621c update dependencies and improve generator 2026-07-10 19:28:53 -04:00
9 changed files with 486 additions and 555 deletions
+4 -4
View File
@@ -9,10 +9,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: https://git.keligrubb.com/actions/checkout@v7 uses: https://github.com/actions/checkout@v6
- name: Setup Node - name: Setup Node
uses: https://git.keligrubb.com/actions/setup-node@v6 uses: https://github.com/actions/setup-node@v6
with: with:
node-version: "24" node-version: "24"
cache: "npm" cache: "npm"
@@ -28,10 +28,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: https://git.keligrubb.com/actions/checkout@v7 uses: https://github.com/actions/checkout@v6
- name: Setup Node - name: Setup Node
uses: https://git.keligrubb.com/actions/setup-node@v6 uses: https://github.com/actions/setup-node@v6
with: with:
node-version: "24" node-version: "24"
cache: "npm" cache: "npm"
+4 -6
View File
@@ -15,14 +15,13 @@ jobs:
env: env:
OLLAMA_API_URL: https://ai.keligrubb.com/api/chat/completions OLLAMA_API_URL: https://ai.keligrubb.com/api/chat/completions
OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }} OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }}
OLLAMA_MODEL: qwen3.5-122b-a10b
COMFYUI_URL: http://192.168.1.124:8188 COMFYUI_URL: http://192.168.1.124:8188
steps: steps:
- name: Checkout - name: Checkout
uses: https://git.keligrubb.com/actions/checkout@v7 uses: https://github.com/actions/checkout@v6
- name: Setup Node - name: Setup Node
uses: https://git.keligrubb.com/actions/setup-node@v6 uses: https://github.com/actions/setup-node@v6
with: with:
node-version: "24" node-version: "24"
cache: "npm" cache: "npm"
@@ -34,7 +33,7 @@ jobs:
run: npm start run: npm start
- name: Upload PDF artifact - name: Upload PDF artifact
uses: https://git.keligrubb.com/actions/upload-artifact@v3 uses: https://github.com/actions/upload-artifact@v7
with: with:
name: release-pdf name: release-pdf
path: "*.pdf" path: "*.pdf"
@@ -47,10 +46,9 @@ jobs:
GITEA_TOKEN: ${{ secrets.SCROLLSMITH_GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.SCROLLSMITH_GITEA_TOKEN }}
steps: steps:
- name: Download PDF artifact - name: Download PDF artifact
uses: https://git.keligrubb.com/actions/download-artifact@v3 uses: https://github.com/actions/download-artifact@v8
with: with:
name: release-pdf name: release-pdf
path: .
- name: Create release and upload PDF - name: Create release and upload PDF
run: | run: |
+9 -11
View File
@@ -2,7 +2,7 @@
[![status-badge](https://ci.keligrubb.com/api/badges/2/status.svg)](https://ci.keligrubb.com/repos/2) [![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 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. 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.
--- ---
@@ -14,21 +14,20 @@ Scrollsmith is a Node.js tool for generating Dungeons & Dragons one-page dungeon
3. JSON conversion: output strictly valid JSON for PDF generation 3. JSON conversion: output strictly valid JSON for PDF generation
- Automatically generates a PDF named after the dungeon title - Automatically generates a PDF named after the dungeon title
- PDF layout includes three columns: map & hooks, rooms, encounters & treasure & NPCs - PDF layout includes three columns: map & hooks, rooms, encounters & treasure & NPCs
- Open WebUI or Ollama - Easy to integrate with a local Ollama server
--- ---
## Requirements ## Requirements
- Node.js 22+ - Node.js 22+
- LLM endpoint (see below) - Ollama server running and accessible
- Gitea Releases (optional) for PDF uploads - Gitea Releases (optional) for PDF uploads
- `.env` file with: - `.env` file with:
```env ```env
OLLAMA_API_URL=https://your-openwebui-host/api/chat/completions OLLAMA_API_URL=http://localhost:3000/api/chat/completions
OLLAMA_API_KEY=your_api_key_here OLLAMA_API_KEY=your_api_key_here
OLLAMA_MODEL=qwen3.5-122b-a10b
COMFYUI_URL=http://192.168.1.124:8188 COMFYUI_URL=http://192.168.1.124:8188
``` ```
@@ -57,18 +56,17 @@ OLLAMA_API_URL=http://localhost:11434/api/generate
### Open WebUI API ### Open WebUI API
For Open WebUI API calls, set: For Open WebUI API calls, set:
```env ```env
OLLAMA_API_URL=https://your-openwebui-host/api/chat/completions OLLAMA_API_URL=http://localhost:3000/api/chat/completions
OLLAMA_API_KEY=your_open_webui_api_key OLLAMA_API_KEY=your_open_webui_api_key
OLLAMA_MODEL=qwen3.5-122b-a10b
``` ```
> Note: The API type is inferred from the URL (`/api/chat/completions` vs `/api/generate`); no `OLLAMA_API_TYPE` variable. > 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.
--- ---
## Usage ## Usage
1. Configure `.env` and ensure the LLM endpoint is reachable. 1. Make sure your Ollama server is running and `.env` is configured.
2. Run: 2. Run:
```bash ```bash
@@ -83,7 +81,7 @@ Optional: update the map path in `index.js` if you have a local dungeon map.
## Project structure ## Project structure
- **`index.js`** Entry point: env, model init, dungeon + image + PDF. - **`index.js`** Entry point: loads env, initializes the Ollama model, runs dungeon generation, image generation, and PDF output.
- **`src/`** Application modules: - **`src/`** Application modules:
- `dungeonGenerator.js` LLM-backed dungeon content generation and validation. - `dungeonGenerator.js` LLM-backed dungeon content generation and validation.
- `dungeonTemplate.js` HTML template and layout for the PDF. - `dungeonTemplate.js` HTML template and layout for the PDF.
@@ -107,7 +105,7 @@ Optional: update the map path in `index.js` if you have a local dungeon map.
## Notes ## Notes
* Open WebUI needs a valid `OLLAMA_API_KEY` when the server requires it. * Make sure your Ollama server is accessible and API key is valid.
* Dungeon generation can take a few minutes depending on your LLM response time. * Dungeon generation can take a few minutes depending on your LLM response time.
--- ---
+8 -5
View File
@@ -2,7 +2,7 @@ import "dotenv/config";
import { generateDungeon } from "./src/dungeonGenerator.js"; import { generateDungeon } from "./src/dungeonGenerator.js";
import { generateDungeonImages } from "./src/imageGenerator.js"; import { generateDungeonImages } from "./src/imageGenerator.js";
import { generatePDF } from "./src/generatePDF.js"; import { generatePDF } from "./src/generatePDF.js";
import { initializeModel } from "./src/ollamaClient.js"; import { OLLAMA_MODEL, initializeModel } from "./src/ollamaClient.js";
// Utility to create a filesystem-safe filename from the dungeon title // Utility to create a filesystem-safe filename from the dungeon title
function slugify(text) { function slugify(text) {
@@ -18,15 +18,18 @@ function slugify(text) {
throw new Error("OLLAMA_API_URL environment variable is required"); throw new Error("OLLAMA_API_URL environment variable is required");
} }
console.log("Using Ollama API URL:", process.env.OLLAMA_API_URL); console.log("Using Ollama API URL:", process.env.OLLAMA_API_URL);
const model = await initializeModel(); // Initialize model (will fetch default from API or use fallback)
console.log("Using Ollama model:", model); await initializeModel();
console.log("Using Ollama model:", OLLAMA_MODEL);
// Generate the dungeon data // Generate the dungeon data
const dungeonData = await generateDungeon(); const dungeonData = await generateDungeon();
// Generate dungeon map image (uses dungeonData.flavor)
const mapPath = await generateDungeonImages(dungeonData); const mapPath = await generateDungeonImages(dungeonData);
if (mapPath) dungeonData.map = mapPath;
dungeonData.map = mapPath;
// Generate PDF filename based on the title // Generate PDF filename based on the title
const filename = `${slugify(dungeonData.title)}.pdf`; const filename = `${slugify(dungeonData.title)}.pdf`;
+401 -453
View File
File diff suppressed because it is too large Load Diff
+17 -17
View File
@@ -2,7 +2,7 @@ import sharp from 'sharp';
import path from "path"; import path from "path";
import { mkdir, writeFile } from "fs/promises"; import { mkdir, writeFile } from "fs/promises";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { callOllama } from "./ollamaClient.js"; import { callOllama, OLLAMA_MODEL } from "./ollamaClient.js";
const COMFYUI_ENABLED = process.env.COMFYUI_ENABLED !== 'false'; const COMFYUI_ENABLED = process.env.COMFYUI_ENABLED !== 'false';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -63,7 +63,7 @@ Input:
${flavor} ${flavor}
Output:`, Output:`,
undefined, OLLAMA_MODEL,
3, 3,
"Generate Visual Prompt" "Generate Visual Prompt"
); );
@@ -239,22 +239,22 @@ export async function generateDungeonImages({ flavor }) {
return path.join(__dirname, "dungeon_upscaled.png"); return path.join(__dirname, "dungeon_upscaled.png");
} }
try { const finalPrompt = await generateVisualPrompt(flavor);
const finalPrompt = await generateVisualPrompt(flavor); console.log("Engineered visual prompt:\n", finalPrompt);
console.log("Engineered visual prompt:\n", finalPrompt);
const baseFilename = `dungeon.png`; const baseFilename = `dungeon.png`;
const upscaledFilename = `dungeon_upscaled.png`; const upscaledFilename = `dungeon_upscaled.png`;
const filepath = await generateImageViaComfyUI(finalPrompt, baseFilename); const filepath = await generateImageViaComfyUI(finalPrompt, baseFilename);
if (!filepath) return null; if (!filepath) {
throw new Error("Failed to generate dungeon image.");
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;
} }
+14 -28
View File
@@ -1,16 +1,10 @@
import { cleanText } from "./textUtils.js"; import { cleanText } from "./textUtils.js";
const OLLAMA_API_URL = process.env.OLLAMA_API_URL; const OLLAMA_API_URL = process.env.OLLAMA_API_URL;
export let OLLAMA_MODEL = process.env.OLLAMA_MODEL || "qwen3.6-35b-a3b";
export const OLLAMA_MODEL = "qwen3.6-35b-a3b";
function effectiveModel(explicit) {
if (explicit !== undefined && explicit !== null) return explicit;
return process.env.OLLAMA_MODEL || OLLAMA_MODEL;
}
export async function initializeModel() { export async function initializeModel() {
if (process.env.OLLAMA_MODEL) return process.env.OLLAMA_MODEL; if (process.env.OLLAMA_MODEL) return;
try { try {
const apiUrl = process.env.OLLAMA_API_URL; const apiUrl = process.env.OLLAMA_API_URL;
const isOpenWebUI = apiUrl?.includes("/api/chat/completions"); const isOpenWebUI = apiUrl?.includes("/api/chat/completions");
@@ -22,20 +16,17 @@ export async function initializeModel() {
const res = await fetch(url, { headers }); const res = await fetch(url, { headers });
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
const model = isOpenWebUI const model = isOpenWebUI
? data.data?.[0]?.id || data.data?.[0]?.name ? data.data?.[0]?.id || data.data?.[0]?.name
: data.models?.[0]?.name; : data.models?.[0]?.name;
if (model) { if (model) {
process.env.OLLAMA_MODEL = model; OLLAMA_MODEL = model;
console.log(`Using default model: ${model}`); console.log(`Using default model: ${model}`);
return model;
} }
} }
} catch { } catch {
// fall through to warn below console.warn(`Could not fetch default model, using: ${OLLAMA_MODEL}`);
} }
console.warn(`Could not fetch default model, using: ${OLLAMA_MODEL}`);
return OLLAMA_MODEL;
} }
export { cleanText }; export { cleanText };
@@ -54,10 +45,8 @@ async function sleep(ms) {
async function callOllamaBase(prompt, model, retries, stepName, apiType) { async function callOllamaBase(prompt, model, retries, stepName, apiType) {
const isUsingOpenWebUI = apiType === "open-webui"; const isUsingOpenWebUI = apiType === "open-webui";
const isUsingOllamaChat = apiType === "ollama-chat"; const isUsingOllamaChat = apiType === "ollama-chat";
const resolvedModel = effectiveModel(model);
const attempts = Array.from({ length: retries }, (_, index) => index + 1);
for (const attempt of attempts) { for (let attempt = 1; attempt <= retries; attempt++) {
try { try {
const promptCharCount = prompt.length; const promptCharCount = prompt.length;
const promptWordCount = prompt.split(/\s+/).length; const promptWordCount = prompt.split(/\s+/).length;
@@ -69,17 +58,14 @@ async function callOllamaBase(prompt, model, retries, stepName, apiType) {
`Prompt: ${promptCharCount} chars, ~${promptWordCount} words`, `Prompt: ${promptCharCount} chars, ~${promptWordCount} words`,
); );
const headers = const headers = { "Content-Type": "application/json" };
isUsingOpenWebUI && process.env.OLLAMA_API_KEY if (isUsingOpenWebUI && process.env.OLLAMA_API_KEY) {
? { headers["Authorization"] = `Bearer ${process.env.OLLAMA_API_KEY}`;
"Content-Type": "application/json", }
"Authorization": `Bearer ${process.env.OLLAMA_API_KEY}`,
}
: { "Content-Type": "application/json" };
const body = isUsingOpenWebUI || isUsingOllamaChat const body = isUsingOpenWebUI || isUsingOllamaChat
? { model: resolvedModel, messages: [{ role: "user", content: prompt }] } ? { model, messages: [{ role: "user", content: prompt }] }
: { model: resolvedModel, prompt, stream: false }; : { model, prompt, stream: false };
const response = await fetch(OLLAMA_API_URL, { const response = await fetch(OLLAMA_API_URL, {
method: "POST", method: "POST",
@@ -122,7 +108,7 @@ async function callOllamaBase(prompt, model, retries, stepName, apiType) {
export async function callOllama( export async function callOllama(
prompt, prompt,
model, model = OLLAMA_MODEL,
retries = 5, retries = 5,
stepName = "unknown", stepName = "unknown",
) { ) {
@@ -132,7 +118,7 @@ export async function callOllama(
export async function callOllamaExplicit( export async function callOllamaExplicit(
prompt, prompt,
model, model = OLLAMA_MODEL,
retries = 5, retries = 5,
stepName = "unknown", stepName = "unknown",
apiType = "ollama-generate", apiType = "ollama-generate",
+22 -22
View File
@@ -6,46 +6,46 @@ import path from "path";
const hasOllama = !!process.env.OLLAMA_API_URL; const hasOllama = !!process.env.OLLAMA_API_URL;
describe.skipIf(!hasOllama)("Dungeon generation (Ollama)", { timeout: 120000 }, () => { describe.skipIf(!hasOllama)("Dungeon generation (Ollama)", () => {
const fixture = {}; let dungeonData;
beforeAll(async () => { beforeAll(async () => {
fixture.dungeon = await generateDungeon(); dungeonData = await generateDungeon();
}); }, 120000);
it("generates dungeon data", () => { it("generates dungeon data", () => {
expect(fixture.dungeon).toBeDefined(); expect(dungeonData).toBeDefined();
}); });
it("title is 2-4 words, no colons", () => { it("title is 2-4 words, no colons", () => {
expect(fixture.dungeon.title).toBeTruthy(); expect(dungeonData.title).toBeTruthy();
const words = fixture.dungeon.title.split(/\s+/); const words = dungeonData.title.split(/\s+/);
expect(words.length).toBeGreaterThanOrEqual(2); expect(words.length).toBeGreaterThanOrEqual(2);
expect(words.length).toBeLessThanOrEqual(4); expect(words.length).toBeLessThanOrEqual(4);
expect(fixture.dungeon.title).not.toContain(":"); expect(dungeonData.title).not.toContain(":");
}); });
it("flavor text is ≤60 words", () => { it("flavor text is ≤60 words", () => {
expect(fixture.dungeon.flavor).toBeTruthy(); expect(dungeonData.flavor).toBeTruthy();
const words = fixture.dungeon.flavor.split(/\s+/); const words = dungeonData.flavor.split(/\s+/);
expect(words.length).toBeLessThanOrEqual(60); expect(words.length).toBeLessThanOrEqual(60);
}); });
it("hooks have no title prefixes", () => { it("hooks have no title prefixes", () => {
expect(fixture.dungeon.hooksRumors).toBeDefined(); expect(dungeonData.hooksRumors).toBeDefined();
fixture.dungeon.hooksRumors.forEach((hook) => { dungeonData.hooksRumors.forEach((hook) => {
expect(hook).not.toMatch(/^[^:]+:\s/); expect(hook).not.toMatch(/^[^:]+:\s/);
}); });
}); });
it("has exactly 6 random events", () => { it("has exactly 6 random events", () => {
expect(fixture.dungeon.randomEvents).toBeDefined(); expect(dungeonData.randomEvents).toBeDefined();
expect(fixture.dungeon.randomEvents.length).toBe(6); expect(dungeonData.randomEvents.length).toBe(6);
}); });
it("encounter details do not start with encounter name", () => { it("encounter details do not start with encounter name", () => {
expect(fixture.dungeon.encounters).toBeDefined(); expect(dungeonData.encounters).toBeDefined();
fixture.dungeon.encounters.forEach((encounter) => { dungeonData.encounters.forEach((encounter) => {
if (encounter.details) { if (encounter.details) {
const detailsLower = encounter.details.toLowerCase(); const detailsLower = encounter.details.toLowerCase();
const nameLower = encounter.name.toLowerCase(); const nameLower = encounter.name.toLowerCase();
@@ -55,8 +55,8 @@ describe.skipIf(!hasOllama)("Dungeon generation (Ollama)", { timeout: 120000 },
}); });
it("treasure descriptions do not start with 'description'", () => { it("treasure descriptions do not start with 'description'", () => {
expect(fixture.dungeon.treasure).toBeDefined(); expect(dungeonData.treasure).toBeDefined();
fixture.dungeon.treasure.forEach((item) => { dungeonData.treasure.forEach((item) => {
if (typeof item === "object" && item.description) { if (typeof item === "object" && item.description) {
expect(item.description.toLowerCase().startsWith("description")).toBe(false); expect(item.description.toLowerCase().startsWith("description")).toBe(false);
} }
@@ -64,8 +64,8 @@ describe.skipIf(!hasOllama)("Dungeon generation (Ollama)", { timeout: 120000 },
}); });
it("NPC traits do not start with 'description'", () => { it("NPC traits do not start with 'description'", () => {
expect(fixture.dungeon.npcs).toBeDefined(); expect(dungeonData.npcs).toBeDefined();
fixture.dungeon.npcs.forEach((npc) => { dungeonData.npcs.forEach((npc) => {
if (npc.trait) { if (npc.trait) {
expect(npc.trait.toLowerCase().startsWith("description")).toBe(false); expect(npc.trait.toLowerCase().startsWith("description")).toBe(false);
} }
@@ -75,11 +75,11 @@ describe.skipIf(!hasOllama)("Dungeon generation (Ollama)", { timeout: 120000 },
it("PDF fits on one page", async () => { it("PDF fits on one page", async () => {
const testPdfPath = path.join(process.cwd(), "test-output.pdf"); const testPdfPath = path.join(process.cwd(), "test-output.pdf");
try { try {
await generatePDF(fixture.dungeon, testPdfPath); await generatePDF(dungeonData, testPdfPath);
const pdfBuffer = await fs.readFile(testPdfPath); const pdfBuffer = await fs.readFile(testPdfPath);
const pdfText = pdfBuffer.toString("binary"); const pdfText = pdfBuffer.toString("binary");
const pageCount = (pdfText.match(/\/Type\s*\/Page[^s]/g) || []).length; const pageCount = (pdfText.match(/\/Type\s*\/Page[^s]/g) || []).length;
const expectedPages = fixture.dungeon.map ? 2 : 1; const expectedPages = dungeonData.map ? 2 : 1;
expect(pageCount).toBeLessThanOrEqual(expectedPages); expect(pageCount).toBeLessThanOrEqual(expectedPages);
} finally { } finally {
try { try {
+7 -9
View File
@@ -203,16 +203,16 @@ describe("initializeModel (mocked fetch)", () => {
expect(globalThis.fetch).not.toHaveBeenCalled(); expect(globalThis.fetch).not.toHaveBeenCalled();
}); });
it("does not set env when fetch returns not ok", async () => { it("leaves OLLAMA_MODEL unchanged when fetch returns not ok", async () => {
process.env.OLLAMA_MODEL = ""; process.env.OLLAMA_MODEL = "";
const before = OLLAMA_MODEL;
vi.mocked(globalThis.fetch).mockResolvedValueOnce({ vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: false, ok: false,
status: 404, status: 404,
json: () => Promise.resolve({}), json: () => Promise.resolve({}),
}); });
const resolved = await initializeModel(); await initializeModel();
expect(resolved).toBe(OLLAMA_MODEL); expect(OLLAMA_MODEL).toBe(before);
expect(process.env.OLLAMA_MODEL).toBe("");
}); });
it("fetches /api/tags when OLLAMA_MODEL not set", async () => { it("fetches /api/tags when OLLAMA_MODEL not set", async () => {
@@ -222,13 +222,11 @@ describe("initializeModel (mocked fetch)", () => {
ok: true, ok: true,
json: () => Promise.resolve({ models: [{ name: "test-model" }] }), json: () => Promise.resolve({ models: [{ name: "test-model" }] }),
}); });
const resolved = await initializeModel(); await initializeModel();
expect(globalThis.fetch).toHaveBeenCalled(); expect(globalThis.fetch).toHaveBeenCalled();
const [url, opts] = vi.mocked(globalThis.fetch).mock.calls[0]; const [url, opts] = vi.mocked(globalThis.fetch).mock.calls[0];
expect(String(url)).toMatch(/\/api\/tags$/); expect(String(url)).toMatch(/\/api\/tags$/);
expect(opts?.method || "GET").toBe("GET"); 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 () => { it("fetches /api/v1/models when URL has open-webui path and sets model from data.data id", async () => {
@@ -241,7 +239,7 @@ describe("initializeModel (mocked fetch)", () => {
await initializeModel(); await initializeModel();
const [url] = vi.mocked(globalThis.fetch).mock.calls[0]; const [url] = vi.mocked(globalThis.fetch).mock.calls[0];
expect(String(url)).toMatch(/\/api\/v1\/models$/); expect(String(url)).toMatch(/\/api\/v1\/models$/);
expect(process.env.OLLAMA_MODEL).toBe("webui-model"); expect(OLLAMA_MODEL).toBe("webui-model");
}); });
it("sets model from data.data[0].name when id missing", async () => { it("sets model from data.data[0].name when id missing", async () => {
@@ -252,7 +250,7 @@ describe("initializeModel (mocked fetch)", () => {
json: () => Promise.resolve({ data: [{ name: "webui-model-name" }] }), json: () => Promise.resolve({ data: [{ name: "webui-model-name" }] }),
}); });
await initializeModel(); await initializeModel();
expect(process.env.OLLAMA_MODEL).toBe("webui-model-name"); expect(OLLAMA_MODEL).toBe("webui-model-name");
}); });
it("catches fetch failure and warns", async () => { it("catches fetch failure and warns", async () => {