Skip to content

Commit 4243355

Browse files
authored
Merge pull request #55 from Unity-Lab-AI/codex/investigate-broken-image-generation
Improve PolliLib docs, async image handling, and tests
2 parents f69daf1 + 3418396 commit 4243355

20 files changed

Lines changed: 379 additions & 36 deletions

.github/workflows/deploy-pages.yml

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,36 @@ concurrency:
2020
cancel-in-progress: true
2121

2222
jobs:
23+
tests:
24+
name: Run Test Suites
25+
runs-on: ubuntu-latest
26+
steps:
27+
- name: Checkout repository
28+
uses: actions/checkout@v4
29+
30+
- name: Setup Node
31+
uses: actions/setup-node@v4
32+
with:
33+
node-version: '20'
34+
35+
- name: Run tests
36+
run: |
37+
npm test
38+
39+
- name: Capture test status
40+
id: capture
41+
run: |
42+
cat tests/test-results.json
43+
node -e "const fs=require('fs');const r=JSON.parse(fs.readFileSync('tests/test-results.json','utf8'));fs.appendFileSync(process.env.GITHUB_OUTPUT,`status=${r.status}\n`);"
44+
45+
- name: Report test summary
46+
run: |
47+
node -e "const fs=require('fs');const r=JSON.parse(fs.readFileSync('tests/test-results.json','utf8'));console.log('# Test Summary');console.log('PolliLib',r.groups.pollilib.passed,'/',r.groups.pollilib.total);console.log('Site',r.groups.site.passed,'/',r.groups.site.total);console.log('Overall',r.passed,'/',r.total,'->',r.status);" >> $GITHUB_STEP_SUMMARY
48+
2349
build:
2450
name: Build and Upload Artifact
51+
needs: tests
52+
if: needs.tests.outputs.status != 'fail'
2553
runs-on: ubuntu-latest
2654
steps:
2755
- name: Checkout repository
@@ -49,7 +77,7 @@ jobs:
4977

5078
report-build-status:
5179
name: Report Build Status
52-
needs: build
80+
needs: [build, tests]
5381
runs-on: ubuntu-latest
5482
if: always()
5583
steps:

docs/polliLib.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# PolliLib Usage Guide
2+
3+
PolliLib provides a lightweight client for interacting with the Pollinations API from the browser or Node.js environments. This guide covers basic usage patterns for image, text and audio generation along with other helper utilities.
4+
5+
## Installation
6+
7+
PolliLib is bundled in this repository under `js/polliLib`. Include `polliLib-web.global.js` in the browser or import the individual modules from `js/polliLib/src` when using Node.js.
8+
9+
```html
10+
<script src="js/polliLib/polliLib-web.global.js"></script>
11+
<script>
12+
polliLib.configure({ referrer: window.location.origin });
13+
</script>
14+
```
15+
16+
## Image Generation
17+
18+
```javascript
19+
import { image } from './js/polliLib/src/image.js';
20+
21+
const blob = await image('a tiny red square', {
22+
width: 64,
23+
height: 64,
24+
json: false, // set to true to receive raw JSON metadata
25+
retries: 5 // poll until the image is ready
26+
});
27+
```
28+
29+
The call returns a `Blob` containing the generated PNG. Passing `json: true` forces Pollinations to return raw JSON when supported. When the service responds with a placeholder JSON payload, the function automatically polls until an actual image is available.
30+
31+
## Text Generation
32+
33+
```javascript
34+
import { text, chat } from './js/polliLib/src/text.js';
35+
36+
const out = await text('Explain gravity in one sentence.', { model: 'openai' });
37+
38+
const chatOut = await chat({
39+
model: 'openai',
40+
messages: [
41+
{ role: 'user', content: 'Say hello.' }
42+
],
43+
json: true // request strict JSON formatting
44+
});
45+
```
46+
47+
`text` returns a string (or an async iterator when `stream: true`). `chat` mirrors the OpenAI chat API and can also stream JSON objects when requested.
48+
49+
## Audio Generation
50+
51+
```javascript
52+
import { tts, stt } from './js/polliLib/src/audio.js';
53+
54+
const speech = await tts('hello world', { voice: 'alloy' });
55+
const transcript = await stt({ data: myArrayBuffer, format: 'mp3' });
56+
```
57+
58+
`tts` produces a spoken audio `Blob` using the `openai-audio` model. `stt` performs speech‑to‑text on a provided file or raw audio buffer.
59+
60+
## Model Capabilities
61+
62+
```javascript
63+
import { modelCapabilities } from './js/polliLib/src/models.js';
64+
65+
const caps = await modelCapabilities();
66+
console.log(caps.image); // available image models
67+
console.log(caps.text); // available text models
68+
console.log(caps.audio); // audio voices (if available)
69+
```
70+
71+
This helper combines information from the image and text model endpoints so applications can dynamically enable features based on available capabilities.
72+
73+
## Other Utilities
74+
75+
- **Feeds**`imageFeed` and `textFeed` stream recent public generations.
76+
- **Tools & MCP** – helpers for creating tool calls and constructing MCP servers.
77+
- **Pipeline** – compose multi‑step workflows that mix text, image and audio steps.
78+
79+
See the source files in `js/polliLib/src` for full details on each module.
80+

