experiment with sse
This commit is contained in:
parent
407cbdc49c
commit
9de04109d8
|
|
@ -0,0 +1,42 @@
|
||||||
|
# PocketFlow Hello World
|
||||||
|
|
||||||
|
Your first PocketFlow application! This simple example demonstrates how to create a basic PocketFlow app from scratch.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── docs/ # Documentation files
|
||||||
|
├── utils/ # Utility functions
|
||||||
|
├── flow.py # PocketFlow implementation
|
||||||
|
├── main.py # Main application entry point
|
||||||
|
└── README.md # Project documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Create a virtual environment:
|
||||||
|
```bash
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run the example:
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## What This Example Demonstrates
|
||||||
|
|
||||||
|
- How to create your first PocketFlow application
|
||||||
|
- Basic PocketFlow concepts and usage
|
||||||
|
- Simple example of PocketFlow's capabilities
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- [PocketFlow Documentation](https://the-pocket.github.io/PocketFlow/)
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
# Human-in-the-Loop Web Service
|
||||||
|
|
||||||
|
## 1. Requirements
|
||||||
|
|
||||||
|
* **Goal:** Create a web service for task submission, processing, human review (Approve/Reject loop via UI), and finalization.
|
||||||
|
* **Interface:** Simple web UI (HTML/JS) for input, status display, and feedback buttons.
|
||||||
|
* **Backend:** Flask application using PocketFlow for workflow management.
|
||||||
|
* **Real-time Updates:** Use Server-Sent Events (SSE) to push status changes (pending, running, waiting_for_review, completed, failed) and intermediate results to the client without page reloads.
|
||||||
|
* **State:** Use in-memory storage for task state (Warning: Not suitable for production).
|
||||||
|
|
||||||
|
## 2. Flow Design
|
||||||
|
|
||||||
|
* **Core Pattern:** Workflow with a conditional loop based on human feedback. SSE for asynchronous status communication.
|
||||||
|
* **Nodes:**
|
||||||
|
1. `ProcessNode` (Regular): Takes input, executes the (simulated) task processing.
|
||||||
|
2. `ReviewNode` (Async): Waits for human feedback signaled via an `asyncio.Event`. Pushes "waiting\_for\_review" status to the SSE queue.
|
||||||
|
3. `ResultNode` (Regular): Marks the task as complete and logs the final result.
|
||||||
|
* **Shared Store (`shared` dict per task):**
|
||||||
|
* `task_input`: Initial data from user.
|
||||||
|
* `processed_output`: Result from `ProcessNode`.
|
||||||
|
* `feedback`: 'approved' or 'rejected' set by the `/feedback` endpoint.
|
||||||
|
* `review_event`: `asyncio.Event` used by `ReviewNode` to wait and `/feedback` to signal.
|
||||||
|
* `final_result`: The approved output.
|
||||||
|
* `current_attempt`: Tracks reprocessing count.
|
||||||
|
* `task_id`: Unique identifier for the task.
|
||||||
|
* **SSE Communication:** An `asyncio.Queue` (stored alongside the `shared` store in the server's global `tasks` dict, *not directly in PocketFlow's shared store*) is used per task. Nodes (or wrapper code) put status updates onto this queue. The `/stream` endpoint reads from the queue and sends SSE messages.
|
||||||
|
* **Mermaid Diagram:**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Process[Process Task] -- "default" --> Review{Wait for Feedback}
|
||||||
|
Review -- "approved" --> Result[Final Result]
|
||||||
|
Review -- "rejected" --> Process
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Utilities
|
||||||
|
|
||||||
|
For this specific example, the core "utility" is the processing logic itself. Let's simulate it with a simple function. The Flask server acts as the external interface.
|
||||||
|
|
||||||
|
* `process_task(input_data)`: A placeholder function. In a real scenario, this might call an LLM (`utils/call_llm.py`).
|
||||||
|
|
||||||
|
## 4. Node Design (Detailed)
|
||||||
|
|
||||||
|
* **`ProcessNode` (Node):**
|
||||||
|
* `prep`: Reads `task_input`, `current_attempt` from `shared`.
|
||||||
|
* `exec`: Calls `utils.process_task.process_task`.
|
||||||
|
* `post`: Writes `processed_output` to `shared`, increments `current_attempt`. Returns "default".
|
||||||
|
* **`ReviewNode` (AsyncNode):**
|
||||||
|
* `prep_async`: (As modified/wrapped by server.py) Reads `review_event`, `processed_output` from `shared`. **Puts "waiting\_for\_review" status onto the task's SSE queue.**
|
||||||
|
* `exec_async`: `await shared["review_event"].wait()`.
|
||||||
|
* `post_async`: Reads `feedback` from `shared`. Clears the event. Returns "approved" or "rejected". If approved, stores `processed_output` into `final_result`.
|
||||||
|
* **`ResultNode` (Node):**
|
||||||
|
* `prep`: Reads `final_result` from `shared`.
|
||||||
|
* `exec`: Prints/logs the final result.
|
||||||
|
* `post`: Returns `None` (ends flow).
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from flask import Flask, request, jsonify, render_template, send_from_directory, Response
|
||||||
|
from flow import create_feedback_flow
|
||||||
|
|
||||||
|
# --- Configuration ---
|
||||||
|
template_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'templates'))
|
||||||
|
static_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'static'))
|
||||||
|
app = Flask(__name__, template_folder=template_dir, static_folder=static_dir)
|
||||||
|
|
||||||
|
# --- State Management (In-Memory - NOT FOR PRODUCTION) ---
|
||||||
|
tasks = {} # task_id -> {"shared": dict, "status": str, "task_obj": asyncio.Task}
|
||||||
|
|
||||||
|
# --- Background Flow Runner ---
|
||||||
|
async def run_flow_background(task_id, flow, shared):
|
||||||
|
"""Runs the flow in background, uses queue in shared for SSE."""
|
||||||
|
if task_id not in tasks: return # Should not happen
|
||||||
|
queue = shared.get("sse_queue")
|
||||||
|
if not queue:
|
||||||
|
print(f"ERROR: Task {task_id} missing sse_queue in shared store!")
|
||||||
|
tasks[task_id]["status"] = "failed"
|
||||||
|
# Cannot easily report via SSE if queue is missing
|
||||||
|
return
|
||||||
|
|
||||||
|
tasks[task_id]["status"] = "running"
|
||||||
|
await queue.put({"status": "running"})
|
||||||
|
print(f"Task {task_id}: Flow starting.")
|
||||||
|
|
||||||
|
final_status = "unknown"
|
||||||
|
error_message = None
|
||||||
|
try:
|
||||||
|
await flow.run_async(shared)
|
||||||
|
# Check final state
|
||||||
|
if shared.get("final_result") is not None:
|
||||||
|
final_status = "completed"
|
||||||
|
else:
|
||||||
|
# If flow ends without setting final_result (e.g., error before ResultNode)
|
||||||
|
final_status = "finished_incomplete"
|
||||||
|
print(f"Task {task_id}: Flow finished with status: {final_status}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
final_status = "failed"
|
||||||
|
error_message = str(e)
|
||||||
|
print(f"Task {task_id}: Flow failed: {e}")
|
||||||
|
finally:
|
||||||
|
if task_id in tasks:
|
||||||
|
tasks[task_id]["status"] = final_status
|
||||||
|
final_update = {"status": final_status}
|
||||||
|
if final_status == "completed":
|
||||||
|
final_update["final_result"] = shared.get("final_result")
|
||||||
|
elif error_message:
|
||||||
|
final_update["error"] = error_message
|
||||||
|
await queue.put(final_update)
|
||||||
|
# Signal end of stream
|
||||||
|
await queue.put(None)
|
||||||
|
print(f"Task {task_id}: Background task ended. Final update put on queue.")
|
||||||
|
|
||||||
|
# --- Flask Routes ---
|
||||||
|
@app.route('/')
|
||||||
|
async def index():
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
@app.route('/static/<path:filename>')
|
||||||
|
async def static_files(filename):
|
||||||
|
return send_from_directory(app.static_folder, filename)
|
||||||
|
|
||||||
|
@app.route('/submit', methods=['POST'])
|
||||||
|
async def submit_task():
|
||||||
|
if not request.is_json or 'data' not in request.json:
|
||||||
|
return jsonify({"error": "Requires JSON with 'data' field"}), 400
|
||||||
|
|
||||||
|
task_id = str(uuid.uuid4())
|
||||||
|
feedback_event = asyncio.Event()
|
||||||
|
status_queue = asyncio.Queue() # Queue for SSE
|
||||||
|
|
||||||
|
# Initial shared state for the flow
|
||||||
|
shared = {
|
||||||
|
"task_input": request.json['data'],
|
||||||
|
"processed_output": None,
|
||||||
|
"feedback": None,
|
||||||
|
"review_event": feedback_event,
|
||||||
|
"sse_queue": status_queue, # Make queue accessible to nodes
|
||||||
|
"final_result": None,
|
||||||
|
"task_id": task_id
|
||||||
|
}
|
||||||
|
|
||||||
|
flow = create_feedback_flow()
|
||||||
|
|
||||||
|
# Store task state
|
||||||
|
tasks[task_id] = {
|
||||||
|
"shared": shared,
|
||||||
|
"status": "pending",
|
||||||
|
# "flow": flow, # Not strictly needed if we don't re-use it
|
||||||
|
"task_obj": None # Will hold the background task
|
||||||
|
}
|
||||||
|
|
||||||
|
await status_queue.put({"status": "pending", "task_id": task_id})
|
||||||
|
|
||||||
|
# Start flow execution in background
|
||||||
|
task_obj = asyncio.create_task(run_flow_background(task_id, flow, shared))
|
||||||
|
tasks[task_id]["task_obj"] = task_obj
|
||||||
|
|
||||||
|
print(f"Task {task_id}: Submitted.")
|
||||||
|
return jsonify({"message": "Task submitted", "task_id": task_id}), 202
|
||||||
|
|
||||||
|
@app.route('/feedback/<task_id>', methods=['POST'])
|
||||||
|
async def provide_feedback(task_id):
|
||||||
|
if task_id not in tasks:
|
||||||
|
return jsonify({"error": "Task not found"}), 404
|
||||||
|
|
||||||
|
task_info = tasks[task_id]
|
||||||
|
shared = task_info["shared"]
|
||||||
|
queue = shared.get("sse_queue")
|
||||||
|
|
||||||
|
async def report_error(message, status_code=400):
|
||||||
|
print(f"Task {task_id}: Feedback error - {message}")
|
||||||
|
if queue: await queue.put({"status": "feedback_error", "error": message})
|
||||||
|
return jsonify({"error": message}), status_code
|
||||||
|
|
||||||
|
if not request.is_json or 'feedback' not in request.json:
|
||||||
|
return await report_error("Requires JSON with 'feedback' field")
|
||||||
|
feedback = request.json.get('feedback')
|
||||||
|
if feedback not in ["approved", "rejected"]:
|
||||||
|
return await report_error("Invalid feedback value")
|
||||||
|
|
||||||
|
review_event = shared.get("review_event")
|
||||||
|
if not review_event or review_event.is_set():
|
||||||
|
return await report_error("Task not awaiting feedback or feedback already sent", 409)
|
||||||
|
|
||||||
|
print(f"Task {task_id}: Received feedback: {feedback}")
|
||||||
|
if queue: await queue.put({"status": "processing_feedback"})
|
||||||
|
tasks[task_id]["status"] = "processing_feedback"
|
||||||
|
|
||||||
|
shared["feedback"] = feedback
|
||||||
|
review_event.set() # Signal the waiting ReviewNode
|
||||||
|
|
||||||
|
return jsonify({"message": f"Feedback '{feedback}' received"}), 200
|
||||||
|
|
||||||
|
# --- SSE Endpoint ---
|
||||||
|
@app.route('/stream/<task_id>')
|
||||||
|
async def stream(task_id):
|
||||||
|
if task_id not in tasks or "sse_queue" not in tasks[task_id]["shared"]:
|
||||||
|
return Response("data: {\"status\": \"error\", \"error\": \"Task or queue not found\"}\n\n",
|
||||||
|
mimetype='text/event-stream', status=404)
|
||||||
|
|
||||||
|
queue = tasks[task_id]["shared"]["sse_queue"]
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
print(f"SSE Stream: Client connected for {task_id}")
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
update = await queue.get()
|
||||||
|
if update is None: # Sentinel for end of stream
|
||||||
|
print(f"SSE Stream: Sentinel received for {task_id}, closing.")
|
||||||
|
yield f"data: {json.dumps({'status': 'stream_closed'})}\n\n"
|
||||||
|
break
|
||||||
|
sse_data = json.dumps(update)
|
||||||
|
print(f"SSE Stream: Sending for {task_id}: {sse_data}")
|
||||||
|
yield f"data: {sse_data}\n\n"
|
||||||
|
queue.task_done()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
print(f"SSE Stream: Client disconnected for {task_id}.")
|
||||||
|
finally:
|
||||||
|
print(f"SSE Stream: Generator finished for {task_id}.")
|
||||||
|
# Optional: Cleanup task entry after stream ends?
|
||||||
|
# if task_id in tasks: del tasks[task_id] # Careful if task state needed elsewhere
|
||||||
|
|
||||||
|
headers = {'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache'}
|
||||||
|
return Response(event_generator(), mimetype='text/event-stream', headers=headers)
|
||||||
|
|
||||||
|
# --- Main ---
|
||||||
|
# Use an ASGI server like Hypercorn: `hypercorn server:app`
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print("Run using an ASGI server, e.g., 'hypercorn flask_server:app'")
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
from pocketflow import AsyncFlow
|
||||||
|
from nodes import ProcessNode, ReviewNode, ResultNode
|
||||||
|
|
||||||
|
def create_feedback_flow():
|
||||||
|
"""Creates the minimal feedback workflow."""
|
||||||
|
process_node = ProcessNode()
|
||||||
|
review_node = ReviewNode()
|
||||||
|
result_node = ResultNode()
|
||||||
|
|
||||||
|
# Define transitions
|
||||||
|
process_node >> review_node
|
||||||
|
review_node - "approved" >> result_node
|
||||||
|
review_node - "rejected" >> process_node # Loop back
|
||||||
|
|
||||||
|
# Create the AsyncFlow
|
||||||
|
flow = AsyncFlow(start=process_node)
|
||||||
|
print("Minimal feedback flow created.")
|
||||||
|
return flow
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
from flow import qa_flow
|
||||||
|
|
||||||
|
# Example main function
|
||||||
|
# Please replace this with your own main function
|
||||||
|
def main():
|
||||||
|
shared = {
|
||||||
|
"question": "In one sentence, what's the end of universe?",
|
||||||
|
"answer": None
|
||||||
|
}
|
||||||
|
|
||||||
|
qa_flow.run(shared)
|
||||||
|
print("Question:", shared["question"])
|
||||||
|
print("Answer:", shared["answer"])
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import asyncio
|
||||||
|
from pocketflow import Node, AsyncNode
|
||||||
|
from utils.process_task import process_task
|
||||||
|
|
||||||
|
class ProcessNode(Node):
|
||||||
|
def prep(self, shared):
|
||||||
|
task_input = shared.get("task_input", "No input")
|
||||||
|
print("ProcessNode Prep")
|
||||||
|
return task_input
|
||||||
|
|
||||||
|
def exec(self, prep_res):
|
||||||
|
return process_task(prep_res)
|
||||||
|
|
||||||
|
def post(self, shared, prep_res, exec_res):
|
||||||
|
shared["processed_output"] = exec_res
|
||||||
|
print("ProcessNode Post: Output stored.")
|
||||||
|
return "default" # Go to ReviewNode
|
||||||
|
|
||||||
|
class ReviewNode(AsyncNode):
|
||||||
|
async def prep_async(self, shared):
|
||||||
|
review_event = shared.get("review_event")
|
||||||
|
queue = shared.get("sse_queue") # Expect queue in shared
|
||||||
|
processed_output = shared.get("processed_output", "N/A")
|
||||||
|
|
||||||
|
if not review_event or not queue:
|
||||||
|
print("ERROR: ReviewNode Prep - Missing review_event or sse_queue in shared store!")
|
||||||
|
return None # Signal failure
|
||||||
|
|
||||||
|
# Push status update to SSE queue
|
||||||
|
status_update = {
|
||||||
|
"status": "waiting_for_review",
|
||||||
|
"output_to_review": processed_output
|
||||||
|
}
|
||||||
|
await queue.put(status_update)
|
||||||
|
print("ReviewNode Prep: Put 'waiting_for_review' on SSE queue.")
|
||||||
|
|
||||||
|
return review_event # Return event for exec_async
|
||||||
|
|
||||||
|
async def exec_async(self, prep_res):
|
||||||
|
review_event = prep_res
|
||||||
|
if not review_event:
|
||||||
|
print("ReviewNode Exec: Skipping wait (no event from prep).")
|
||||||
|
return
|
||||||
|
print("ReviewNode Exec: Waiting on review_event...")
|
||||||
|
await review_event.wait()
|
||||||
|
print("ReviewNode Exec: review_event set.")
|
||||||
|
|
||||||
|
async def post_async(self, shared, prep_res, exec_res):
|
||||||
|
feedback = shared.get("feedback")
|
||||||
|
print(f"ReviewNode Post: Processing feedback '{feedback}'")
|
||||||
|
|
||||||
|
# Clear the event for potential loops
|
||||||
|
review_event = shared.get("review_event")
|
||||||
|
if review_event:
|
||||||
|
review_event.clear()
|
||||||
|
shared["feedback"] = None # Reset feedback
|
||||||
|
|
||||||
|
if feedback == "approved":
|
||||||
|
shared["final_result"] = shared.get("processed_output")
|
||||||
|
print("ReviewNode Post: Action=approved")
|
||||||
|
return "approved"
|
||||||
|
else:
|
||||||
|
print("ReviewNode Post: Action=rejected")
|
||||||
|
return "rejected"
|
||||||
|
|
||||||
|
class ResultNode(Node):
|
||||||
|
def prep(self, shared):
|
||||||
|
print("ResultNode Prep")
|
||||||
|
return shared.get("final_result", "No final result.")
|
||||||
|
|
||||||
|
def exec(self, prep_res):
|
||||||
|
print(f"--- FINAL RESULT ---")
|
||||||
|
print(prep_res)
|
||||||
|
print(f"--------------------")
|
||||||
|
return prep_res
|
||||||
|
|
||||||
|
def post(self, shared, prep_res, exec_res):
|
||||||
|
print("ResultNode Post: Flow finished.")
|
||||||
|
return None # End flow
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
pocketflow>=0.0.1
|
||||||
|
flask
|
||||||
|
hypercorn
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
/* Minimal functional styling */
|
||||||
|
body { font-family: sans-serif; margin: 15px; background-color: #fdfdfd; }
|
||||||
|
.container, .status-container { background: #fff; padding: 15px; border: 1px solid #eee; margin-bottom: 15px; border-radius: 4px; max-width: 600px;}
|
||||||
|
textarea { width: 95%; padding: 8px; margin-bottom: 10px; border: 1px solid #ccc; font-size: 1em; min-height: 50px; }
|
||||||
|
button { padding: 8px 12px; margin-right: 5px; cursor: pointer; border: 1px solid #ccc; border-radius: 3px;}
|
||||||
|
button:disabled { cursor: not-allowed; opacity: 0.6; }
|
||||||
|
#task-id-display { font-size: 0.85em; color: #666; margin-bottom: 5px; }
|
||||||
|
#status-display { font-weight: bold; margin-bottom: 10px; padding: 8px; background-color: #f0f0f0; border-radius: 3px;}
|
||||||
|
.hidden { display: none; }
|
||||||
|
.review-box, .result-box { border: 1px solid #ddd; padding: 10px; margin-top: 10px; background-color: #f9f9f9; }
|
||||||
|
pre { background-color: #eee; padding: 10px; border: 1px solid #ddd; white-space: pre-wrap; word-wrap: break-word; max-height: 200px; overflow-y: auto;}
|
||||||
|
.approve { background-color: #d4edda; border-color: #c3e6cb; }
|
||||||
|
.reject { background-color: #f8d7da; border-color: #f5c6cb; }
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Minimal Feedback Task (SSE)</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static_files', filename='style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Minimal Task Submitter (SSE)</h1>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<textarea id="task-input" rows="3" placeholder="Enter text to process..."></textarea>
|
||||||
|
<button id="submit-button">Submit</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-container">
|
||||||
|
<h2>Status</h2>
|
||||||
|
<div id="task-id-display">Task ID: N/A</div>
|
||||||
|
<div id="status-display">Submit a task.</div>
|
||||||
|
|
||||||
|
<div id="review-section" class="hidden review-box">
|
||||||
|
<h3>Review Output</h3>
|
||||||
|
<pre id="review-output"></pre>
|
||||||
|
<button id="approve-button" class="feedback-button approve">Approve</button>
|
||||||
|
<button id="reject-button" class="feedback-button reject">Reject</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="result-section" class="hidden result-box">
|
||||||
|
<h3>Final Result</h3>
|
||||||
|
<pre id="final-result"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const taskInput = document.getElementById('task-input');
|
||||||
|
const submitButton = document.getElementById('submit-button');
|
||||||
|
const taskIdDisplay = document.getElementById('task-id-display');
|
||||||
|
const statusDisplay = document.getElementById('status-display');
|
||||||
|
const reviewSection = document.getElementById('review-section');
|
||||||
|
const reviewOutput = document.getElementById('review-output');
|
||||||
|
const approveButton = document.getElementById('approve-button');
|
||||||
|
const rejectButton = document.getElementById('reject-button');
|
||||||
|
const resultSection = document.getElementById('result-section');
|
||||||
|
const finalResult = document.getElementById('final-result');
|
||||||
|
|
||||||
|
let currentTaskId = null;
|
||||||
|
let eventSource = null;
|
||||||
|
|
||||||
|
submitButton.addEventListener('click', handleSubmit);
|
||||||
|
approveButton.addEventListener('click', () => handleFeedback('approved'));
|
||||||
|
rejectButton.addEventListener('click', () => handleFeedback('rejected'));
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const data = taskInput.value.trim();
|
||||||
|
if (!data) return alert('Input is empty.');
|
||||||
|
|
||||||
|
resetUI();
|
||||||
|
statusDisplay.textContent = 'Submitting...';
|
||||||
|
submitButton.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/submit', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ data: data })
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Submit failed: ${response.status}`);
|
||||||
|
const result = await response.json();
|
||||||
|
currentTaskId = result.task_id;
|
||||||
|
taskIdDisplay.textContent = `Task ID: ${currentTaskId}`;
|
||||||
|
startSSEListener(currentTaskId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Submit error:', error);
|
||||||
|
statusDisplay.textContent = `Submit Error: ${error.message}`;
|
||||||
|
resetUI();
|
||||||
|
} finally {
|
||||||
|
submitButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSSEListener(taskId) {
|
||||||
|
closeSSEListener(); // Close existing connection
|
||||||
|
eventSource = new EventSource(`/stream/${taskId}`);
|
||||||
|
eventSource.onmessage = handleSSEMessage;
|
||||||
|
eventSource.onerror = handleSSEError;
|
||||||
|
eventSource.onopen = () => console.log(`SSE connected for ${taskId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSSEMessage(event) {
|
||||||
|
console.log("SSE data:", event.data);
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
updateUI(data);
|
||||||
|
} catch (e) { console.error("SSE parse error:", e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSSEError(error) {
|
||||||
|
console.error("SSE Error:", error);
|
||||||
|
statusDisplay.textContent = "Status stream error. Connection closed.";
|
||||||
|
closeSSEListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSSEListener() {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
eventSource = null;
|
||||||
|
console.log("SSE connection closed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUI(data) {
|
||||||
|
// Always update main status
|
||||||
|
statusDisplay.textContent = `Status: ${data.status || 'Unknown'}`;
|
||||||
|
|
||||||
|
// Hide sections, then show relevant one
|
||||||
|
reviewSection.classList.add('hidden');
|
||||||
|
resultSection.classList.add('hidden');
|
||||||
|
approveButton.disabled = false; // Re-enable by default
|
||||||
|
rejectButton.disabled = false;
|
||||||
|
|
||||||
|
switch(data.status) {
|
||||||
|
case 'waiting_for_review':
|
||||||
|
reviewOutput.textContent = data.output_to_review || '';
|
||||||
|
reviewSection.classList.remove('hidden');
|
||||||
|
break;
|
||||||
|
case 'processing_feedback':
|
||||||
|
approveButton.disabled = true; // Disable while processing
|
||||||
|
rejectButton.disabled = true;
|
||||||
|
break;
|
||||||
|
case 'completed':
|
||||||
|
finalResult.textContent = data.final_result || '';
|
||||||
|
resultSection.classList.remove('hidden');
|
||||||
|
closeSSEListener();
|
||||||
|
break;
|
||||||
|
case 'failed':
|
||||||
|
case 'feedback_error':
|
||||||
|
statusDisplay.textContent = `Status: ${data.status} - ${data.error || 'Unknown error'}`;
|
||||||
|
closeSSEListener();
|
||||||
|
break;
|
||||||
|
case 'finished_incomplete':
|
||||||
|
statusDisplay.textContent = `Status: Flow finished unexpectedly.`;
|
||||||
|
closeSSEListener();
|
||||||
|
break;
|
||||||
|
case 'stream_closed':
|
||||||
|
// Server closed the stream gracefully (usually after completed/failed)
|
||||||
|
if (!['completed', 'failed', 'finished_incomplete'].includes(tasks[currentTaskId]?.status)) {
|
||||||
|
statusDisplay.textContent = "Status: Connection closed by server.";
|
||||||
|
}
|
||||||
|
closeSSEListener();
|
||||||
|
break;
|
||||||
|
case 'pending':
|
||||||
|
case 'running':
|
||||||
|
// Just update status text, wait for next message
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFeedback(feedbackValue) {
|
||||||
|
if (!currentTaskId) return;
|
||||||
|
approveButton.disabled = true;
|
||||||
|
rejectButton.disabled = true;
|
||||||
|
statusDisplay.textContent = `Sending ${feedbackValue}...`; // Optimistic UI update
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/feedback/${currentTaskId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ feedback: feedbackValue })
|
||||||
|
});
|
||||||
|
if (!response.ok) { // Rely on SSE for status change or error reporting
|
||||||
|
const errorData = await response.json().catch(()=>({error: `Feedback failed: ${response.status}`}));
|
||||||
|
throw new Error(errorData.error);
|
||||||
|
}
|
||||||
|
console.log(`Feedback ${feedbackValue} POST successful.`);
|
||||||
|
// Successful POST - wait for SSE to update status to 'processing', then 'running' etc.
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Feedback error:', error);
|
||||||
|
statusDisplay.textContent = `Feedback Error: ${error.message}`;
|
||||||
|
// Re-enable buttons if feedback POST failed
|
||||||
|
approveButton.disabled = false;
|
||||||
|
rejectButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetUI() {
|
||||||
|
closeSSEListener();
|
||||||
|
currentTaskId = null;
|
||||||
|
taskIdDisplay.textContent = 'Task ID: N/A';
|
||||||
|
statusDisplay.textContent = 'Submit a task.';
|
||||||
|
reviewSection.classList.add('hidden');
|
||||||
|
resultSection.classList.add('hidden');
|
||||||
|
taskInput.value = '';
|
||||||
|
submitButton.disabled = false;
|
||||||
|
approveButton.disabled = false;
|
||||||
|
rejectButton.disabled = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import time
|
||||||
|
|
||||||
|
def process_task(input_data):
|
||||||
|
"""Minimal simulation of processing the input data."""
|
||||||
|
print(f"Processing: '{input_data[:50]}...'")
|
||||||
|
|
||||||
|
# Simulate work
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
processed_result = f"Processed: {input_data}"
|
||||||
|
print(f"Finished processing.")
|
||||||
|
return processed_result
|
||||||
|
|
||||||
|
# We don't need a separate utils/call_llm.py for this minimal example,
|
||||||
|
# but you would add it here if ProcessNode used an LLM.
|
||||||
|
|
||||||
Loading…
Reference in New Issue