Laravel OpenAI Streaming (SSE) returns broken words instead of full sentences

I am using Laravel and OpenAI to generate blog content in real-time using Server-Sent Events (SSE). However, instead of full sentences, the output is coming in broken words, which makes the generated text unreadable.

For example, instead of:

"Freelance work is a great way to become independent."

I get:

"Free lan ce work is a gre at way to be come in dep end ent."

It seems like the response is not properly handling tokenized streaming, causing words to be split incorrectly.

What I Have Tried

1.Ensured correct SSE headers

  • My Laravel response already includes:
return response()->stream(function () { ... }, 200, [
    'Content-Type' => 'text/event-stream',
    'Cache-Control' => 'no-cache',
    'Connection' => 'keep-alive',
    'X-Accel-Buffering' => 'no',
]);

Current Laravel Controeller:

<?php

namespace AppHttpControllersDashboardBlogAI;

use AppHttpControllersController;
use IlluminateHttpRequest;
use IlluminateSupportFacadesLog;
use AppServicesOpenAIService;
use AppModelsAIDocument;
use AppModelsAIBlog;

class AIWriterController extends Controller
{
    protected $openAIService;

    public function __construct(OpenAIService $openAIService)
    {
        $this->openAIService = $openAIService;
    }

    public function index()
    {
        Log::info("AIWriterController@index çağrıldı.");

        $models = config('openai.models');
        $tones = config('openai.tones');
        $languages = config('languages.supported');
        $settings = $this->openAIService->getSettings();

        return view('dashboard.blog.ai.index', compact('models', 'tones', 'languages', 'settings'));
    }

    public function generate(Request $request)
    {
        $validated = $request->validate([
            'article_title' => 'required|string',
            'focus_keywords' => 'nullable|string',
            'exclude_keywords' => 'nullable|string',
            'default_language' => 'required|string',
            'default_tone' => 'required|string',
            'max_words' => 'required|integer|min:50|max:2000',
        ]);

        $prompt = "Başlık: " . $validated['article_title'] . "n";
        $prompt .= "Anahtar Kelimeler: " . $validated['focus_keywords'] . "n";
        $prompt .= "Hariç Tutulacak: " . $validated['exclude_keywords'] . "n";
        $prompt .= "Ton: " . $validated['default_tone'] . "n";
        $prompt .= "Dil: " . $validated['default_language'] . "n";

        return $this->openAIService->generateStreamedText($prompt, $validated['max_words']);
    }

    public function save(Request $request)
    {
        Log::info("save metodu çalıştırıldı.", $request->all());

        $validated = $request->validate([
            'title' => 'required|string',
            'description' => 'required|string',
        ]);

        Log::info("save için doğrulama tamamlandı.", $validated);

        AIDocument::create([
            'title' => $request->title,
            'content' => $request->description,
            'user_id' => auth()->id(),
        ]);

        Log::info("Makale başarıyla kaydedildi.");

        return response()->json(['message' => 'Makale başarıyla kaydedildi.']);
    }

    public function publish(Request $request)
    {
        Log::info("publish metodu çalıştırıldı.", $request->all());

        $validated = $request->validate([
            'title' => 'required|string',
            'description' => 'required|string',
        ]);

        Log::info("publish için doğrulama tamamlandı.", $validated);

        AIBlog::create([
            'title' => $request->title,
            'content' => $request->description,
            'user_id' => auth()->id(),
            'status' => 'published',
        ]);

        Log::info("Makale başarıyla yayınlandı.");

        return response()->json(['message' => 'Makale başarıyla yayınlandı.']);
    }
}

My service file:

<?php

namespace AppServices;

use GuzzleHttpClient;
use IlluminateSupportFacadesLog;
use SymfonyComponentHttpFoundationStreamedResponse;
use AppServicesOpenAISettingsService;

class OpenAIService
{
    protected $client;
    protected $settingsService;
    protected $settings;

    public function __construct(OpenAISettingsService $settingsService)
    {
        $this->client = new Client();
        $this->settingsService = $settingsService;
        $this->settings = $this->settingsService->getSettings();
    }

    public function getSettings()
    {
        return $this->settings;
    }

