11import { createContext , useCallback , useContext , useEffect , useRef , useState } from 'react' ;
22import type { ReactNode } from 'react' ;
3- import { streamChat , appendChatEvent , getLoadingStatus } from '../lib/api' ;
3+ import {
4+ getCurrentChatRun ,
5+ getLoadingStatus ,
6+ startChatRun ,
7+ stopCurrentChatRun ,
8+ subscribeToChatRun ,
9+ } from '../lib/api' ;
410import { useConversations } from './ConversationContext' ;
5- import type { ChatMessage } from '../types/ui' ;
11+ import type { ChatMessage , ChatRunSnapshot , ChatRunStreamEvent } from '../types/ui' ;
612
713export type StreamEntry = {
814 streaming : boolean ;
915 streamingContent : string ;
1016 loadingPhase : string ;
1117 loadingProgress : number ;
12- layersOnGpu : number ;
18+ layersOnRpc : number ;
1319} ;
1420
1521export type StreamToast = {
@@ -24,7 +30,7 @@ const emptyEntry: StreamEntry = {
2430 streamingContent : '' ,
2531 loadingPhase : '' ,
2632 loadingProgress : 0 ,
27- layersOnGpu : 0 ,
33+ layersOnRpc : 0 ,
2834} ;
2935
3036export type StartStreamParams = {
@@ -47,22 +53,29 @@ type ChatStreamingContextValue = {
4753
4854const ChatStreamingContext = createContext < ChatStreamingContextValue | null > ( null ) ;
4955
56+ function isRunActive ( snapshot : ChatRunSnapshot ) : boolean {
57+ return snapshot . status === 'starting' || snapshot . status === 'streaming' ;
58+ }
59+
5060export function ChatStreamingProvider ( { children } : { children : ReactNode } ) {
51- const { appendMessage, updateLastMessage, activeId } = useConversations ( ) ;
61+ const {
62+ conversations,
63+ appendMessages,
64+ upsertAssistantMessage,
65+ activeId,
66+ } = useConversations ( ) ;
5267 const [ streams , setStreams ] = useState < Record < string , StreamEntry > > ( { } ) ;
5368 const [ nodeCount , setNodeCount ] = useState ( 0 ) ;
5469 const [ toasts , setToasts ] = useState < StreamToast [ ] > ( [ ] ) ;
55- const abortRefs = useRef < Record < string , AbortController > > ( { } ) ;
56- // Ref so async stream callbacks always see the current activeId without stale closure
70+ const sourcesRef = useRef < Record < string , EventSource > > ( { } ) ;
71+ const hydratedRef = useRef < Set < string > > ( new Set ( ) ) ;
72+ const hydrationPromiseRef = useRef < Record < string , Promise < ChatRunSnapshot | null > > > ( { } ) ;
5773 const activeIdRef = useRef < string | null > ( activeId ) ;
74+
5875 useEffect ( ( ) => {
5976 activeIdRef . current = activeId ;
60- if ( activeId ) {
61- setToasts ( ( prev ) => prev . filter ( ( t ) => t . convId !== activeId ) ) ;
62- }
6377 } , [ activeId ] ) ;
6478
65- // Always-on slow poll for node count
6679 useEffect ( ( ) => {
6780 const fetch = ( ) => {
6881 void getLoadingStatus ( ) . then ( ( s ) => setNodeCount ( s . node_count ) ) . catch ( ( ) => undefined ) ;
@@ -79,122 +92,143 @@ export function ChatStreamingProvider({ children }: { children: ReactNode }) {
7992 } ) ) ;
8093 } , [ ] ) ;
8194
82- const stopStream = useCallback ( ( convId : string ) => {
83- abortRefs . current [ convId ] ?. abort ( ) ;
95+ const applySnapshot = useCallback ( ( convId : string , snapshot : ChatRunSnapshot ) => {
96+ upsertAssistantMessage ( convId , snapshot . assistant_content ) ;
97+ patch ( convId , {
98+ streaming : isRunActive ( snapshot ) ,
99+ streamingContent : snapshot . assistant_content ,
100+ loadingPhase : snapshot . loading_phase ,
101+ loadingProgress : snapshot . loading_progress ,
102+ layersOnRpc : snapshot . layers_on_gpu ,
103+ } ) ;
104+ } , [ patch , upsertAssistantMessage ] ) ;
105+
106+ const closeSource = useCallback ( ( convId : string ) => {
107+ sourcesRef . current [ convId ] ?. close ( ) ;
108+ delete sourcesRef . current [ convId ] ;
109+ } , [ ] ) ;
110+
111+ const attachSource = useCallback ( ( convId : string , convTitle : string , model : string ) => {
112+ closeSource ( convId ) ;
113+ const source = subscribeToChatRun (
114+ convId ,
115+ ( event : ChatRunStreamEvent ) => {
116+ applySnapshot ( convId , event . snapshot ) ;
117+ if ( ! isRunActive ( event . snapshot ) ) {
118+ closeSource ( convId ) ;
119+ if ( event . snapshot . status === 'completed' && activeIdRef . current !== convId ) {
120+ setToasts ( ( prev ) => [
121+ ...prev ,
122+ { id : crypto . randomUUID ( ) , convId, convTitle, model } ,
123+ ] ) ;
124+ }
125+ }
126+ } ,
127+ ( ) => {
128+ closeSource ( convId ) ;
129+ }
130+ ) ;
131+ sourcesRef . current [ convId ] = source ;
132+ } , [ applySnapshot , closeSource ] ) ;
133+
134+ const hydrateRun = useCallback ( ( convId : string , convTitle : string , model : string ) : Promise < ChatRunSnapshot | null > => {
135+ const existing = hydrationPromiseRef . current [ convId ] ;
136+ if ( existing ) return existing ;
137+
138+ const pending = getCurrentChatRun ( convId )
139+ . then ( ( snapshot ) => {
140+ applySnapshot ( convId , snapshot ) ;
141+ if ( isRunActive ( snapshot ) ) {
142+ attachSource ( convId , convTitle , model ) ;
143+ }
144+ return snapshot ;
145+ } )
146+ . catch ( ( ) => null )
147+ . finally ( ( ) => {
148+ delete hydrationPromiseRef . current [ convId ] ;
149+ } ) ;
150+
151+ hydrationPromiseRef . current [ convId ] = pending ;
152+ return pending ;
153+ } , [ applySnapshot , attachSource ] ) ;
154+
155+ useEffect ( ( ) => {
156+ conversations . forEach ( ( conv ) => {
157+ if ( hydratedRef . current . has ( conv . id ) ) return ;
158+ hydratedRef . current . add ( conv . id ) ;
159+ void hydrateRun ( conv . id , conv . title , conv . model ) ;
160+ } ) ;
161+ } , [ conversations , hydrateRun ] ) ;
162+
163+ useEffect ( ( ) => ( ) => {
164+ Object . values ( sourcesRef . current ) . forEach ( ( source ) => source . close ( ) ) ;
165+ sourcesRef . current = { } ;
84166 } , [ ] ) ;
85167
168+ const stopStream = useCallback ( ( convId : string ) => {
169+ void stopCurrentChatRun ( convId )
170+ . then ( async ( ) => {
171+ const snapshot = await getCurrentChatRun ( convId ) ;
172+ applySnapshot ( convId , snapshot ) ;
173+ } )
174+ . catch ( ( ) => undefined )
175+ . finally ( ( ) => {
176+ closeSource ( convId ) ;
177+ } ) ;
178+ } , [ applySnapshot , closeSource ] ) ;
179+
86180 const dismissToast = useCallback ( ( id : string ) => {
87181 setToasts ( ( prev ) => prev . filter ( ( t ) => t . id !== id ) ) ;
88182 } , [ ] ) ;
89183
90184 const startStream = useCallback ( ( {
91185 convId, model, content, prevMessages, thinkingEnabled, convTitle,
92186 } : StartStreamParams ) => {
93- abortRefs . current [ convId ] ?. abort ( ) ;
94- const controller = new AbortController ( ) ;
95- abortRefs . current [ convId ] = controller ;
96-
97- appendMessage ( convId , { role : 'user' , content } ) ;
98- appendMessage ( convId , { role : 'assistant' , content : '' } ) ;
99-
100- void appendChatEvent ( convId , {
101- event_type : 'message_sent' ,
102- role : 'user' ,
103- content,
104- timestamp : new Date ( ) . toISOString ( ) ,
105- } ) . catch ( ( ) => undefined ) ;
106-
107- patch ( convId , { streaming : true , streamingContent : '' , loadingPhase : '' , loadingProgress : 0 } ) ;
108-
109- const history : ChatMessage [ ] = [
110- ...( ! thinkingEnabled ? [ { role : 'system' as const , content : '/no_think' } ] : [ ] ) ,
111- ...prevMessages ,
112- { role : 'user' as const , content } ,
113- ] ;
187+ closeSource ( convId ) ;
114188
115189 void ( async ( ) => {
116- let assistantContent = '' ;
117- let firstTokenTime : number | null = null ;
118- let tokenCount = 0 ;
119-
120- // Poll loading phase until first token arrives
121- const phaseInterval = setInterval ( ( ) => {
122- if ( assistantContent !== '' ) { clearInterval ( phaseInterval ) ; return ; }
123- void getLoadingStatus ( ) . then ( ( s ) => {
124- if ( assistantContent === '' ) {
125- patch ( convId , { loadingPhase : s . phase , loadingProgress : s . progress , layersOnGpu : s . layers_on_gpu } ) ;
126- }
127- } ) . catch ( ( ) => undefined ) ;
128- } , 1200 ) ;
190+ const existingRun = await hydrateRun ( convId , convTitle , model ) ;
191+ if ( existingRun && isRunActive ( existingRun ) ) {
192+ return ;
193+ }
129194
130- const attemptStream = async ( isRetry : boolean ) : Promise < boolean > => {
131- if ( isRetry ) {
132- await new Promise ( ( r ) => setTimeout ( r , 2000 ) ) ;
133- if ( controller . signal . aborted ) return false ;
134- }
135- for await ( const token of streamChat ( history , model , controller . signal ) ) {
136- if ( firstTokenTime === null ) firstTokenTime = Date . now ( ) ;
137- tokenCount += 1 ;
138- assistantContent += token ;
139- patch ( convId , { streamingContent : assistantContent , loadingPhase : '' } ) ;
140- }
141- return true ;
142- } ;
143-
144- const finalize = ( tps ?: number ) => {
145- updateLastMessage ( convId , ( ) => assistantContent , tps ) ;
146- void appendChatEvent ( convId , {
147- event_type : 'message_completed' ,
148- role : 'assistant' ,
149- content : assistantContent ,
150- timestamp : new Date ( ) . toISOString ( ) ,
151- } ) . catch ( ( ) => undefined ) ;
152- if ( activeIdRef . current !== convId ) {
153- setToasts ( ( prev ) => [ ...prev , {
154- id : crypto . randomUUID ( ) , convId, convTitle, model,
155- } ] ) ;
156- }
157- } ;
195+ appendMessages ( convId , [
196+ { role : 'user' , content } ,
197+ { role : 'assistant' , content : '' } ,
198+ ] ) ;
199+ patch ( convId , { streaming : true , streamingContent : '' , loadingPhase : '' , loadingProgress : 0 , layersOnRpc : 0 } ) ;
158200
159- const calcTps = ( ) =>
160- firstTokenTime !== null && tokenCount > 0
161- ? tokenCount / ( ( Date . now ( ) - firstTokenTime ) / 1000 )
162- : undefined ;
201+ const messages : Array < Pick < ChatMessage , 'role' | 'content' > > = [
202+ ... prevMessages . map ( ( message ) => ( { role : message . role , content : message . content } ) ) ,
203+ { role : 'user' as const , content } ,
204+ ] ;
163205
164206 try {
165- let ok = await attemptStream ( false ) ;
166- if ( ! ok ) throw new DOMException ( 'Aborted' , 'AbortError' ) ;
167- if ( assistantContent === '' ) {
168- ok = await attemptStream ( true ) ;
169- if ( ! ok ) throw new DOMException ( 'Aborted' , 'AbortError' ) ;
170- }
171- finalize ( calcTps ( ) ) ;
172- } catch ( err ) {
173- if ( ( err as Error ) . name !== 'AbortError' ) {
174- try {
175- await attemptStream ( true ) ;
176- finalize ( calcTps ( ) ) ;
177- } catch ( retryErr ) {
178- if ( ( retryErr as Error ) . name !== 'AbortError' ) {
179- updateLastMessage ( convId , ( prev ) => prev || '_(Error: could not reach model server)_' ) ;
180- void appendChatEvent ( convId , {
181- event_type : 'stream_error' ,
182- error : ( retryErr as Error ) . message ,
183- timestamp : new Date ( ) . toISOString ( ) ,
184- } ) . catch ( ( ) => undefined ) ;
185- }
186- }
187- }
188- } finally {
189- clearInterval ( phaseInterval ) ;
190- patch ( convId , { streaming : false , streamingContent : '' , loadingPhase : '' } ) ;
191- delete abortRefs . current [ convId ] ;
207+ const snapshot = await startChatRun ( convId , {
208+ model,
209+ messages,
210+ thinking_enabled : thinkingEnabled ,
211+ } ) ;
212+ applySnapshot ( convId , snapshot ) ;
213+ attachSource ( convId , convTitle , model ) ;
214+ } catch {
215+ upsertAssistantMessage ( convId , '_(Error: could not reach model server)_' ) ;
216+ patch ( convId , { streaming : false , loadingPhase : '' , loadingProgress : 0 } ) ;
192217 }
193218 } ) ( ) ;
194- } , [ appendMessage , updateLastMessage , patch ] ) ;
219+ } , [ appendMessages , attachSource , applySnapshot , closeSource , hydrateRun , patch , upsertAssistantMessage ] ) ;
195220
196221 return (
197- < ChatStreamingContext . Provider value = { { streams, nodeCount, toasts, startStream, stopStream, dismissToast } } >
222+ < ChatStreamingContext . Provider
223+ value = { {
224+ streams,
225+ nodeCount,
226+ toasts : activeId ? toasts . filter ( ( toast ) => toast . convId !== activeId ) : toasts ,
227+ startStream,
228+ stopStream,
229+ dismissToast,
230+ } }
231+ >
198232 { children }
199233 </ ChatStreamingContext . Provider >
200234 ) ;
0 commit comments