Skip to content

Commit 649cad4

Browse files
committed
feat(chat): stream tokens for non-image prompts; reduce default image size for faster generations
1 parent 4c93907 commit 649cad4

2 files changed

Lines changed: 137 additions & 4 deletions

File tree

Libs/pollilib/index.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,86 @@ export async function chat(payload, client) {
255255
}
256256
}
257257

258+
// Streaming chat helper (SSE). Yields content deltas as strings.
259+
export async function* chatStream(payload, client) {
260+
const c = client instanceof PolliClient ? client : new PolliClient();
261+
const referrer = resolveReferrer();
262+
const { endpoint = 'openai', model: selectedModel = 'openai', messages = [], tools = null, tool_choice = 'auto', ...rest } = payload || {};
263+
// Intentionally do not set response_format here to keep tokens human-readable
264+
const filteredMessages = Array.isArray(messages) ? messages.filter(m => !m || typeof m !== 'object' || m.role !== 'system') : [];
265+
const url = `${c.textPromptBase}/openai`;
266+
const body = {
267+
model: selectedModel,
268+
messages: filteredMessages,
269+
stream: true,
270+
...(referrer ? { referrer } : {}),
271+
...(rest.seed != null ? { seed: rest.seed } : {}),
272+
...(Array.isArray(tools) && tools.length ? { tools, tool_choice } : {}),
273+
...rest,
274+
};
275+
body.safe = false;
276+
const controller = new AbortController();
277+
const t = setTimeout(() => controller.abort(), c.timeoutMs);
278+
try {
279+
try {
280+
let log = (globalThis && globalThis.__PANEL_LOG__);
281+
if (!log && globalThis) { globalThis.__PANEL_LOG__ = []; log = globalThis.__PANEL_LOG__; }
282+
if (log && Array.isArray(log)) {
283+
log.push({ ts: Date.now(), kind: 'chat:request', url, model: selectedModel, referer: referrer || null, meta: { endpoint: endpoint || 'openai', json: false, stream: true } });
284+
}
285+
} catch {}
286+
const resp = await c.fetch(url, {
287+
method: 'POST',
288+
headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' },
289+
body: JSON.stringify(body),
290+
signal: controller.signal,
291+
});
292+
if (!resp.ok) {
293+
const err = new Error(`HTTP ${resp.status}`);
294+
err.status = resp.status;
295+
err.statusText = resp.statusText;
296+
try { const log = (globalThis && globalThis.__PANEL_LOG__); if (log && Array.isArray(log)) log.push({ ts: Date.now(), kind: 'chat:error', url, model: selectedModel, ok: false, status: resp.status, meta: { stream: true } }); } catch {}
297+
throw err;
298+
}
299+
// Iterate SSE lines
300+
const reader = resp.body && typeof resp.body.getReader === 'function' ? resp.body.getReader() : null;
301+
if (reader) {
302+
const decoder = new TextDecoder();
303+
let buf = '';
304+
for (;;) {
305+
const { done, value } = await reader.read();
306+
if (done) break;
307+
buf += decoder.decode(value, { stream: true });
308+
const parts = buf.split(/\r?\n/);
309+
buf = parts.pop() ?? '';
310+
for (const line of parts) {
311+
if (!line.startsWith('data:')) continue;
312+
const data = line.slice(5).trim();
313+
if (data === '[DONE]') { buf = ''; break; }
314+
try {
315+
const obj = JSON.parse(data);
316+
const content = obj?.choices?.[0]?.delta?.content;
317+
if (content) yield content;
318+
} catch {
319+
// ignore non-JSON chunks
320+
}
321+
}
322+
}
323+
} else {
324+
// Fallback: parse entire body if streaming unsupported
325+
const text = await resp.text();
326+
for (const line of String(text).split(/\r?\n/)) {
327+
if (!line.startsWith('data:')) continue;
328+
const data = line.slice(5).trim();
329+
if (data === '[DONE]') break;
330+
try { const obj = JSON.parse(data); const content = obj?.choices?.[0]?.delta?.content; if (content) yield content; } catch {}
331+
}
332+
}
333+
} finally {
334+
clearTimeout(t);
335+
}
336+
}
337+
258338
export async function image(prompt, options, client) {
259339
const c = client instanceof PolliClient ? client : new PolliClient();
260340
const referrer = resolveReferrer();

src/main.js

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import './style.css';
22
import 'highlight.js/styles/github.css';
33
import { renderMarkdown, enhanceCodeBlocksHtml } from './lib/markdown.js';
44
import { looseJsonParse, repairModelOutput } from './lib/json-repair.js';
5-
import { chat, image, textModels } from '../Libs/pollilib/index.js';
5+
import { chat, chatStream, image, textModels } from '../Libs/pollilib/index.js';
66
import { generateSeed } from './seed.js';
77
import { createPollinationsClient } from './pollinations-client.js';
88
import {
@@ -281,6 +281,53 @@ function renderDebugPanel(extra = {}) {
281281
}
282282
}
283283

284+
// Fast-path streaming for text-only prompts to improve perceived latency
285+
async function sendPromptStreaming(prompt) {
286+
const selectedModel = getSelectedModel();
287+
if (!selectedModel) throw new Error('No model selected.');
288+
if (!client) throw new Error('Pollinations client is not ready.');
289+
const endpoints = buildEndpointSequence(selectedModel);
290+
if (!endpoints.length) throw new Error(`No endpoints available for model "${selectedModel.label ?? selectedModel.id}".`);
291+
292+
const startingLength = state.conversation.length;
293+
// Do NOT inject the JSON primer for streaming text-only turns
294+
state.conversation.push({ role: 'user', content: prompt });
295+
try {
296+
setStatus('Streaming response…');
297+
const assistantMsg = addMessage({ role: 'assistant', type: 'text', content: '' });
298+
const pinnedId = state.pinnedModelId || selectedModel.id;
299+
const endpoint = endpoints[0] || 'openai';
300+
state.activeModel = { id: pinnedId, endpoint, info: selectedModel };
301+
if (!state.pinnedModelId) state.pinnedModelId = pinnedId;
302+
let streamed = '';
303+
try {
304+
for await (const chunk of chatStream({ model: pinnedId, endpoint, messages: state.conversation, seed: generateSeed() }, client)) {
305+
if (typeof chunk === 'string' && chunk) {
306+
streamed += chunk;
307+
assistantMsg.content = streamed;
308+
renderMessages();
309+
}
310+
}
311+
} catch (e) {
312+
// Fallback to existing non-stream flow
313+
console.warn('Streaming failed; falling back to standard request', e);
314+
state.conversation.length = startingLength; // revert user injection
315+
return await sendPrompt(prompt);
316+
}
317+
if (streamed.trim()) {
318+
state.conversation.push({ role: 'assistant', content: streamed });
319+
if (state.voicePlayback && els.voiceSelect.value) {
320+
void speakMessage(assistantMsg, { autoplay: true });
321+
}
322+
}
323+
resetStatusIfIdle();
324+
} catch (error) {
325+
console.error('Chat error (streaming)', error);
326+
state.conversation.length = startingLength;
327+
throw error;
328+
}
329+
}
330+
284331
async function copyLogsToClipboard() {
285332
try {
286333
const data = (globalThis && globalThis.__PANEL_LOG__) || [];
@@ -1859,8 +1906,8 @@ async function generateImageAsset(prompt, { width, height, model: imageModel, se
18591906
}
18601907
const resolvedSeed = (typeof seed === 'number' || (typeof seed === 'string' && seed.trim().length)) ? seed : generateSeed();
18611908
const dims = [];
1862-
const w = Number(width) || 1024;
1863-
const h = Number(height) || 1024;
1909+
const w = Number(width) || 768;
1910+
const h = Number(height) || 768;
18641911
dims.push([w, h]);
18651912
if (w > 512 || h > 512) dims.push([512, 512]);
18661913

@@ -2338,7 +2385,13 @@ els.form.addEventListener('submit', async event => {
23382385
console.info('Generated Pollinations image with seed %s.', seed);
23392386
resetStatusIfIdle();
23402387
} else {
2341-
await sendPrompt(raw);
2388+
// Stream for non-image prompts to speed up perceived latency
2389+
const wantsImage = hasImageIntent(raw);
2390+
if (!wantsImage) {
2391+
await sendPromptStreaming(raw);
2392+
} else {
2393+
await sendPrompt(raw);
2394+
}
23422395
}
23432396
} catch (error) {
23442397
console.error('Submission error', error);

0 commit comments

Comments
 (0)