Issues with Streaming HTML Content in JavaScript: Leading Parts of Tags Getting Removed

Hello everyone,

I’m working on a project where I need to stream HTML content from a server to a client using Server-Sent Events (SSE). The server sends chunks of HTML content that should be displayed on the client side in real-time. However, I’m facing an issue where leading parts of HTML tags, such as <h and <p, are getting removed during the rendering process on the client side.

Here’s what I’ve done so far:

  1. Server-Side PHP Code:
  • The server sends chunks of HTML content using SSE.
  • Each chunk is processed and sent to the client.

PHP code snippet which acts as a proxy between Open API chat completion API and my client.

function process_openai_stream_data($data, &$full_response) {
    if (connection_aborted()) {
        return 0; // Stop the stream if the client has disconnected
    }

    $lines = explode("\n", $data);
    foreach ($lines as $line) {
        if (strpos($line, 'data: ') === 0) {
            $line = trim(substr($line, 6)); // Remove 'data: ' prefix
            if ($line === '[DONE]') {
                // End of stream signal
                echo "data: [END]\n\n";
                flush();
                return 0; // Stop the stream
            }
            $json = json_decode($line, true);
            if (json_last_error() !== JSON_ERROR_NONE) {
                continue;
            }
            if (isset($json['choices'][0]['delta']['content'])) {
                $content = $json['choices'][0]['delta']['content'];
                $full_response .= $content;
                echo "data: " . $content . "\n\n";
                flush();
            }
        }
    }
    return strlen($data);
}
  1. Client-Side JavaScript Code:
  • The client receives the data and appends it to a div.

javascript code which processes the events:

function downloadResults(requestId) {
    const formData = new FormData();
    formData.append('action', 'download_feedback_streaming');
    formData.append('request_id', requestId);
    formData.append('nonce', beyond.nonce);
    formData.append('ajax', 'frontend');

    const resultDisplay = document.getElementById('result-content');
    if (!resultDisplay) {
        console.error('Result display element not found');
        return;
    }
    resultDisplay.innerHTML = ''; // Clear previous content

    fetch(beyond.ajaxurl, {
        method: 'POST',
        body: formData
    })
    .then(response => {
        if (!response.ok) {
            throw new Error('Network response was not ok');
        }
        const reader = response.body.getReader();
        const decoder = new TextDecoder();
        let buffer = '';

        function readStream() {
            reader.read().then(({ done, value }) => {
                if (done) {
                    displayAudioFeedback();
                    console.log('Stream complete');
                    return;
                }

                buffer += decoder.decode(value, { stream: true });
                const events = buffer.split('\n\n');
                buffer = events.pop(); // Keep the last incomplete event in the buffer

                events.forEach(event => {
                    if (event.startsWith('data: ')) {
                        const data = event.slice(6); // Remove 'data: ' prefix
                        // Append to the result display
                        resultDisplay.insertAdjacentHTML('beforeend', data);
                    }
                });

                readStream(); // Continue reading next chunk
            }).catch(error => {
                console.error('Error reading stream:', error);
                displayError("An error occurred while retrieving your feedback. Please try again.");
            });
        }

        readStream(); // Start reading the stream
    })
    .catch(error => {
        console.error('Error downloading results:', error);
        displayError("An error occurred while retrieving your feedback. Please try again.");
    });
}

The Issue:

When streaming, some HTML tags are split across chunks, resulting in the leading parts of the tags getting removed. For instance:

  • The tag <h3> might be split into <h, 3, and >, and I see that the <h in this example is not getting added to the div. This happens for other tags such as

    .

What I’ve Tried:

I printed the data on the server side, during the API invocation from the client and the Javascript code. The data is coming as expected in the first two places. I noticed a new line added to the closing tag such as “>n”.

[07-Jul-2024 22:20:35 UTC] data: {"id":"chatcmpl-9iUXCKCHUnVncnVOWUOP8W9I8cqOE","object":"chat.completion.chunk","created":1720390834,"model":"gpt-4-0613","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":">\n"},"logprobs":null,"finish_reason":null}]}

On the Javascript side, the opening portion of the tag that comes right after the closing tag with the new line is getting ignored.

Question:

How can I ensure that HTML tags split across multiple chunks are correctly handled and displayed on the client? Is there a better way to handle this if I want to display HTML-formatted content to the user?

Any help or suggestions would be greatly appreciated!

1 Like

Welcome back! Hope you’ve been well.

Hrm. Interesting one. I’ve not done a lot of streaming myself, but I wonder (since you’re processing them anyway) if you can check for complete tags and queue one or two chunks until you verify you have complete tags?

Or maybe autocomplete on your own any broken tags then remove the other end? Could get complicated, though.

Following for other (better) ideas…

Thanks for sharing the problem with us. Hopefully, we can all come up with a solution.

This sounds like a disassemble-assemble communications problem. I believe the solution involves keeping a buffer on the server side till you have a valid html “packet” or chunk before sending. Search on the web for ‘is_html’ to check that the chunk is valid. Note that some of the solutions that search provides may lead to false positives (for those solutions using regular-expressions), so you may need to use them as starting points to a full solution.

1 Like

In fact, I’m not doing anything special here apart from the fact that there is a proxy between the web client and Open AI completion (streaming) API. The format of the data that is coming is the same as the data that would come from Open AI API. I’m hoping there is a better solution than the server doing additional processing for “collecting” all related tags.

