Days
Hours
Minutes
Seconds
x
Skip to content
Froala Documentation

Stream AI Chat Responses with OpenRouter

Try the Streaming AI Chat Demo

Streaming shows an AI answer word by word as the model writes it, instead of making users wait for the full reply. This example connects Froala’s AI Chat to OpenRouter with one JavaScript function, aiAssistRequest. It sends each piece through the onChunk callback, so your rich text editor feels as responsive as a chat app.

Try it yourself:


Edit in JSFiddle

HTML

<div id="editor">
</div>

JAVASCRIPT

<script>
// OpenRouter settings. Replace the key with your own.
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
const OPENROUTER_API_KEY = 'sk-or-v1-YOUR_OPENROUTER_API_KEY';
const OPENROUTER_MODEL = 'openai/gpt-4o-mini';

// Reads a streamed response one line at a time.
// OpenRouter sends each piece of the answer as a "data: {...}" line.
async function readSSELines(response, onLine) {
  const reader = response.body.getReader();
  const decoder = new TextDecoder('utf-8');
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n');
    buffer = lines.pop(); // Keep the last, possibly incomplete, line for the next read.
    lines.forEach(onLine);
  }

  if (buffer) onLine(buffer); // Process anything left after the stream closes.
}

// Froala calls this function for every AI Chat and AI Assist request.
// AI Chat passes onChunk, so the answer streams in piece by piece.
// AI Assist leaves onChunk out, so the full answer comes back at once.
async function openRouterRequestHandler(request, signal, onChunk) {
  const streaming = typeof onChunk === 'function';

  const response = await fetch(OPENROUTER_API_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${OPENROUTER_API_KEY}`
    },
    body: JSON.stringify({
      model: OPENROUTER_MODEL,
      messages: [{ role: 'user', content: request.prompt }],
      stream: streaming
    }),
    signal // Lets the Cancel button end the request.
  });

  if (!response.ok) {
    throw new Error(`OpenRouter API error ${response.status}`);
  }

  // One-shot mode: the full answer arrives in a single JSON response.
  if (!streaming) {
    const data = await response.json();
    return (data.choices?.[0]?.message?.content || '').trim();
  }

  // Streaming mode: pass each new piece to onChunk as it arrives.
  let answer = '';

  await readSSELines(response, (line) => {
    const trimmed = line.trim();
    if (!trimmed.startsWith('data:')) return; // Skips keep-alive comments.

    const payload = trimmed.slice(5).trim();
    if (payload === '[DONE]') return; // OpenRouter's end-of-stream marker.

    let chunk;
    try {
      chunk = JSON.parse(payload);
    } catch (err) {
      console.warn('OpenRouter stream: skipped an unreadable line', err);
      return;
    }

    // Errors that happen mid-stream arrive as a data line with an "error" field.
    if (chunk.error) {
      throw new Error(`OpenRouter stream error: ${chunk.error.message}`);
    }

    const text = chunk.choices?.[0]?.delta?.content;
    if (text) {
      answer += text;
      onChunk(text);
    }
  });

  return answer.trim();
}

new FroalaEditor('#editor', {
  toolbarButtons: ['aiChatAssistant', 'aiAssist', '|', 'bold', 'italic', 'formatUL', '|', 'undo', 'redo'],
  heightMin: 300,
  aiSupplementalTermsAccepted: true, // Required for every AI feature.
  aiChatStreamResponse: true, // The default. Shown here for clarity.
  aiAssistRequest: openRouterRequestHandler
});
</script>

In this example

You write one function, aiAssistRequest, and Froala uses it for both AI features in the toolbar.

  • AI Chat calls it with an onChunk callback and shows the answer in small pieces as they arrive.
  • AI Assist calls it once and inserts the complete answer.
  • The signal argument connects the chat's Cancel button to your request.
  • OpenRouter gives you access to models from many AI providers through one API key.

How it works

How a streamed answer reaches the chat panel
  1. The user asks a question in AI Chat
  2. Froala calls your handleraiAssistRequest
  3. OpenRouter streams the answer in small piecesstream: true
  4. Your handler passes each piece to FroalaonChunk(text)
  5. Your handler returns the full answerreturn answer

Froala picks the mode for you

AI Chat streams by default because aiChatStreamResponse is true. In this mode, Froala calls your handler with a third argument, onChunk. AI Assist calls the same handler with two arguments.

Checking typeof onChunk === 'function' tells your code which path to take. One function then serves both features, which keeps your setup in one place.

OpenRouter sends the answer in small pieces

With stream: true, OpenRouter replies with server-sent events. Each event is a line that starts with data: and holds a small JSON object with the next piece of text.

The handler reads three kinds of lines.

  • Lines that start with : are keep-alive comments that hold the connection open. The handler skips them.
  • data: [DONE] marks the end of the answer.
  • A data: line with an error field reports a problem mid-stream. The handler throws an error, and the chat panel shows a Retry option.

readSSELines keeps every line whole

The network delivers data in chunks, and a chunk can end in the middle of a line. readSSELines holds the unfinished part in a buffer and joins it with the next chunk. Every line reaches JSON.parse complete, so the stream reads reliably on slow connections too.

Return the complete answer

Each onChunk(text) call adds text to the chat message. When the stream ends, the handler returns the full answer, and Froala uses it as the final version of the message. This keeps the message and the chat history in sync.

Cancel works through signal

Froala creates an AbortSignal for every request. Passing it to fetch lets the chat's Cancel button close the connection to OpenRouter. Froala then clears the unfinished reply.

Good to know

  • This example calls OpenRouter from the browser, so anyone who opens the page can read your API key. Use it for prototypes, demos, and model testing. For production, keep the key on your server and follow the Custom Backend example.
  • Froala AI features stay inactive until you set aiSupplementalTermsAccepted to true. Setting it confirms that you accept the Froala AI Supplemental Terms.
  • Froala's prompt asks the model to answer in HTML, so answers keep their formatting when users insert them into the editor.
Related Plugin:
AI Assist