    public function generateStreamedText($prompt, $maxTokens)
    {
        if (!$this->settings->api_key) {
            Log::error("OpenAI API anahtarı eksik!");
            return response()->json(['error' => 'OpenAI API anahtarı eksik!'], 400);
        }

        try {
            $jsonData = [
                'model'  => $this->settings->default_model,
                'messages' => [
                    ['role' => 'system', 'content' => 'Sen bir blog yazarı asistansın.'],
                    ['role' => 'user', 'content' => $prompt],
                ],
                'max_tokens' => (int) $maxTokens,
                'temperature' => 0.7,
                'stream' => true,
            ];

            $response = $this->client->post('https://api.openai.com/v1/chat/completions', [
                'headers' => [
                    'Authorization' => "Bearer " . $this->settings->api_key,
                    'Content-Type'  => 'application/json',
                ],
                'json' => $jsonData,
                'stream' => true,
            ]);

            return response()->stream(function () use ($response) {
                header('Content-Type: text/event-stream');
                header('Cache-Control: no-cache');
                header('Connection: keep-alive');
                header('X-Accel-Buffering: no');

                $buffer = "";
                $body = $response->getBody();
                while (!$body->eof()) {
                    $chunk = trim($body->read(4096));

                    if (!empty($chunk)) {
                        $lines = explode("n", $chunk);
                        foreach ($lines as $line) {
                            if (strpos($line, "data: ") === 0) {
                                $json = json_decode(substr($line, 5), true);
                                if (isset($json['choices'][0]['delta']['content'])) {
                                    $text = trim($json['choices'][0]['delta']['content']);

                                    $buffer .= $text;

                                    if (preg_match('/s$/', $text) || mb_strlen($buffer) > 6) {
                                        echo "event: updaten";
                                        echo "data: " . $buffer . "nn";
                                        ob_flush();
                                        flush();
                                        $buffer = "";
                                    }
                                }
                            }
                        }
                    }
                }

                echo "event: updaten";
                echo "data: <END_STREAMING_SSE>nn";
                ob_flush();
                flush();
            }, 200, [
                'Content-Type' => 'text/event-stream',
                'Cache-Control' => 'no-cache',
                'Connection' => 'keep-alive',
                'X-Accel-Buffering' => 'no',
            ]);
        } catch (Exception $e) {
            Log::error("OpenAI API Hatası: " . $e->getMessage());
            return response()->json(['error' => 'API isteğinde hata oluştu.'], 500);
        }
    }
}

My js code (included in blade)

<script>
    document.addEventListener("DOMContentLoaded", function () {
        console.log("JavaScript yüklendi ve çalışıyor!");

        document.getElementById('generate-blog').addEventListener('click', function() {
            console.log("Blog oluşturma butonuna basıldı!");

            let formData = new FormData(document.getElementById('ai-blog-form'));
            let outputDiv = document.getElementById('streamedData');
            outputDiv.innerHTML = ""; // Önceki içeriği temizle

            fetch("{{ route('dashboard.blog.ai.generate') }}", {
                method: "POST",
                body: formData,
                headers: {
                    "X-CSRF-TOKEN": document.querySelector('meta[name="csrf-token"]').getAttribute("content")
                }
            })
            .then(response => {
                console.log("Fetch response geldi:", response);
                if (!response.ok) {
                    throw new Error(`HTTP error! Status: ${response.status}`);
                }
                return response.body.getReader();
            })
            .then(reader => {
                let decoder = new TextDecoder();

                function readStream() {
                    reader.read().then(({ done, value }) => {
                        if (done) {
                            console.log("Stream tamamlandı.");
                            return;
                        }

                        let text = decoder.decode(value, { stream: true });
                        console.log("Gelen veri:", text);

                        let lines = text.split("n");

                        lines.forEach(line => {
                            if (line.startsWith("data: ")) {
                                try {
                                    let kelime = line.replace("data: ", "").trim();
                                    // Her parçadan sonra boşluk ekleyerek yazıyoruz
                                    outputDiv.innerHTML += kelime + " ";
                                    console.log("Ekrana yazılan kelime:", kelime);
                                } catch (e) {
                                    console.error("JSON parse hatası:", e, line);
                                }
                            }
                        });

                        readStream();
                    }).catch(error => console.error("Stream okuma hatası:", error));
                }

                readStream();
            })
            .catch(error => console.error("Fetch hatası:", error));
        });
    });
</script>

Routes:

Route::prefix('blog/ai')->group(function () {
        Route::get('/', [AIWriterController::class, 'index'])->name('dashboard.blog.ai.index');
        Route::post('/generate', [AIWriterController::class, 'generate'])->name('dashboard.blog.ai.generate');
        Route::post('/save', [AIWriterController::class, 'save'])->name('dashboard.blog.ai.save');
        Route::post('/publish', [AIWriterController::class, 'publish'])->name('dashboard.blog.ai.publish');
    });