/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { SSE } from 'sse.js'
import { getFreshAuthHeaders } from '@/lib/api'
import { API_ENDPOINTS, ERROR_MESSAGES } from '../constants'
import {
getStreamReadyStateError,
isStreamClosedReadyState,
isStreamDoneMessage,
parseStreamErrorDetails,
parseStreamMessageUpdates,
} from '../lib'
import type { ChatCompletionRequest } from '../types'
interface StreamEventSource {
readyState?: number
addEventListener: (
type: string,
listener: (event: Event & { data?: string; readyState?: number }) => void
) => void
close: () => void
stream: () => void
}
interface StreamRequestCallbacks {
onUpdate: (type: 'reasoning' | 'content', chunk: string) => void
onComplete: () => void
onError: (error: string, errorCode?: string) => void
}
interface StreamRequestControllerRuntime {
getHeaders: () => Promise>
createSource: (
payload: ChatCompletionRequest,
headers: Record
) => StreamEventSource
setStreaming: (streaming: boolean) => void
}
export function createStreamRequestController(
runtime: StreamRequestControllerRuntime
) {
let source: StreamEventSource | null = null
let generation = 0
const closeActiveSource = (target: StreamEventSource) => {
target.close()
if (source === target) {
source = null
runtime.setStreaming(false)
}
}
const send = async (
payload: ChatCompletionRequest,
callbacks: StreamRequestCallbacks
) => {
const requestGeneration = generation + 1
generation = requestGeneration
const previousSource = source
source = null
previousSource?.close()
runtime.setStreaming(false)
let headers: Record
try {
headers = await runtime.getHeaders()
} catch (error: unknown) {
if (generation !== requestGeneration) return
callbacks.onError(
error instanceof Error
? error.message
: ERROR_MESSAGES.STREAM_START_ERROR
)
return
}
if (generation !== requestGeneration) return
const nextSource = runtime.createSource(payload, headers)
source = nextSource
runtime.setStreaming(true)
let completed = false
const isCurrent = () =>
generation === requestGeneration && source === nextSource
const handleError = (errorMessage: string, errorCode?: string) => {
if (!isCurrent() || completed) return
completed = true
callbacks.onError(errorMessage, errorCode)
closeActiveSource(nextSource)
}
nextSource.addEventListener('message', (event) => {
if (!isCurrent() || completed) return
const data = event.data ?? ''
if (isStreamDoneMessage(data)) {
completed = true
closeActiveSource(nextSource)
callbacks.onComplete()
return
}
try {
const updates = parseStreamMessageUpdates(data)
for (const update of updates) {
callbacks.onUpdate(update.type, update.chunk)
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to parse SSE message:', error)
handleError(ERROR_MESSAGES.PARSE_ERROR)
}
})
nextSource.addEventListener('error', (event) => {
if (!isCurrent() || completed) return
if (!isStreamClosedReadyState(nextSource.readyState)) {
// eslint-disable-next-line no-console
console.error('SSE Error:', event)
const { errorCode, errorMessage } = parseStreamErrorDetails(event.data)
handleError(errorMessage, errorCode)
}
})
nextSource.addEventListener('readystatechange', (event) => {
if (!isCurrent() || completed) return
const errorMessage = getStreamReadyStateError(
event.readyState,
nextSource
)
if (errorMessage) {
handleError(errorMessage)
}
})
try {
if (!isCurrent()) return
nextSource.stream()
} catch (error: unknown) {
if (!isCurrent() || completed) return
// eslint-disable-next-line no-console
console.error('Failed to start SSE stream:', error)
handleError(ERROR_MESSAGES.STREAM_START_ERROR)
}
}
const cancel = (notify: boolean) => {
generation += 1
const activeSource = source
source = null
activeSource?.close()
if (notify) runtime.setStreaming(false)
}
const stop = () => cancel(true)
const dispose = () => cancel(false)
return { send, stop, dispose }
}
/**
* Hook for handling streaming chat completion requests
*/
export function useStreamRequest() {
const [isStreaming, setIsStreaming] = useState(false)
const controllerRef = useRef | null>(null)
if (!controllerRef.current) {
controllerRef.current = createStreamRequestController({
getHeaders: getFreshAuthHeaders,
createSource: (payload, headers) =>
new SSE(API_ENDPOINTS.CHAT_COMPLETIONS, {
headers,
method: 'POST',
payload: JSON.stringify(payload),
}) as StreamEventSource,
setStreaming: setIsStreaming,
})
}
const sendStreamRequest = useCallback(
(
payload: ChatCompletionRequest,
onUpdate: (type: 'reasoning' | 'content', chunk: string) => void,
onComplete: () => void,
onError: (error: string, errorCode?: string) => void
) =>
controllerRef.current?.send(payload, {
onUpdate,
onComplete,
onError,
}),
[]
)
const stopStream = useCallback(() => {
controllerRef.current?.stop()
}, [])
useEffect(
() => () => {
controllerRef.current?.dispose()
},
[]
)
return {
sendStreamRequest,
stopStream,
isStreaming,
}
}