55
66from fastapi import APIRouter , HTTPException , Path , Request
77from fastapi .responses import StreamingResponse
8- from langchain_core .messages import HumanMessage
98from langchain_core .runnables import RunnableConfig
109from loguru import logger
1110from pydantic import BaseModel , Field
1716 get_source_or_404 ,
1817 get_verified_source_session ,
1918)
19+ from api .source_chat_service import source_chat_turn
2020from open_notebook .database .repository import ensure_record_id , repo_query
2121from open_notebook .domain .notebook import ChatSession
2222from open_notebook .exceptions import (
3737# generation promptly instead of waiting for the next SSE comment interval.
3838DISCONNECT_POLL_INTERVAL_SECONDS = 1.0
3939
40- # Per-session locks serialize the read-modify-write sequence (snapshot -> append
41- # user message -> invoke) in `stream_source_chat_response`. Without them, two
42- # concurrent requests for the same thread could both read the same trailing
43- # message and each start a generation. Created lazily; refcounted so the entry is
44- # evicted once the last holder releases — a long-lived process must not keep one
45- # lock per session it has ever seen.
46- class _SessionLock :
47- __slots__ = ("lock" , "holders" )
48-
49- def __init__ (self ) -> None :
50- self .lock = asyncio .Lock ()
51- self .holders = 0
52-
53-
54- _session_locks : dict [str , _SessionLock ] = {}
55-
56-
57- def _get_session_lock (session_id : str ) -> _SessionLock :
58- # No `await` between these dict ops, so on a single event loop the
59- # read/create/increment is atomic. Registering the caller as a holder before
60- # it awaits `acquire` keeps the entry alive until it releases.
61- entry = _session_locks .get (session_id )
62- if entry is None :
63- entry = _SessionLock ()
64- _session_locks [session_id ] = entry
65- entry .holders += 1
66- return entry
67-
68-
69- def _release_session_lock (session_id : str , entry : _SessionLock ) -> None :
70- entry .lock .release ()
71- entry .holders -= 1
72- # The `is entry` guard is defensive: the entry is only evicted when this was
73- # the last holder, so `session_id` must still map to this same entry.
74- if entry .holders == 0 and _session_locks .get (session_id ) is entry :
75- _session_locks .pop (session_id , None )
76-
7740
7841# Request/Response models
7942class CreateSourceChatSessionRequest (BaseModel ):
@@ -396,95 +359,83 @@ async def stream_source_chat_response(
396359 config = RunnableConfig (
397360 configurable = {"thread_id" : session_id , "model_id" : model_override }
398361 )
399- invoke_task : Optional [asyncio .Task ] = None
400- # Serialize snapshot -> append -> invoke per session. Two concurrent requests
401- # for the same thread would otherwise both read the same trailing message and
402- # each start a generation. Held for the whole stream; released in finally.
403- lock_entry = _get_session_lock (session_id )
404- acquired = False
405362 try :
406- await lock_entry .lock .acquire ()
407- acquired = True
408- # Persist the user message to the checkpoint up front so it survives a
409- # mid-generation disconnect (the frontend refetches the checkpoint on
410- # cancel/complete and would otherwise drop the user's message). Skip the
411- # append when this turn is already the trailing (unanswered) one. The
412- # guard keys on the client message id, not content: a retry that reuses
413- # the same id is deduplicated, while two distinct identical messages get
414- # distinct ids and are both kept. A completed exchange always ends with an
415- # AI message, so a trailing human turn is necessarily still pending.
416- current_state = await asyncio .to_thread (
417- source_chat_graph .get_state , config = config
418- )
419- already_pending = False
420- if current_state and current_state .values and "messages" in current_state .values :
421- existing_messages = current_state .values ["messages" ]
422- last_message = existing_messages [- 1 ] if existing_messages else None
423- already_pending = (
424- isinstance (last_message , HumanMessage )
425- and message_id is not None
426- and getattr (last_message , "id" , None ) == message_id
427- )
428- if not already_pending :
429- await source_chat_graph .aupdate_state (
430- config , {"messages" : [HumanMessage (content = message , id = message_id )]}
431- )
432-
433- # Send user message event
434- user_event = {"type" : "user_message" , "content" : message , "timestamp" : None }
435- yield f"data: { json .dumps (user_event )} \n \n "
436-
437- # Run the async graph with ainvoke so generation is cancellable. Only the
438- # per-message config is passed as input; the messages (incl. the user
439- # message above) are read from the checkpoint. The ignore is a langgraph
440- # typing limitation: it accepts a partial state dict at runtime, but the
441- # signature requires the full state type.
442- invoke_task = asyncio .create_task (
443- source_chat_graph .ainvoke (
444- input = {"source_id" : source_id , "model_override" : model_override }, # type: ignore[call-overload]
445- config = config ,
446- )
447- )
448- last_keepalive = time .monotonic ()
449- while True :
450- done , _ = await asyncio .wait (
451- {invoke_task }, timeout = DISCONNECT_POLL_INTERVAL_SECONDS
363+ # Serializes the turn for this session and persists the pending user
364+ # message before generation starts. Held for the whole stream.
365+ async with source_chat_turn (
366+ graph = source_chat_graph ,
367+ session_id = session_id ,
368+ config = config ,
369+ message = message ,
370+ message_id = message_id ,
371+ ):
372+ # Send user message event
373+ user_event = {"type" : "user_message" , "content" : message , "timestamp" : None }
374+ yield f"data: { json .dumps (user_event )} \n \n "
375+
376+ # Run the async graph with ainvoke so generation is cancellable. Only
377+ # the per-message config is passed as input; the messages (incl. the
378+ # user message above) are read from the checkpoint. The ignore is a
379+ # langgraph typing limitation: it accepts a partial state dict at
380+ # runtime, but the signature requires the full state type.
381+ invoke_task = asyncio .create_task (
382+ source_chat_graph .ainvoke (
383+ input = {"source_id" : source_id , "model_override" : model_override }, # type: ignore[call-overload]
384+ config = config ,
385+ )
452386 )
453- if done :
454- # Re-raises on graph error, caught by the outer try/except below.
455- result = invoke_task .result ()
456- break
457- if await request .is_disconnected ():
458- # Client went away — stop generating instead of burning tokens.
459- return
460- now = time .monotonic ()
461- if now - last_keepalive >= KEEPALIVE_INTERVAL_SECONDS :
462- # SSE comment — ignored by clients, keeps the connection alive.
463- yield ": ping\n \n "
464- last_keepalive = now
465-
466- # Stream the complete AI response
467- if "messages" in result :
468- for msg in result ["messages" ]:
469- if hasattr (msg , "type" ) and msg .type == "ai" :
470- ai_event = {
471- "type" : "ai_message" ,
472- "content" : msg .content if hasattr (msg , "content" ) else str (msg ),
473- "timestamp" : None ,
387+ try :
388+ last_keepalive = time .monotonic ()
389+ while True :
390+ done , _ = await asyncio .wait (
391+ {invoke_task }, timeout = DISCONNECT_POLL_INTERVAL_SECONDS
392+ )
393+ if done :
394+ # Re-raises on graph error, caught by the except below.
395+ result = invoke_task .result ()
396+ break
397+ if await request .is_disconnected ():
398+ # Client went away — stop generating instead of burning
399+ # tokens.
400+ return
401+ now = time .monotonic ()
402+ if now - last_keepalive >= KEEPALIVE_INTERVAL_SECONDS :
403+ # SSE comment — ignored by clients, keeps the connection
404+ # alive.
405+ yield ": ping\n \n "
406+ last_keepalive = now
407+
408+ # Stream the complete AI response
409+ if "messages" in result :
410+ for msg in result ["messages" ]:
411+ if hasattr (msg , "type" ) and msg .type == "ai" :
412+ ai_event = {
413+ "type" : "ai_message" ,
414+ "content" : msg .content
415+ if hasattr (msg , "content" )
416+ else str (msg ),
417+ "timestamp" : None ,
418+ }
419+ yield f"data: { json .dumps (ai_event )} \n \n "
420+
421+ # Stream context indicators
422+ if "context_indicators" in result :
423+ context_event = {
424+ "type" : "context_indicators" ,
425+ "data" : result ["context_indicators" ],
474426 }
475- yield f"data: { json .dumps (ai_event )} \n \n "
476-
477- # Stream context indicators
478- if "context_indicators" in result :
479- context_event = {
480- "type" : "context_indicators" ,
481- "data" : result ["context_indicators" ],
482- }
483- yield f"data: { json .dumps (context_event )} \n \n "
484-
485- # Send completion signal
486- completion_event = {"type" : "complete" }
487- yield f"data: { json .dumps (completion_event )} \n \n "
427+ yield f"data: { json .dumps (context_event )} \n \n "
428+
429+ # Send completion signal
430+ completion_event = {"type" : "complete" }
431+ yield f"data: { json .dumps (completion_event )} \n \n "
432+ finally :
433+ # Stop generation if the generator is torn down mid-flight
434+ # (client disconnect or server cancellation) so the model doesn't
435+ # keep running. Runs before the turn lock is released.
436+ if not invoke_task .done ():
437+ invoke_task .cancel ()
438+ await asyncio .gather (invoke_task , return_exceptions = True )
488439
489440 except Exception as e :
490441 from open_notebook .utils .error_classifier import classify_error
@@ -493,23 +444,6 @@ async def stream_source_chat_response(
493444 logger .error (f"Error in source chat streaming: { str (e )} " )
494445 error_event = {"type" : "error" , "message" : error_message }
495446 yield f"data: { json .dumps (error_event )} \n \n "
496- finally :
497- # Stop generation if the generator is torn down mid-flight (client
498- # disconnect or server cancellation) so the model doesn't keep running.
499- if invoke_task is not None and not invoke_task .done ():
500- invoke_task .cancel ()
501- await asyncio .gather (invoke_task , return_exceptions = True )
502- if acquired :
503- _release_session_lock (session_id , lock_entry )
504- else :
505- # Cancelled while waiting to acquire — decrement the holder count
506- # registered in `_get_session_lock` without releasing an unheld lock.
507- lock_entry .holders -= 1
508- if (
509- lock_entry .holders == 0
510- and _session_locks .get (session_id ) is lock_entry
511- ):
512- _session_locks .pop (session_id , None )
513447
514448
515449@router .post ("/sources/{source_id}/chat/sessions/{session_id}/messages" )
0 commit comments