Streaming Responses
April 4, 2026 · View on GitHub
QuikChat supports token-by-token streaming for LLM responses. This is the pattern used by ChatGPT, Claude, and other AI interfaces where text appears word-by-word.
The Pattern
Two methods make streaming work:
messageAddNew()— creates a new message with the first token, returns its IDmessageAppendContent(id, token)— appends each subsequent token to that message
// First token: create the message
const id = chat.messageAddNew(firstToken, 'bot', 'left');
// Subsequent tokens: append to it
chat.messageAppendContent(id, nextToken);
chat.messageAppendContent(id, anotherToken);
// ... keep appending until the stream ends
QuikChat handles scrolling automatically — as tokens are appended, the messages area scrolls to show the latest content (unless the user has manually scrolled up).
Typing Indicator
Show animated "..." dots while waiting for the first token:
const id = chat.messageAddTypingIndicator('bot', 'left');
// When the first token arrives, the dots auto-clear:
chat.messageReplaceContent(id, firstToken);
// Subsequent tokens append normally:
chat.messageAppendContent(id, nextToken);
The quikchat-typing CSS class is automatically removed by messageReplaceContent() and messageAppendContent(). No manual cleanup needed.
Complete Example with fetch + ReadableStream
This is the core streaming pattern used by all the LLM examples. It works with any API that returns a streaming response (Ollama, OpenAI, Mistral, LM Studio, etc.).
const chat = new quikchat('#chat', async (chat, userInput) => {
// 1. Echo the user's message
chat.messageAddNew(userInput, 'user', 'right', 'user');
// 2. Show typing indicator and disable input
const id = chat.messageAddTypingIndicator('bot');
chat.inputAreaSetEnabled(false);
// 3. Call the API with streaming enabled
const response = await fetch('http://localhost:11434/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'llama3.1',
messages: chat.historyGet(), // pass full conversation history
stream: true
})
});
// 4. Read the stream token by token
const reader = response.body.getReader();
let first = true;
while (true) {
const { value, done } = await reader.read();
if (done) break;
const text = new TextDecoder().decode(value, { stream: true });
const token = JSON.parse(text.trim()).message.content;
if (first) {
chat.messageReplaceContent(id, token); // clears typing dots
first = false;
} else {
chat.messageAppendContent(id, token); // append to it
}
}
chat.inputAreaSetEnabled(true);
});
OpenAI-Style SSE Streams
OpenAI and compatible APIs use Server-Sent Events (data: {...}\n) rather than NDJSON. The stream parsing is slightly different but the quikchat pattern is the same:
async function streamOpenAI(chat, userInput) {
chat.messageAddNew(userInput, 'user', 'right');
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: 'You are a helpful assistant.' },
...chat.historyGet()
],
stream: true
})
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let id;
let first = true;
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // keep incomplete line for next iteration
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') return;
const token = JSON.parse(data).choices[0].delta.content;
if (!token) continue;
if (first) {
id = chat.messageAddNew(token, 'bot', 'left');
first = false;
} else {
chat.messageAppendContent(id, token);
}
}
}
}
Non-Streaming (Simple Completion)
If you don't need streaming, just await the full response and add it as one message:
const chat = new quikchat('#chat', async (chat, userInput) => {
chat.messageAddNew(userInput, 'user', 'right');
const response = await fetch('http://localhost:11434/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'llama3.1',
prompt: userInput,
stream: false
})
});
const data = await response.json();
chat.messageAddNew(data.response, 'bot', 'left');
});
Replacing Content
messageReplaceContent(id, newContent) replaces the entire message body. This is useful for:
- Replacing a "thinking..." placeholder with the actual response
- Updating a tool-call result after execution
- Correcting a message
const id = chat.messageAddNew('Thinking...', 'bot', 'left');
// ... later ...
chat.messageReplaceContent(id, 'Here is the actual answer.');
Disabling Input While Streaming
Use inputAreaSetEnabled() to prevent the user from sending while the bot is responding:
const chat = new quikchat('#chat', async (chat, msg) => {
chat.messageAddNew(msg, 'user', 'right');
chat.inputAreaSetEnabled(false);
chat.inputAreaSetButtonText('Thinking...');
// ... stream response ...
chat.inputAreaSetEnabled(true);
chat.inputAreaSetButtonText('Send');
});
Tips
- The
roleparameter matters. When you passchat.historyGet()to an LLM API, therolefield maps directly to the API's role system ('user','assistant','system'). Use'assistant'for bot messages if you're sending history to an LLM. Each message also gets a CSS class (quikchat-role-assistant, etc.) for role-based styling. - History includes timestamps. Each message has
timestamp(created) andupdatedtime(last appended). Useful for logging or display. - Scroll behavior is automatic. QuikChat scrolls to the bottom on each append — unless the user has scrolled up, in which case it leaves them where they are.