langgraph-stream-parser 0.1.6__tar.gz → 0.1.7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/CHANGELOG.md +16 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/PKG-INFO +65 -1
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/README.md +60 -0
- langgraph_stream_parser-0.1.7/examples/fastapi_websocket.py +234 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/pyproject.toml +6 -1
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/__init__.py +1 -1
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/adapters/__init__.py +9 -1
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/adapters/base.py +67 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/adapters/cli.py +1 -5
- langgraph_stream_parser-0.1.7/src/langgraph_stream_parser/adapters/fastapi.py +311 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/adapters/jupyter.py +3 -38
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/adapters/print.py +3 -42
- langgraph_stream_parser-0.1.7/tests/test_fastapi_adapter.py +398 -0
- langgraph_stream_parser-0.1.6/examples/fastapi_websocket.py +0 -455
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/.claude/settings.local.json +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/.gitignore +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/LICENSE +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/examples/agent.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/examples/jupyter_example.ipynb +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/spec.md +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/compat.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/events.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/extractors/__init__.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/extractors/base.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/extractors/builtins.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/extractors/interrupts.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/extractors/messages.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/handlers/__init__.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/handlers/messages.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/handlers/updates.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/parser.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/resume.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/tests/__init__.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/tests/fixtures/__init__.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/tests/fixtures/mocks.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/tests/test_cli_adapter.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/tests/test_compat.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/tests/test_dual_mode.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/tests/test_events.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/tests/test_extractors.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/tests/test_jupyter.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/tests/test_parser.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/tests/test_print_adapter.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/tests/test_resume.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/tests/test_subagent.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/tests/test_v2_stream.py +0 -0
- {langgraph_stream_parser-0.1.6 → langgraph_stream_parser-0.1.7}/uv.lock +0 -0
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.7] - 2026-04-18
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `FastAPIAdapter` for streaming LangGraph events over WebSocket and Server-Sent Events; stateless by design — conversation state is keyed by `session_id` used as LangGraph `thread_id`
|
|
7
|
+
- Per-session asyncio lock with refcounted cleanup to serialize concurrent turns on the same thread
|
|
8
|
+
- `BaseAdapter._text_prompt_interrupt()` helper, shared by `PrintAdapter` and `JupyterDisplay`
|
|
9
|
+
- `BaseAdapter._truncate()` helper for preview-length capping
|
|
10
|
+
- `fastapi` optional dependency group (`pip install langgraph-stream-parser[fastapi]`)
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- Hoisted `_last_rendered_count` incremental-render cursor from Print/CLI into `BaseAdapter`
|
|
14
|
+
- Slimmed `examples/fastapi_websocket.py` from ~455 to ~234 lines by using the new adapter
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- `UsageEvent` now has an explicit case in `BaseAdapter._process_event` instead of silently falling through
|
|
18
|
+
|
|
3
19
|
## [0.1.6] - 2026-03-28
|
|
4
20
|
|
|
5
21
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: langgraph-stream-parser
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
4
4
|
Summary: Universal parser for LangGraph streaming outputs
|
|
5
5
|
Project-URL: Homepage, https://github.com/dkedar7/langgraph-stream-parser
|
|
6
6
|
Project-URL: Documentation, https://github.com/dkedar7/langgraph-stream-parser#readme
|
|
@@ -21,12 +21,16 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
|
21
21
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
22
|
Requires-Python: >=3.10
|
|
23
23
|
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: fastapi>=0.100; extra == 'dev'
|
|
25
|
+
Requires-Dist: httpx>=0.25; extra == 'dev'
|
|
24
26
|
Requires-Dist: langchain-core>=0.2.0; extra == 'dev'
|
|
25
27
|
Requires-Dist: langgraph>=0.2.0; extra == 'dev'
|
|
26
28
|
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
27
29
|
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
28
30
|
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
29
31
|
Requires-Dist: rich>=13.0; extra == 'dev'
|
|
32
|
+
Provides-Extra: fastapi
|
|
33
|
+
Requires-Dist: fastapi>=0.100; extra == 'fastapi'
|
|
30
34
|
Provides-Extra: jupyter
|
|
31
35
|
Requires-Dist: ipython>=8.0; extra == 'jupyter'
|
|
32
36
|
Requires-Dist: rich>=13.0; extra == 'jupyter'
|
|
@@ -342,6 +346,66 @@ adapter.run(graph=agent, input_data=input_data, config=config)
|
|
|
342
346
|
|
|
343
347
|
Universal output that works in any Python environment without dependencies.
|
|
344
348
|
|
|
349
|
+
### FastAPIAdapter - WebSocket / SSE Streaming
|
|
350
|
+
|
|
351
|
+
Stream events to a web client over WebSocket or Server-Sent Events. The adapter is stateless — conversation state lives in LangGraph's checkpointer, keyed by `session_id` (used as `thread_id`).
|
|
352
|
+
|
|
353
|
+
```python
|
|
354
|
+
from fastapi import FastAPI, WebSocket
|
|
355
|
+
from langgraph_stream_parser.adapters import FastAPIAdapter
|
|
356
|
+
|
|
357
|
+
app = FastAPI()
|
|
358
|
+
adapter = FastAPIAdapter(graph=agent) # agent must be compiled with a checkpointer
|
|
359
|
+
|
|
360
|
+
@app.websocket("/chat/{session_id}")
|
|
361
|
+
async def chat(ws: WebSocket, session_id: str):
|
|
362
|
+
await adapter.handle_websocket(ws, session_id)
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Reconnecting with the same `session_id` resumes the conversation — LangGraph's checkpointer rehydrates history automatically.
|
|
366
|
+
|
|
367
|
+
**WebSocket message protocol (client ↔ server)**
|
|
368
|
+
|
|
369
|
+
Client → Server:
|
|
370
|
+
|
|
371
|
+
```jsonc
|
|
372
|
+
{"type": "message", "content": "Hello"}
|
|
373
|
+
{"type": "decision", "decisions": [{"type": "approve"}]}
|
|
374
|
+
{"type": "decision", "decisions": [{"type": "edit", "args": {...}}]}
|
|
375
|
+
{"type": "cancel"}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Server → Client: every event's `to_dict()` output (e.g. `{"type": "content", ...}`, `{"type": "tool_start", ...}`, `{"type": "interrupt", ...}`, `{"type": "complete"}`), plus protocol-level messages:
|
|
379
|
+
|
|
380
|
+
```jsonc
|
|
381
|
+
{"type": "ack", "ref": "message|decision|cancel"}
|
|
382
|
+
{"type": "error", "error": "..."}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
**Server-Sent Events**
|
|
386
|
+
|
|
387
|
+
```python
|
|
388
|
+
from fastapi.responses import StreamingResponse
|
|
389
|
+
from langgraph_stream_parser import prepare_agent_input
|
|
390
|
+
|
|
391
|
+
@app.post("/chat/{session_id}")
|
|
392
|
+
async def chat(session_id: str, body: dict):
|
|
393
|
+
input_data = prepare_agent_input(message=body["message"])
|
|
394
|
+
return StreamingResponse(
|
|
395
|
+
adapter.sse_stream(session_id, input_data),
|
|
396
|
+
media_type="text/event-stream",
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
@app.post("/chat/{session_id}/resume")
|
|
400
|
+
async def resume(session_id: str, body: dict):
|
|
401
|
+
return StreamingResponse(
|
|
402
|
+
adapter.resume(session_id, body["decisions"]),
|
|
403
|
+
media_type="text/event-stream",
|
|
404
|
+
)
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
Requires: `pip install langgraph-stream-parser[fastapi]`
|
|
408
|
+
|
|
345
409
|
### JupyterDisplay - Rich Notebook Display
|
|
346
410
|
|
|
347
411
|
```python
|
|
@@ -308,6 +308,66 @@ adapter.run(graph=agent, input_data=input_data, config=config)
|
|
|
308
308
|
|
|
309
309
|
Universal output that works in any Python environment without dependencies.
|
|
310
310
|
|
|
311
|
+
### FastAPIAdapter - WebSocket / SSE Streaming
|
|
312
|
+
|
|
313
|
+
Stream events to a web client over WebSocket or Server-Sent Events. The adapter is stateless — conversation state lives in LangGraph's checkpointer, keyed by `session_id` (used as `thread_id`).
|
|
314
|
+
|
|
315
|
+
```python
|
|
316
|
+
from fastapi import FastAPI, WebSocket
|
|
317
|
+
from langgraph_stream_parser.adapters import FastAPIAdapter
|
|
318
|
+
|
|
319
|
+
app = FastAPI()
|
|
320
|
+
adapter = FastAPIAdapter(graph=agent) # agent must be compiled with a checkpointer
|
|
321
|
+
|
|
322
|
+
@app.websocket("/chat/{session_id}")
|
|
323
|
+
async def chat(ws: WebSocket, session_id: str):
|
|
324
|
+
await adapter.handle_websocket(ws, session_id)
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Reconnecting with the same `session_id` resumes the conversation — LangGraph's checkpointer rehydrates history automatically.
|
|
328
|
+
|
|
329
|
+
**WebSocket message protocol (client ↔ server)**
|
|
330
|
+
|
|
331
|
+
Client → Server:
|
|
332
|
+
|
|
333
|
+
```jsonc
|
|
334
|
+
{"type": "message", "content": "Hello"}
|
|
335
|
+
{"type": "decision", "decisions": [{"type": "approve"}]}
|
|
336
|
+
{"type": "decision", "decisions": [{"type": "edit", "args": {...}}]}
|
|
337
|
+
{"type": "cancel"}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Server → Client: every event's `to_dict()` output (e.g. `{"type": "content", ...}`, `{"type": "tool_start", ...}`, `{"type": "interrupt", ...}`, `{"type": "complete"}`), plus protocol-level messages:
|
|
341
|
+
|
|
342
|
+
```jsonc
|
|
343
|
+
{"type": "ack", "ref": "message|decision|cancel"}
|
|
344
|
+
{"type": "error", "error": "..."}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
**Server-Sent Events**
|
|
348
|
+
|
|
349
|
+
```python
|
|
350
|
+
from fastapi.responses import StreamingResponse
|
|
351
|
+
from langgraph_stream_parser import prepare_agent_input
|
|
352
|
+
|
|
353
|
+
@app.post("/chat/{session_id}")
|
|
354
|
+
async def chat(session_id: str, body: dict):
|
|
355
|
+
input_data = prepare_agent_input(message=body["message"])
|
|
356
|
+
return StreamingResponse(
|
|
357
|
+
adapter.sse_stream(session_id, input_data),
|
|
358
|
+
media_type="text/event-stream",
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
@app.post("/chat/{session_id}/resume")
|
|
362
|
+
async def resume(session_id: str, body: dict):
|
|
363
|
+
return StreamingResponse(
|
|
364
|
+
adapter.resume(session_id, body["decisions"]),
|
|
365
|
+
media_type="text/event-stream",
|
|
366
|
+
)
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Requires: `pip install langgraph-stream-parser[fastapi]`
|
|
370
|
+
|
|
311
371
|
### JupyterDisplay - Rich Notebook Display
|
|
312
372
|
|
|
313
373
|
```python
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI WebSocket Example using FastAPIAdapter.
|
|
3
|
+
|
|
4
|
+
Demonstrates the built-in ``FastAPIAdapter`` streaming LangGraph events
|
|
5
|
+
over a WebSocket with interrupt handling. The adapter is stateless —
|
|
6
|
+
conversation state lives in LangGraph's checkpointer keyed by
|
|
7
|
+
``session_id`` (used as ``thread_id``).
|
|
8
|
+
|
|
9
|
+
Requirements:
|
|
10
|
+
pip install 'langgraph-stream-parser[fastapi]' uvicorn langgraph
|
|
11
|
+
|
|
12
|
+
Run:
|
|
13
|
+
uvicorn examples.fastapi_websocket:app --reload
|
|
14
|
+
|
|
15
|
+
Then open http://localhost:8000 in your browser.
|
|
16
|
+
"""
|
|
17
|
+
import uuid
|
|
18
|
+
|
|
19
|
+
from fastapi import FastAPI, WebSocket
|
|
20
|
+
from fastapi.responses import HTMLResponse
|
|
21
|
+
from dotenv import load_dotenv
|
|
22
|
+
|
|
23
|
+
from langgraph_stream_parser.adapters import FastAPIAdapter
|
|
24
|
+
|
|
25
|
+
load_dotenv()
|
|
26
|
+
|
|
27
|
+
from .agent import agent
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
app = FastAPI()
|
|
31
|
+
adapter = FastAPIAdapter(graph=agent)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.websocket("/ws/chat/{session_id}")
|
|
35
|
+
async def websocket_chat(websocket: WebSocket, session_id: str):
|
|
36
|
+
"""WebSocket endpoint. Reconnecting with the same session_id resumes
|
|
37
|
+
the conversation (state lives in the checkpointer)."""
|
|
38
|
+
await adapter.handle_websocket(websocket, session_id)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.get("/")
|
|
42
|
+
async def get_client():
|
|
43
|
+
"""Serve the HTML test client, seeded with a fresh session_id."""
|
|
44
|
+
return HTMLResponse(HTML_CLIENT.replace("{{SESSION_ID}}", str(uuid.uuid4())))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── HTML client ─────────────────────────────────────────────────────
|
|
48
|
+
# The protocol: client sends
|
|
49
|
+
# {"type": "message", "content": "..."}
|
|
50
|
+
# {"type": "decision", "decisions": [{"type": "approve"}, ...]}
|
|
51
|
+
# Server sends: event_to_dict(event) for each StreamEvent, plus
|
|
52
|
+
# {"type": "ack", "ref": "..."} and {"type": "error", "error": "..."}
|
|
53
|
+
|
|
54
|
+
HTML_CLIENT = """
|
|
55
|
+
<!DOCTYPE html>
|
|
56
|
+
<html>
|
|
57
|
+
<head>
|
|
58
|
+
<title>LangGraph Stream Parser - WebSocket Demo</title>
|
|
59
|
+
<style>
|
|
60
|
+
* { box-sizing: border-box; }
|
|
61
|
+
body {
|
|
62
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
63
|
+
max-width: 800px;
|
|
64
|
+
margin: 0 auto;
|
|
65
|
+
padding: 20px;
|
|
66
|
+
background: #f5f5f5;
|
|
67
|
+
}
|
|
68
|
+
h1 { color: #333; }
|
|
69
|
+
#chat {
|
|
70
|
+
background: white;
|
|
71
|
+
border: 1px solid #ddd;
|
|
72
|
+
border-radius: 8px;
|
|
73
|
+
height: 500px;
|
|
74
|
+
overflow-y: auto;
|
|
75
|
+
padding: 16px;
|
|
76
|
+
margin-bottom: 16px;
|
|
77
|
+
}
|
|
78
|
+
.message { margin: 8px 0; padding: 12px; border-radius: 8px; }
|
|
79
|
+
.user { background: #007bff; color: white; margin-left: 20%; }
|
|
80
|
+
.assistant { background: #e9ecef; margin-right: 20%; }
|
|
81
|
+
.tool-start { background: #fff3cd; border-left: 4px solid #ffc107; font-size: 0.9em; }
|
|
82
|
+
.tool-end { background: #d4edda; border-left: 4px solid #28a745; font-size: 0.9em; }
|
|
83
|
+
.tool-error { background: #f8d7da; border-left: 4px solid #dc3545; }
|
|
84
|
+
.complete { background: #d1ecf1; border-left: 4px solid #17a2b8; text-align: center; font-style: italic; }
|
|
85
|
+
.error { background: #f8d7da; border-left: 4px solid #dc3545; }
|
|
86
|
+
.interrupt { background: #fff3cd; border: 2px solid #ffc107; padding: 16px; }
|
|
87
|
+
.interrupt h4 { margin: 0 0 12px 0; color: #856404; }
|
|
88
|
+
.interrupt pre { background: #f8f9fa; padding: 8px; border-radius: 4px; overflow-x: auto; font-size: 0.85em; }
|
|
89
|
+
.interrupt-buttons { margin-top: 12px; display: flex; gap: 8px; }
|
|
90
|
+
.interrupt-buttons button { padding: 8px 16px; font-size: 14px; }
|
|
91
|
+
.btn-approve { background: #28a745; }
|
|
92
|
+
.btn-reject { background: #dc3545; }
|
|
93
|
+
#input-area { display: flex; gap: 8px; }
|
|
94
|
+
#message-input { flex: 1; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 16px; }
|
|
95
|
+
button { padding: 12px 24px; background: #007bff; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 16px; }
|
|
96
|
+
button:disabled { background: #6c757d; cursor: not-allowed; }
|
|
97
|
+
code { background: #f4f4f4; padding: 2px 6px; border-radius: 4px; font-family: Monaco, Consolas, monospace; }
|
|
98
|
+
.session { color: #666; font-size: 0.85em; }
|
|
99
|
+
</style>
|
|
100
|
+
</head>
|
|
101
|
+
<body>
|
|
102
|
+
<h1>LangGraph Stream Parser Demo</h1>
|
|
103
|
+
<p class="session">Session: <code>{{SESSION_ID}}</code></p>
|
|
104
|
+
|
|
105
|
+
<div id="chat"></div>
|
|
106
|
+
|
|
107
|
+
<div id="input-area">
|
|
108
|
+
<input type="text" id="message-input" placeholder="Type a message..." />
|
|
109
|
+
<button id="send-btn" onclick="sendMessage()">Send</button>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<script>
|
|
113
|
+
const sessionId = "{{SESSION_ID}}";
|
|
114
|
+
const chat = document.getElementById('chat');
|
|
115
|
+
const input = document.getElementById('message-input');
|
|
116
|
+
const sendBtn = document.getElementById('send-btn');
|
|
117
|
+
|
|
118
|
+
let ws = null;
|
|
119
|
+
let currentAssistantMessage = null;
|
|
120
|
+
|
|
121
|
+
function connect() {
|
|
122
|
+
ws = new WebSocket(`ws://${window.location.host}/ws/chat/${sessionId}`);
|
|
123
|
+
ws.onopen = () => addMessage('system', 'Connected.');
|
|
124
|
+
ws.onmessage = (event) => handleEvent(JSON.parse(event.data));
|
|
125
|
+
ws.onclose = () => {
|
|
126
|
+
addMessage('error', 'Disconnected. Refresh to reconnect.');
|
|
127
|
+
sendBtn.disabled = true;
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function handleEvent(data) {
|
|
132
|
+
switch (data.type) {
|
|
133
|
+
case 'ack':
|
|
134
|
+
if (data.ref === 'message') sendBtn.disabled = true;
|
|
135
|
+
break;
|
|
136
|
+
case 'content':
|
|
137
|
+
if (!currentAssistantMessage) {
|
|
138
|
+
currentAssistantMessage = addMessage('assistant', '');
|
|
139
|
+
}
|
|
140
|
+
currentAssistantMessage.textContent += data.content;
|
|
141
|
+
break;
|
|
142
|
+
case 'tool_start':
|
|
143
|
+
addMessage('tool-start',
|
|
144
|
+
`🔧 Calling <code>${data.name}</code> with: ${JSON.stringify(data.args)}`);
|
|
145
|
+
break;
|
|
146
|
+
case 'tool_end':
|
|
147
|
+
const cls = data.status === 'success' ? 'tool-end' : 'tool-error';
|
|
148
|
+
const icon = data.status === 'success' ? '✓' : '✗';
|
|
149
|
+
addMessage(cls, `${icon} <code>${data.name}</code>: ${data.result}`);
|
|
150
|
+
break;
|
|
151
|
+
case 'complete':
|
|
152
|
+
addMessage('complete', '— Response complete —');
|
|
153
|
+
sendBtn.disabled = false;
|
|
154
|
+
currentAssistantMessage = null;
|
|
155
|
+
break;
|
|
156
|
+
case 'error':
|
|
157
|
+
addMessage('error', `Error: ${data.error}`);
|
|
158
|
+
sendBtn.disabled = false;
|
|
159
|
+
break;
|
|
160
|
+
case 'interrupt':
|
|
161
|
+
showInterrupt(data);
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
chat.scrollTop = chat.scrollHeight;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function showInterrupt(data) {
|
|
168
|
+
const div = document.createElement('div');
|
|
169
|
+
div.className = 'message interrupt';
|
|
170
|
+
|
|
171
|
+
let actionsHtml = '';
|
|
172
|
+
for (const action of data.action_requests) {
|
|
173
|
+
const tool = action.tool || action.action?.tool || 'unknown';
|
|
174
|
+
const args = action.args || action.action?.args || {};
|
|
175
|
+
actionsHtml += `<p><strong>Tool:</strong> <code>${tool}</code></p>`;
|
|
176
|
+
actionsHtml += `<pre>${JSON.stringify(args, null, 2)}</pre>`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const allowed = data.allowed_decisions || ['approve', 'reject'];
|
|
180
|
+
let buttonsHtml = '<div class="interrupt-buttons">';
|
|
181
|
+
if (allowed.includes('approve')) {
|
|
182
|
+
buttonsHtml += `<button class="btn-approve" onclick="sendDecision('approve')">Approve</button>`;
|
|
183
|
+
}
|
|
184
|
+
if (allowed.includes('reject')) {
|
|
185
|
+
buttonsHtml += `<button class="btn-reject" onclick="sendDecision('reject')">Reject</button>`;
|
|
186
|
+
}
|
|
187
|
+
buttonsHtml += '</div>';
|
|
188
|
+
|
|
189
|
+
div.innerHTML = `<h4>Action Required</h4>${actionsHtml}${buttonsHtml}`;
|
|
190
|
+
chat.appendChild(div);
|
|
191
|
+
chat.scrollTop = chat.scrollHeight;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function sendDecision(decisionType) {
|
|
195
|
+
if (!ws) return;
|
|
196
|
+
document.querySelectorAll('.interrupt-buttons').forEach(el => el.remove());
|
|
197
|
+
ws.send(JSON.stringify({
|
|
198
|
+
type: 'decision',
|
|
199
|
+
decisions: [{ type: decisionType }],
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function addMessage(type, content) {
|
|
204
|
+
const div = document.createElement('div');
|
|
205
|
+
div.className = `message ${type}`;
|
|
206
|
+
div.innerHTML = content;
|
|
207
|
+
chat.appendChild(div);
|
|
208
|
+
chat.scrollTop = chat.scrollHeight;
|
|
209
|
+
return div;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function sendMessage() {
|
|
213
|
+
const message = input.value.trim();
|
|
214
|
+
if (!message || !ws) return;
|
|
215
|
+
addMessage('user', message);
|
|
216
|
+
ws.send(JSON.stringify({ type: 'message', content: message }));
|
|
217
|
+
input.value = '';
|
|
218
|
+
sendBtn.disabled = true;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
input.addEventListener('keypress', (e) => {
|
|
222
|
+
if (e.key === 'Enter') sendMessage();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
connect();
|
|
226
|
+
</script>
|
|
227
|
+
</body>
|
|
228
|
+
</html>
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__":
|
|
233
|
+
import uvicorn
|
|
234
|
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "langgraph-stream-parser"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.7"
|
|
4
4
|
description = "Universal parser for LangGraph streaming outputs"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = {text = "MIT"}
|
|
@@ -35,6 +35,9 @@ jupyter = [
|
|
|
35
35
|
"rich>=13.0",
|
|
36
36
|
"ipython>=8.0",
|
|
37
37
|
]
|
|
38
|
+
fastapi = [
|
|
39
|
+
"fastapi>=0.100",
|
|
40
|
+
]
|
|
38
41
|
dev = [
|
|
39
42
|
"pytest>=7.0",
|
|
40
43
|
"pytest-asyncio>=0.21",
|
|
@@ -42,6 +45,8 @@ dev = [
|
|
|
42
45
|
"langgraph>=0.2.0",
|
|
43
46
|
"langchain-core>=0.2.0",
|
|
44
47
|
"rich>=13.0",
|
|
48
|
+
"fastapi>=0.100",
|
|
49
|
+
"httpx>=0.25",
|
|
45
50
|
]
|
|
46
51
|
|
|
47
52
|
[project.urls]
|
|
@@ -3,5 +3,13 @@
|
|
|
3
3
|
from .base import BaseAdapter, ToolStatus, ToolState
|
|
4
4
|
from .print import PrintAdapter
|
|
5
5
|
from .cli import CLIAdapter
|
|
6
|
+
from .fastapi import FastAPIAdapter
|
|
6
7
|
|
|
7
|
-
__all__ = [
|
|
8
|
+
__all__ = [
|
|
9
|
+
"BaseAdapter",
|
|
10
|
+
"ToolStatus",
|
|
11
|
+
"ToolState",
|
|
12
|
+
"PrintAdapter",
|
|
13
|
+
"CLIAdapter",
|
|
14
|
+
"FastAPIAdapter",
|
|
15
|
+
]
|
|
@@ -18,6 +18,7 @@ from ..events import (
|
|
|
18
18
|
ToolExtractedEvent,
|
|
19
19
|
InterruptEvent,
|
|
20
20
|
StateUpdateEvent,
|
|
21
|
+
UsageEvent,
|
|
21
22
|
CustomEvent,
|
|
22
23
|
ValuesEvent,
|
|
23
24
|
DebugEvent,
|
|
@@ -142,6 +143,12 @@ class BaseAdapter(ABC):
|
|
|
142
143
|
self._error: ErrorEvent | None = None
|
|
143
144
|
self._complete: bool = False
|
|
144
145
|
|
|
146
|
+
# Incremental-render cursor — the render() implementations that
|
|
147
|
+
# emit chunks as they arrive (PrintAdapter, CLIAdapter) use this
|
|
148
|
+
# to slice _display_items and only render what's new. Jupyter
|
|
149
|
+
# re-renders the full list each time, so it ignores this.
|
|
150
|
+
self._last_rendered_count: int = 0
|
|
151
|
+
|
|
145
152
|
def reset(self) -> None:
|
|
146
153
|
"""Reset state for a new stream."""
|
|
147
154
|
self._display_items.clear()
|
|
@@ -151,6 +158,7 @@ class BaseAdapter(ABC):
|
|
|
151
158
|
self._interrupt = None
|
|
152
159
|
self._error = None
|
|
153
160
|
self._complete = False
|
|
161
|
+
self._last_rendered_count = 0
|
|
154
162
|
|
|
155
163
|
def run(
|
|
156
164
|
self,
|
|
@@ -325,6 +333,13 @@ class BaseAdapter(ABC):
|
|
|
325
333
|
case StateUpdateEvent():
|
|
326
334
|
pass # Ignored in display
|
|
327
335
|
|
|
336
|
+
case UsageEvent():
|
|
337
|
+
pass # Ignored in display — subclasses may override
|
|
338
|
+
|
|
339
|
+
case _:
|
|
340
|
+
# Any future event types fall through here.
|
|
341
|
+
pass
|
|
342
|
+
|
|
328
343
|
# Helper methods for subclasses
|
|
329
344
|
|
|
330
345
|
def get_allowed_decisions(self, event: InterruptEvent) -> set[str]:
|
|
@@ -364,6 +379,58 @@ class BaseAdapter(ABC):
|
|
|
364
379
|
|
|
365
380
|
return decisions
|
|
366
381
|
|
|
382
|
+
def _text_prompt_interrupt(
|
|
383
|
+
self,
|
|
384
|
+
event: InterruptEvent,
|
|
385
|
+
) -> list[dict[str, Any]] | None:
|
|
386
|
+
"""Shared ``input()``-based interrupt prompt.
|
|
387
|
+
|
|
388
|
+
Used by PrintAdapter and JupyterDisplay — both prompt the user
|
|
389
|
+
via ``input()`` for a decision string and, if "edit" is chosen,
|
|
390
|
+
a JSON args object. CLIAdapter has its own arrow-key variant
|
|
391
|
+
and does not call this.
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
Decision list for ``create_resume_input(decisions=...)``,
|
|
395
|
+
or None if the user cancelled.
|
|
396
|
+
"""
|
|
397
|
+
import json as _json
|
|
398
|
+
|
|
399
|
+
allowed = self.get_allowed_decisions(event)
|
|
400
|
+
options = sorted(allowed)
|
|
401
|
+
options_str = "/".join(options)
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
response = input(f"Decision ({options_str}): ").strip().lower()
|
|
405
|
+
except (EOFError, KeyboardInterrupt):
|
|
406
|
+
return None
|
|
407
|
+
|
|
408
|
+
if not response or response not in allowed:
|
|
409
|
+
response = "reject" if "reject" in allowed else options[0]
|
|
410
|
+
|
|
411
|
+
args_modifier = None
|
|
412
|
+
if response == "edit":
|
|
413
|
+
try:
|
|
414
|
+
new_args_str = input("New args (JSON): ").strip()
|
|
415
|
+
if new_args_str:
|
|
416
|
+
new_args = _json.loads(new_args_str)
|
|
417
|
+
args_modifier = lambda _: new_args # noqa: E731
|
|
418
|
+
except (EOFError, KeyboardInterrupt, _json.JSONDecodeError):
|
|
419
|
+
response = "reject"
|
|
420
|
+
|
|
421
|
+
return self.build_decisions(event, response, args_modifier)
|
|
422
|
+
|
|
423
|
+
def _truncate(self, s: str, limit: int | None = None) -> str:
|
|
424
|
+
"""Truncate a string to ``max_content_preview`` (or ``limit``).
|
|
425
|
+
|
|
426
|
+
Used by adapters to cap preview sizes without each one
|
|
427
|
+
re-implementing the same slice-and-ellipsis logic.
|
|
428
|
+
"""
|
|
429
|
+
cap = limit if limit is not None else self._max_content_preview
|
|
430
|
+
if len(s) > cap:
|
|
431
|
+
return s[:cap] + "..."
|
|
432
|
+
return s
|
|
433
|
+
|
|
367
434
|
@staticmethod
|
|
368
435
|
def format_duration(duration_ms: float | None) -> str:
|
|
369
436
|
"""Format duration for display."""
|
|
@@ -128,7 +128,6 @@ class CLIAdapter(BaseAdapter):
|
|
|
128
128
|
)
|
|
129
129
|
self._use_spinner = use_spinner
|
|
130
130
|
self._use_colors = use_colors
|
|
131
|
-
self._last_rendered_count = 0
|
|
132
131
|
self._spinner: Spinner | None = None
|
|
133
132
|
self._active_tools: set[str] = set()
|
|
134
133
|
|
|
@@ -139,7 +138,6 @@ class CLIAdapter(BaseAdapter):
|
|
|
139
138
|
def reset(self) -> None:
|
|
140
139
|
"""Reset state for a new stream."""
|
|
141
140
|
super().reset()
|
|
142
|
-
self._last_rendered_count = 0
|
|
143
141
|
self._stop_spinner()
|
|
144
142
|
self._active_tools.clear()
|
|
145
143
|
|
|
@@ -420,9 +418,7 @@ class CLIAdapter(BaseAdapter):
|
|
|
420
418
|
"""Print extracted content with styled formatting."""
|
|
421
419
|
c = self._c
|
|
422
420
|
|
|
423
|
-
data_str = str(event.data)
|
|
424
|
-
if len(data_str) > self._max_content_preview:
|
|
425
|
-
data_str = data_str[:self._max_content_preview] + "..."
|
|
421
|
+
data_str = self._truncate(str(event.data))
|
|
426
422
|
|
|
427
423
|
# Special handling for todo types
|
|
428
424
|
if event.extracted_type in self._todo_types and isinstance(event.data, list):
|