js/chat/chat-storage.js

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -183,18 +183,27 @@ document.addEventListener("DOMContentLoaded", () => {
183183
img.dataset.imageUrl = url;
184184
img.dataset.imageId = imageId;
185185
img.crossOrigin = "anonymous";
186-
img.onload = () => {
187-
loadingDiv.remove();
188-
img.style.display = "block";
189-
attachImageButtons(img, imageId);
190-
};
191-
img.onerror = () => {
192-
loadingDiv.innerHTML = "⚠️ Failed to load image";
193-
loadingDiv.style.display = "flex";
194-
loadingDiv.style.justifyContent = "center";
195-
loadingDiv.style.alignItems = "center";
196-
};
197-
imageContainer.appendChild(img);
186+
let attempts = 0;
187+
const maxAttempts = 5;
188+
const tryReload = () => {
189+
if (attempts++ >= maxAttempts) {
190+
loadingDiv.innerHTML = "⚠️ Failed to load image";
191+
loadingDiv.style.display = "flex";
192+
loadingDiv.style.justifyContent = "center";
193+
loadingDiv.style.alignItems = "center";
194+
return;
195+
}
196+
setTimeout(() => {
197+
img.src = url + (url.includes('?') ? '&' : '?') + 'retry=' + Date.now();
198+
}, 1000 * attempts);
199+
};
200+
img.onload = () => {
201+
loadingDiv.remove();
202+
img.style.display = "block";
203+
attachImageButtons(img, imageId);
204+
};
205+
img.onerror = tryReload;
206+
imageContainer.appendChild(img);
198207
const imgButtonContainer = document.createElement("div");
199208
imgButtonContainer.className = "image-button-container";
200209
imgButtonContainer.dataset.imageId = imageId;

js/polliLib/polliLib-web.global.js

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171

7272
// --- helpers ---
7373
const bool = v => (v == null ? undefined : (v ? 'true' : 'false'));
74+
const sleep = ms => new Promise(res => setTimeout(res, ms));
7475
function base64FromArrayBuffer(ab) {
7576
const bytes = new Uint8Array(ab);
7677
let binary = '';
@@ -83,7 +84,7 @@
8384
}
8485

8586
// --- image.js ---
86-
async function image(prompt, { model, seed, width, height, image: imgUrl, nologo, private: priv, enhance, safe, referrer } = {}, client = getDefaultClient()) {
87+
async function image(prompt, { model, seed, width, height, image: imgUrl, nologo, private: priv, enhance, safe, referrer, json, retries = 5, retryDelayMs = 1000 } = {}, client = getDefaultClient()) {
8788
const url = `${client.imageBase}/prompt/${encodeURIComponent(prompt)}`;
8889
const params = {};
8990
if (model) params.model = model;
@@ -96,8 +97,24 @@
9697
if (enhance != null) params.enhance = bool(enhance);
9798
if (safe != null) params.safe = bool(safe);
9899
if (referrer) params.referrer = referrer;
99-
const r = await client.get(url, { params });
100+
if (json) params.json = 'true';
101+
const headers = json ? { Accept: 'application/json' } : {};
102+
const r = await client.get(url, { params, headers });
100103
if (!r.ok) throw new Error(`image error ${r.status}`);
104+
const ct = r.headers.get('content-type') ?? '';
105+
if (ct.includes('application/json')) {
106+
const data = await r.json();
107+
if (json) return data;
108+
if (data?.url) {
109+
const ir = await fetch(data.url);
110+
if (ir.ok) return await ir.blob();
111+
}
112+
if (retries > 0) {
113+
await sleep(retryDelayMs);
114+
return await image(prompt, { model, seed, width, height, image: imgUrl, nologo, private: priv, enhance, safe, referrer, json, retries: retries - 1, retryDelayMs }, client);
115+
}
116+
throw new Error('image pending');
117+
}
101118
return await r.blob();
102119
}
103120
async function imageModels(client = getDefaultClient()) {
@@ -131,7 +148,7 @@
131148
return await r.text();
132149
}
133150
}
134-
async function chat({ model, messages, seed, temperature, top_p, presence_penalty, frequency_penalty, max_tokens, stream, private: priv, tools, tool_choice, referrer }, client = getDefaultClient()) {
151+
async function chat({ model, messages, seed, temperature, top_p, presence_penalty, frequency_penalty, max_tokens, stream, private: priv, tools, tool_choice, referrer, json }, client = getDefaultClient()) {
135152
const url = `${client.textBase}/openai`;
136153
const body = { model, messages };
137154
if (seed != null) body.seed = seed;
@@ -144,6 +161,7 @@
144161
if (tools) body.tools = tools;
145162
if (tool_choice) body.tool_choice = tool_choice;
146163
if (referrer) body.referrer = referrer;
164+
if (json) body.json = true;
147165
if (stream) {
148166
body.stream = true;
149167
const r = await client.postJson(url, body, { headers: { 'Accept': 'text/event-stream' } });
@@ -266,6 +284,14 @@
266284
async function listTextModels(client) { return await textModels(client); }
267285
async function listAudioVoices(client) { const models = await textModels(client); return models?.['openai-audio']?.voices ?? []; }
268286

287+
async function modelCapabilities(client = getDefaultClient()) {
288+
const [image, text] = await Promise.all([
289+
imageModels(client).catch(() => ({})),
290+
textModels(client).catch(() => ({})),
291+
]);
292+
return { image, text, audio: text?.['openai-audio'] ?? {} };
293+
}
294+
269295
// --- pipeline.js ---
270296
class Context extends Map {}
271297
class Pipeline { constructor() { this.steps = []; } step(s) { this.steps.push(s); return this; } async execute({ client, context = new Context() } = {}) { for (const s of this.steps) await s.run({ client, context }); return context; } }
@@ -282,7 +308,7 @@
282308
const api = {
283309
configure,
284310
image, text, chat, search, tts, stt, vision,
285-
imageModels, textModels, imageFeed, textFeed,
311+
imageModels, textModels, imageFeed, textFeed, modelCapabilities,
286312
tools: { functionTool, ToolBox, chatWithTools },
287313
mcp: { serverName, toolDefinitions, generateImageUrl, generateImageBase64, listImageModels, listTextModels, listAudioVoices },
288314
pipeline: { Context, Pipeline, TextGetStep, ImageStep, TtsStep, VisionUrlStep },

js/polliLib/polliLib-web.global.js.bak

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171

7272
// --- helpers ---
7373
const bool = v => (v == null ? undefined : (v ? 'true' : 'false'));
74+
const sleep = ms => new Promise(res => setTimeout(res, ms));
7475
function base64FromArrayBuffer(ab) {
7576
const bytes = new Uint8Array(ab);
7677
let binary = '';
@@ -83,7 +84,7 @@
8384
}
8485

8586
// --- image.js ---
86-
async function image(prompt, { model, seed, width, height, image: imgUrl, nologo, private: priv, enhance, safe, referrer } = {}, client = getDefaultClient()) {
87+
async function image(prompt, { model, seed, width, height, image: imgUrl, nologo, private: priv, enhance, safe, referrer, json, retries = 5, retryDelayMs = 1000 } = {}, client = getDefaultClient()) {
8788
const url = `${client.imageBase}/prompt/${encodeURIComponent(prompt)}`;
8889
const params = {};
8990
if (model) params.model = model;
@@ -96,8 +97,24 @@
9697
if (enhance != null) params.enhance = bool(enhance);
9798
if (safe != null) params.safe = bool(safe);
9899
if (referrer) params.referrer = referrer;
99-
const r = await client.get(url, { params });
100+
if (json) params.json = 'true';
101+
const headers = json ? { Accept: 'application/json' } : {};
102+
const r = await client.get(url, { params, headers });
100103
if (!r.ok) throw new Error(`image error ${r.status}`);
104+
const ct = r.headers.get('content-type') ?? '';
105+
if (ct.includes('application/json')) {
106+
const data = await r.json();
107+
if (json) return data;
108+
if (data?.url) {
109+
const ir = await fetch(data.url);
110+
if (ir.ok) return await ir.blob();
111+
}
112+
if (retries > 0) {
113+
await sleep(retryDelayMs);
114+
return await image(prompt, { model, seed, width, height, image: imgUrl, nologo, private: priv, enhance, safe, referrer, json, retries: retries - 1, retryDelayMs }, client);
115+
}
116+
throw new Error('image pending');
117+
}
101118
return await r.blob();
102119
}
103120
async function imageModels(client = getDefaultClient()) {
@@ -131,7 +148,7 @@
131148
return await r.text();
132149
}
133150
}
134-
async function chat({ model, messages, seed, temperature, top_p, presence_penalty, frequency_penalty, max_tokens, stream, private: priv, tools, tool_choice, referrer }, client = getDefaultClient()) {
151+
async function chat({ model, messages, seed, temperature, top_p, presence_penalty, frequency_penalty, max_tokens, stream, private: priv, tools, tool_choice, referrer, json }, client = getDefaultClient()) {
135152
const url = `${client.textBase}/openai`;
136153
const body = { model, messages };
137154
if (seed != null) body.seed = seed;
@@ -144,6 +161,7 @@
144161
if (tools) body.tools = tools;
145162
if (tool_choice) body.tool_choice = tool_choice;
146163
if (referrer) body.referrer = referrer;
164+
if (json) body.json = true;
147165
if (stream) {
148166
body.stream = true;
149167
const r = await client.postJson(url, body, { headers: { 'Accept': 'text/event-stream' } });
@@ -266,6 +284,14 @@
266284
async function listTextModels(client) { return await textModels(client); }
267285
async function listAudioVoices(client) { const models = await textModels(client); return models?.['openai-audio']?.voices ?? []; }
268286

287+
async function modelCapabilities(client = getDefaultClient()) {
288+
const [image, text] = await Promise.all([
289+
imageModels(client).catch(() => ({})),
290+
textModels(client).catch(() => ({})),
291+
]);
292+
return { image, text, audio: text?.['openai-audio'] ?? {} };
293+
}
294+
269295
// --- pipeline.js ---
270296
class Context extends Map {}
271297
class Pipeline { constructor() { this.steps = []; } step(s) { this.steps.push(s); return this; } async execute({ client, context = new Context() } = {}) { for (const s of this.steps) await s.run({ client, context }); return context; } }
@@ -284,7 +310,7 @@
284310
const api = {
285311
configure,
286312
image, text, chat, search, tts, stt, vision,
287-
imageModels, textModels, imageFeed, textFeed,
313+
imageModels, textModels, imageFeed, textFeed, modelCapabilities,
288314
tools: { functionTool, ToolBox, chatWithTools },
289315
mcp: { serverName, toolDefinitions, generateImageUrl, generateImageBase64, listImageModels, listTextModels, listAudioVoices },
290316
pipeline: { Context, Pipeline, TextGetStep, ImageStep, TtsStep, VisionUrlStep },

js/polliLib/src/image.js

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { getDefaultClient } from './client.js';
22

33
const bool = v => (v == null ? undefined : (v ? 'true' : 'false'));
4+
const sleep = ms => new Promise(res => setTimeout(res, ms));
45

56
export async function image(prompt, {
67
model, seed, width, height, image, nologo, private: priv, enhance, safe, referrer,
8+
json, retries = 5, retryDelayMs = 1000,
79
} = {}, client = getDefaultClient()) {
810
const url = `${client.imageBase}/prompt/${encodeURIComponent(prompt)}`;
911
const params = {};
@@ -17,9 +19,31 @@ export async function image(prompt, {
1719
if (enhance != null) params.enhance = bool(enhance);
1820
if (safe != null) params.safe = bool(safe);
1921
if (referrer) params.referrer = referrer;
22+
if (json) params.json = 'true';
2023

21-
const r = await client.get(url, { params });
24+
const headers = json ? { Accept: 'application/json' } : {};
25+
26+
const r = await client.get(url, { params, headers });
2227
if (!r.ok) throw new Error(`image error ${r.status}`);
28+
29+
const ct = r.headers.get('content-type') ?? '';
30+
if (ct.includes('application/json')) {
31+
const data = await r.json();
32+
if (json) return data;
33+
if (data?.url) {
34+
const ir = await fetch(data.url);
35+
if (ir.ok) return await ir.blob();
36+
}
37+
if (retries > 0) {
38+
await sleep(retryDelayMs);
39+
return await image(prompt, {
40+
model, seed, width, height, image, nologo, private: priv,
41+
enhance, safe, referrer, json, retries: retries - 1, retryDelayMs,
42+
}, client);
43+
}
44+
throw new Error('image pending');
45+
}
46+
2347
return await r.blob();
2448
}
2549

js/polliLib/src/models.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { getDefaultClient } from './client.js';
2+
import { imageModels } from './image.js';
3+
import { textModels } from './text.js';
4+
5+
export async function modelCapabilities(client = getDefaultClient()) {
6+
const [image, text] = await Promise.all([
7+
imageModels(client).catch(() => ({})),
8+
textModels(client).catch(() => ({})),
9+
]);
10+
return { image, text, audio: text?.['openai-audio'] ?? {} };
11+
}
12+

0 commit comments

Comments
 (0)