packages feed

llm-simple-0.1.0.1: src/LLM/Core/SSE.hs

module LLM.Core.SSE (SSEEvent (..), readSSEEvents) where

import Control.Monad (unless)
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Char8 qualified as BC
import Data.IORef (newIORef, readIORef, writeIORef)
import Data.Text (Text)
import Data.Text.Encoding qualified as TE

data SSEEvent = SSEEvent
  { sseEvent :: Maybe Text,
    sseData :: Text
  }
  deriving (Show)

-- | Read Server-Sent Events from a streaming body reader.
-- Calls the callback for each complete event. Returns when the stream ends.
readSSEEvents :: IO ByteString -> (SSEEvent -> IO ()) -> IO ()
readSSEEvents readChunk callback = do
  bufRef <- newIORef BS.empty
  let readLine :: IO (Maybe ByteString)
      readLine = do
        buf <- readIORef bufRef
        case BC.elemIndex '\n' buf of
          Just i -> do
            let (line, rest) = BS.splitAt i buf
                line' =
                  if not (BS.null line) && BC.last line == '\r'
                    then BS.init line
                    else line
            writeIORef bufRef (BS.drop 1 rest)
            pure (Just line')
          Nothing -> do
            chunk <- readChunk
            if BS.null chunk
              then
                if BS.null buf
                  then pure Nothing
                  else do
                    writeIORef bufRef BS.empty
                    pure (Just buf)
              else do
                writeIORef bufRef (buf <> chunk)
                readLine

      loop :: Maybe Text -> [ByteString] -> IO ()
      loop eventType dataLines = do
        mLine <- readLine
        case mLine of
          Nothing ->
            unless (null dataLines) $
              fireEvent eventType dataLines
          Just line
            | BS.null line -> do
                unless (null dataLines) $
                  fireEvent eventType dataLines
                loop Nothing []
            | "data:" `BS.isPrefixOf` line ->
                loop eventType (stripField 5 line : dataLines)
            | "event:" `BS.isPrefixOf` line ->
                loop (Just (TE.decodeUtf8 (stripField 6 line))) dataLines
            | ":" `BS.isPrefixOf` line ->
                loop eventType dataLines -- comment, skip
            | otherwise ->
                loop eventType dataLines

      stripField :: Int -> ByteString -> ByteString
      stripField n bs =
        let rest = BS.drop n bs
         in if not (BS.null rest) && BC.head rest == ' '
              then BS.tail rest
              else rest

      fireEvent :: Maybe Text -> [ByteString] -> IO ()
      fireEvent eventType dataLines =
        callback
          SSEEvent
            { sseEvent = eventType,
              sseData = TE.decodeUtf8 (BS.intercalate "\n" (reverse dataLines))
            }
  loop Nothing []