If you want all the work done on the javascript side, then using insertAdjacentHTML may be the problem, as I think the old school of manipulating the innerHTML directly would work for partial data, as I recall (it’s been awhile and I may have hidden the div temporarily to avoid browser display hiccups). The function you’re using does some preprocessing, so it may skip or ignore bad tags.

1 Like

I did try innerHTML and had the exact same problem. In this case, I cannot hide the div since I want to show the data as it comes in.

1 Like

Waiting for 2 or 3 chunks shouldn’t slow it down too much, no? Yes, it’s a pain, but the way tokens work, I don’t think it’ll be changing… unless they started handling it on THEIR end with all their processing power and send bigger but complete chunks.

Might be good to recategorize this thread as API-Feedback? Let me know.

imo the best way to do this is to abandon html formatting altogether and just let the API send you the content in markdown format. then in the client side, use some markdown library to display the output.

2 Likes

If we choose to use Markdown, it still requires combining chunks. Right? As an example, if the md returns “*** bold ***” in multiple chunks, is it possible to convert into HTML without combining chunks?

1 Like

It could be the same problem, but it might happen less?

A thread in API-Feedback might be good to see how many others are frustrated with parsing it on their own…

1 Like

Agree with re-categorizing this . The application buffers versus the API does html blocking when streaming seems to be the main choices.

1 Like

I’m ok with re-categorizing this issue. Thanks for all the feedback.

1 Like

The squeaky wheel gets the grease, as they say, so if more devs see this and agree that getting larger chunks but not split is important, it might affect change? No promises, but you never know…

In the meantime, let us know if you come up with any better ways to deal with it as I’m sure a lot of other devs are banging their heads too!

1 Like

check this video to demonstrate what will happen. you can briefly see the transition. if that is okay for you then it can be a solution.

short video demo

1 Like

That’s a reasonable solution. In terms of implementation, I’m trying to see how to combine the chunks on the client side.

function readStreamMarkdown(reader, decoder, buffer, markdownBuffer) {
        reader.read().then(({ done, value }) => {
            if (done) {
                console.log('Stream complete');
                // Process any remaining buffered Markdown
                if (markdownBuffer) {
                    const htmlContent = marked(markdownBuffer);
                    resultDisplay.insertAdjacentHTML('beforeend', htmlContent);
                }
                return;
            }

            buffer += decoder.decode(value, { stream: true });

            const events = buffer.split('\n\n');
            buffer = events.pop(); // Keep the last incomplete event in the buffer

            events.forEach(event => {
                if (event.startsWith('data: ')) {
                    let rawData = event.slice(6).trim();
                    if (rawData === '[END]') {
                        // Process any remaining buffered Markdown
                        if (markdownBuffer) {
                            const htmlContent = marked(markdownBuffer);
                            resultDisplay.insertAdjacentHTML('beforeend', htmlContent);
                            markdownBuffer = '';
                        }
                        return;
                    }

                    // Buffering Markdown data
                    markdownBuffer += rawData;

                    
                    if (markdownBuffer.endsWith('\n')) {
                        const htmlContent = marked(markdownBuffer);
                        resultDisplay.insertAdjacentHTML('beforeend', htmlContent);
                        markdownBuffer = '';
                    }
                }
            });
            readStreamMarkdown(reader, decoder, buffer, markdownBuffer); // Start reading the stream
            //readStreamMarkdown(); // Continue reading next chunk
        }).catch(error => {
            console.error('Error reading stream:', error);
            displayError("An error occurred while retrieving your feedback. Please try again.");
        });
    }

However, it is not hitting the “\n” to flush the buffer. Any other ways to check if the markdown is complete before adding it to the DOM?

1 Like

i am not checking anything. i just send all the text chunks directly to the markdown component as i receive it.

1 Like

Is it possible to share the code snippet? When I use marked.parse() with the raw data it is always adding

around it.

i am using react/node.js but you can see i am not doing anything to the output. i just pass it as is and let the markdown component do its thing.

Backend

for await (const event of stream) {

if(event.event === 'thread.message.delta') {
   // send to client
  controller.enqueue(event.data.delta.content[0].text.value)
} else {
...
}

Frontend handler

const reader = response.body.getReader()

// create new asst message
const assistantId = SimpleId()

const assistant_message = {
    id: assistantId,
    role: 'assistant',
    createdAt: Date.now(),
    content: '',
}
    
setMessageItems((prev) => [...prev, ...[assistant_message]])

let is_completed = false

while(!is_completed) {

    const { done, value } = await reader.read()
  
    if(done) {
        break
    }

    const raw_delta = new TextDecoder().decode(value)

    // update asst message
    setMessageItems(prev =>
      prev.map(a => {
        if (a.id === assistantId) {
          a.content += raw_delta
        }
        return a
      })
    )

}

Display

import Markdown from 'react-markdown'

<div className={classes.messages}>
  {
      messageItems.map((msg, index) => {
          return (
              <div key={msg.id} className={classes.content}>
      			<Markdown>
      			{ message.content }
      			</Markdown>
  			</div>
          )
      })
  }
  </div>
1 Like

In this example, I’m still unclear how the markdown is converted into HTML format.

The react-markdown library transforms the markdown content into HTML. It also offers the option to override the settings so you can implement your own tags.

But, you aren’t using React and can’t benefit from this library, or the features that React brings to make this even possible without some serious work.

This IMO is the best overall solution. However the only difference is that I would have the buffer on the client side.

Since you are expecting HTML tags you can simply keep a buffer that holds the content until you get a closing tag. This is conceptually the exact same as parsing a streaming JSON object.