├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── crontab ├── daily.sh ├── requirements.txt ├── src ├── FileTransmit.py ├── NodeData.py ├── ServerTee.py ├── WorkFlow.py ├── __version__.py ├── llm.py ├── main.py ├── process_handler.py ├── run_graph.py └── util.py └── supervisord.conf /.gitignore: -------------------------------------------------------------------------------- 1 | workspace/ 2 | temp/ 3 | *.log 4 | *.png 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | cover/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | .pybuilder/ 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | # For a library or package, you might want to ignore these files since the code is 92 | # intended to run in multiple environments; otherwise, check them in: 93 | # .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # UV 103 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 104 | # This is especially recommended for binary packages to ensure reproducibility, and is more 105 | # commonly ignored for libraries. 106 | #uv.lock 107 | 108 | # poetry 109 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 110 | # This is especially recommended for binary packages to ensure reproducibility, and is more 111 | # commonly ignored for libraries. 112 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 113 | #poetry.lock 114 | 115 | # pdm 116 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 117 | #pdm.lock 118 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 119 | # in version control. 120 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 121 | .pdm.toml 122 | .pdm-python 123 | .pdm-build/ 124 | 125 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 126 | __pypackages__/ 127 | 128 | # Celery stuff 129 | celerybeat-schedule 130 | celerybeat.pid 131 | 132 | # SageMath parsed files 133 | *.sage.py 134 | 135 | # Environments 136 | .env 137 | .venv 138 | env/ 139 | venv/ 140 | ENV/ 141 | env.bak/ 142 | venv.bak/ 143 | 144 | # Spyder project settings 145 | .spyderproject 146 | .spyproject 147 | 148 | # Rope project settings 149 | .ropeproject 150 | 151 | # mkdocs documentation 152 | /site 153 | 154 | # mypy 155 | .mypy_cache/ 156 | .dmypy.json 157 | dmypy.json 158 | 159 | # Pyre type checker 160 | .pyre/ 161 | 162 | # pytype static type analyzer 163 | .pytype/ 164 | 165 | # Cython debug symbols 166 | cython_debug/ 167 | 168 | # PyCharm 169 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 170 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 171 | # and can be added to the global gitignore or merged into this file. For a more nuclear 172 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 173 | #.idea/ 174 | 175 | # PyPI configuration file 176 | .pypirc 177 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim 2 | 3 | # Change the timezone to UTC+8 4 | RUN ln -sf /usr/share/zoneinfo/Asia/Singapore /etc/localtime 5 | 6 | # Install necessary packages 7 | RUN apt-get update && apt-get install -y cron supervisor 8 | 9 | # Set working directory 10 | WORKDIR /app 11 | 12 | # Install dependencies 13 | COPY requirements.txt requirements.txt 14 | RUN pip install -r requirements.txt 15 | 16 | # Copy files to the /app directory 17 | COPY ./src/ ./src/ 18 | 19 | # Ensure the workspace directory exists 20 | RUN mkdir -p /app/src/workspace && mkdir -p /app/src/log 21 | 22 | # for cron daily task 23 | COPY daily.sh daily.sh 24 | RUN chmod +x /app/daily.sh 25 | 26 | # Copy cron configuration 27 | COPY crontab /mycron 28 | RUN chmod 644 /mycron 29 | RUN crontab /mycron 30 | 31 | # Copy Supervisor configuration 32 | COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf 33 | 34 | # Start Supervisor 35 | CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 HomunMage 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LangGraph-GUI-backend 2 | 3 | fastapi ver LangGraph-GUI backend 4 | 5 | The backend supports running LangGraph-GUI workflow json using localLLM such ollama. 6 | 7 | For more infomation, please see official site: [LangGraph-GUI.github.io](https://LangGraph-GUI.github.io) 8 | 9 | ## Environment Setup 10 | 11 | To install the required dependencies for LangGraph and server, run: 12 | 13 | ```bash 14 | pip install -r requirements.txt 15 | ``` 16 | 17 | ## Running the server 18 | 19 | To run a local language model, first start Ollama in a separate terminal: 20 | 21 | ```bash 22 | ollama serve 23 | ``` 24 | 25 | At another thread, up the server 26 | 27 | ```bash 28 | mkdir src/workspace 29 | cd src/workspace 30 | python ../server.py 31 | ``` 32 | 33 | ## Chnage Log 34 | 35 | see: [root repo CHANGELOG](https://github.com/LangGraph-GUI/LangGraph-GUI/blob/main/CHANGELOG.md) 36 | -------------------------------------------------------------------------------- /crontab: -------------------------------------------------------------------------------- 1 | 0 0 * * * /app/daily.sh >> /app/src/workspace/cron.log 2>&1 2 | 3 | # need EOF -------------------------------------------------------------------------------- /daily.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | date 3 | supervisorctl restart fastapi -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | langchain 2 | langchain-community 3 | langchain-core 4 | langgraph 5 | fastapi 6 | uvicorn 7 | httpx 8 | 9 | openai 10 | pyyaml 11 | python-multipart 12 | 13 | langchain-ollama -------------------------------------------------------------------------------- /src/FileTransmit.py: -------------------------------------------------------------------------------- 1 | # FileTransmit.py 2 | 3 | from typing import List 4 | import os 5 | import zipfile 6 | import io 7 | from datetime import datetime 8 | import json 9 | 10 | from fastapi import HTTPException, BackgroundTasks 11 | from fastapi import APIRouter, File, UploadFile, HTTPException 12 | from fastapi.responses import JSONResponse, FileResponse 13 | from fastapi.responses import StreamingResponse 14 | from fastapi.responses import Response 15 | 16 | 17 | # Create a router instance 18 | file_router = APIRouter() 19 | 20 | # Utility function to get or create a user's workspace directory 21 | def get_or_create_workspace(username: str) -> str: 22 | """ 23 | Ensures the workspace directory for a given username exists. 24 | Creates the directory if it doesn't exist. 25 | """ 26 | workspace_path = os.path.join('./workspace/', username) 27 | if not os.path.exists(workspace_path): 28 | os.makedirs(workspace_path) 29 | print(f"Created workspace for {username} at {workspace_path}") 30 | return workspace_path 31 | 32 | 33 | @file_router.get('/download/{username}') 34 | async def download_workspace(username: str): 35 | try: 36 | user_workspace = get_or_create_workspace(username) 37 | 38 | # Create a zip file from the user's workspace directory 39 | zip_filename = f'{username}_workspace.zip' 40 | zip_buffer = io.BytesIO() # in-memory buffer to hold the zip file 41 | 42 | # Create a ZipFile object in write mode 43 | with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: 44 | # Walk through the workspace directory and add files to the zip 45 | for root, dirs, files in os.walk(user_workspace): 46 | for file in files: 47 | file_path = os.path.join(root, file) 48 | arcname = os.path.relpath(file_path, user_workspace) # Store files relative to the workspace 49 | zip_file.write(file_path, arcname) 50 | 51 | # Seek to the beginning of the buffer before sending it 52 | zip_buffer.seek(0) 53 | 54 | # Return the zip file as a Response, without triggering stat checks 55 | return Response( 56 | zip_buffer.read(), # Read the content of the BytesIO object 57 | media_type="application/zip", # Set the media type to zip file 58 | headers={"Content-Disposition": f"attachment; filename={zip_filename}"} 59 | ) 60 | 61 | except Exception as e: 62 | print(f"Error creating zip: {e}") 63 | raise HTTPException(status_code=500, detail=f"Failed to create zip file: {str(e)}") 64 | 65 | # Route to handle file uploads with username 66 | @file_router.post('/upload/{username}') 67 | async def upload_file(username: str, files: List[UploadFile] = File(...)): 68 | user_workspace = get_or_create_workspace(username) 69 | 70 | if not files: 71 | raise HTTPException(status_code=400, detail="No files selected for uploading") 72 | 73 | # Save each uploaded file to the user's workspace 74 | for file in files: 75 | file_path = os.path.join(user_workspace, file.filename) 76 | with open(file_path, 'wb') as f: 77 | f.write(await file.read()) 78 | print(f"Uploaded file: {file.filename} to {user_workspace}") 79 | 80 | return JSONResponse(content={"message": "Files successfully uploaded"}, status_code=200) 81 | 82 | # Route to handle cleaning the user's workspace 83 | @file_router.post('/clean-cache/{username}') 84 | async def clean_cache(username: str): 85 | try: 86 | # Get or create the user's workspace 87 | user_workspace = get_or_create_workspace(username) 88 | 89 | # Delete all files in the user's workspace 90 | for root, dirs, files in os.walk(user_workspace): 91 | for file in files: 92 | file_path = os.path.join(root, file) 93 | os.remove(file_path) 94 | print(f"Deleted file: {file_path}") 95 | 96 | return JSONResponse(content={"message": "Workspace successfully cleaned"}, status_code=200) 97 | 98 | except Exception as e: 99 | print(f"Error cleaning workspace: {e}") 100 | raise HTTPException(status_code=500, detail=f"Failed to clean workspace: {str(e)}") 101 | -------------------------------------------------------------------------------- /src/NodeData.py: -------------------------------------------------------------------------------- 1 | # NodeData.py 2 | 3 | from dataclasses import dataclass, asdict, field 4 | from typing import List, Optional, Dict 5 | 6 | @dataclass 7 | class Serializable: 8 | def to_dict(self): 9 | return asdict(self) 10 | 11 | @classmethod 12 | def from_dict(cls, data): 13 | return cls(**data) 14 | 15 | @dataclass 16 | class NodeData(Serializable): 17 | 18 | # Graph Feature 19 | uniq_id: str = "" 20 | 21 | # Store external properties in a dictionary 22 | ext: dict = field(default_factory=dict) 23 | 24 | 25 | nexts: List[int] = field(default_factory=list) 26 | 27 | # LangGraph attribute 28 | # "START", "STEP", "TOOL", "CONDITION" 29 | type: str = "START" 30 | 31 | # AGENT 32 | name: str = "" 33 | description: str = "" 34 | 35 | # STEP 36 | tool: str = "" 37 | 38 | # CONDITION 39 | true_next: Optional[int] = None 40 | false_next: Optional[int] = None 41 | 42 | 43 | def to_dict(self): 44 | return asdict(self) 45 | 46 | @classmethod 47 | def from_dict(cls, data): 48 | return cls(**data) 49 | -------------------------------------------------------------------------------- /src/ServerTee.py: -------------------------------------------------------------------------------- 1 | # ServerTee.py 2 | 3 | import sys 4 | import datetime 5 | from threading import Lock 6 | from queue import Queue, Empty 7 | 8 | class ServerTee: 9 | def __init__(self, filename, mode='a'): 10 | self.file = open(filename, mode) 11 | self.stdout = sys.stdout 12 | self.lock = Lock() 13 | self.subscribers = [] 14 | sys.stdout = self 15 | 16 | def write(self, message): 17 | with self.lock: 18 | timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') 19 | message_with_timestamp = f"{timestamp} - {message}" 20 | # Ensure the final text ends with '\n' 21 | if not message_with_timestamp.endswith('\n'): 22 | message_with_timestamp += '\n' 23 | self.stdout.write(message_with_timestamp) 24 | self.stdout.flush() # Ensure immediate output to console 25 | self.file.write(message_with_timestamp) 26 | self.file.flush() # Ensure immediate write to file 27 | 28 | def flush(self): 29 | with self.lock: 30 | self.stdout.flush() 31 | self.file.flush() 32 | 33 | def close(self): 34 | with self.lock: 35 | sys.stdout = self.stdout 36 | self.file.close() 37 | 38 | def notify_subscribers(self, message): 39 | for subscriber in self.subscribers: 40 | subscriber.put(message) 41 | 42 | def subscribe(self): 43 | q = Queue() 44 | self.subscribers.append(q) 45 | return q 46 | 47 | def unsubscribe(self, q): 48 | self.subscribers.remove(q) 49 | 50 | def stream_to_frontend(self): 51 | q = self.subscribe() 52 | try: 53 | while True: 54 | try: 55 | message = q.get(timeout=1) 56 | yield message + "\n" 57 | except Empty: 58 | continue 59 | finally: 60 | self.unsubscribe(q) -------------------------------------------------------------------------------- /src/WorkFlow.py: -------------------------------------------------------------------------------- 1 | # WorkFlow.py 2 | 3 | import os 4 | import re 5 | import json 6 | from typing import Dict, List, TypedDict, Any, Annotated, Callable, Literal, Optional, Union 7 | import operator 8 | import inspect 9 | 10 | from langgraph.graph import StateGraph, END, START 11 | 12 | from NodeData import NodeData 13 | from llm import get_llm, clip_history, create_llm_chain 14 | from util import logger 15 | 16 | # Tool registry to hold information about tools 17 | tool_registry: Dict[str, Callable] = {} 18 | tool_info_registry: Dict[str, str] = {} 19 | 20 | # Subgraph registry to hold all the subgraph 21 | subgraph_registry: Dict[str, Any] = {} 22 | 23 | # Decorator to register tools 24 | def tool(func: Callable) -> Callable: 25 | signature = inspect.signature(func) 26 | docstring = func.__doc__ or "" 27 | tool_info = f"{func.__name__}{signature} - {docstring}" 28 | tool_registry[func.__name__] = func 29 | tool_info_registry[func.__name__] = tool_info 30 | return func 31 | 32 | def parse_nodes_from_json(graph_data: Dict[str, Any]) -> Dict[str, NodeData]: 33 | """ 34 | Parses node data from a subgraph's JSON structure. 35 | 36 | Args: 37 | graph_data: A dictionary representing a subgraph. 38 | Returns: 39 | A dictionary of NodeData objects keyed by their unique IDs. 40 | """ 41 | node_map = {} 42 | for node_data in graph_data.get("nodes", []): 43 | node = NodeData.from_dict(node_data) 44 | node_map[node.uniq_id] = node 45 | return node_map 46 | 47 | def find_nodes_by_type(node_map: Dict[str, NodeData], node_type: str) -> List[NodeData]: 48 | return [node for node in node_map.values() if node.type == node_type] 49 | 50 | 51 | class PipelineState(TypedDict): 52 | history: Annotated[str, operator.add] 53 | task: Annotated[str, operator.add] 54 | condition: Annotated[bool, lambda x, y: y] 55 | 56 | def execute_step(name:str, state: PipelineState, prompt_template: str, llm) -> PipelineState: 57 | logger(f"{name} is working...") 58 | state["history"] = clip_history(state["history"]) 59 | 60 | generation = create_llm_chain(prompt_template, llm, state["history"]) 61 | data = json.loads(generation) 62 | 63 | state["history"] += "\n" + json.dumps(data) 64 | state["history"] = clip_history(state["history"]) 65 | 66 | logger(state["history"]) 67 | return state 68 | 69 | def execute_tool(name: str, state: PipelineState, prompt_template: str, llm) -> PipelineState: 70 | 71 | logger(f"{name} is working...") 72 | 73 | state["history"] = clip_history(state["history"]) 74 | 75 | generation = create_llm_chain(prompt_template, llm, state["history"]) 76 | 77 | # Sanitize the generation output by removing invalid control characters 78 | sanitized_generation = re.sub(r'[\x00-\x1F\x7F]', '', generation) 79 | 80 | logger(sanitized_generation) 81 | 82 | data = json.loads(sanitized_generation) 83 | 84 | choice = data 85 | tool_name = choice["function"] 86 | args = choice["args"] 87 | 88 | if tool_name not in tool_registry: 89 | raise ValueError(f"Tool {tool_name} not found in registry.") 90 | 91 | result = tool_registry[tool_name](*args) 92 | 93 | # Flatten args to a string 94 | flattened_args = ', '.join(map(str, args)) 95 | 96 | logger(f"\nExecuted Tool: {tool_name}({flattened_args}) Result is: {result}") 97 | 98 | 99 | state["history"] += f"\nExecuted {tool_name}({flattened_args}) Result is: {result}" 100 | state["history"] = clip_history(state["history"]) 101 | 102 | return state 103 | 104 | def condition_switch(name:str, state: PipelineState, prompt_template: str, llm) -> PipelineState: 105 | logger(f"{name} is working...") 106 | 107 | state["history"] = clip_history(state["history"]) 108 | 109 | generation = create_llm_chain(prompt_template, llm, state["history"]) 110 | data = json.loads(generation) 111 | 112 | condition = data["switch"] 113 | state["condition"] = condition 114 | 115 | state["history"] += f"\nCondition is {condition}" 116 | state["history"] = clip_history(state["history"]) 117 | 118 | return state 119 | 120 | def info_add(name: str, state: PipelineState, information: str, llm) -> PipelineState: 121 | logger(f"{name} is adding information...") 122 | 123 | # Append the provided information to the history 124 | state["history"] += "\n" + information 125 | state["history"] = clip_history(state["history"]) 126 | 127 | return state 128 | 129 | 130 | def sg_add(name:str, state: PipelineState, sg_name: str) -> PipelineState: 131 | logger(f"{name} is working, it is a subgraph node call {sg_name} ...") 132 | subgraph = subgraph_registry[sg_name] 133 | response = subgraph.invoke( 134 | PipelineState( 135 | history=state["history"], 136 | task=state["task"], 137 | condition=state["condition"] 138 | ) 139 | ) 140 | state["history"] = response["history"] 141 | state["task"] = response["task"] 142 | state["condition"] = response["condition"] 143 | return state 144 | 145 | 146 | def conditional_edge(state: PipelineState) -> Literal["True", "False"]: 147 | if state["condition"] in ["True", "true", True]: 148 | return "True" 149 | else: 150 | return "False" 151 | 152 | def build_subgraph(node_map: Dict[str, NodeData], llm) -> StateGraph: 153 | # Define the state machine 154 | subgraph = StateGraph(PipelineState) 155 | 156 | # Start node, only one start point 157 | start_node = find_nodes_by_type(node_map, "START")[0] 158 | logger(f"Start root ID: {start_node.uniq_id}") 159 | 160 | # Step nodes 161 | step_nodes = find_nodes_by_type(node_map, "STEP") 162 | for current_node in step_nodes: 163 | if current_node.tool: 164 | tool_info = tool_info_registry[current_node.tool] 165 | prompt_template = f""" 166 | history: {{history}} 167 | {current_node.description} 168 | Available tool: {tool_info} 169 | Based on Available tool, arguments in the json format: 170 | "function": "", "args": [, , ...] 171 | 172 | next stage directly parse then run (,, ...) make sure syntax is right json and align function siganture 173 | """ 174 | subgraph.add_node( 175 | current_node.uniq_id, 176 | lambda state, template=prompt_template, llm=llm, name=current_node.name : execute_tool(name, state, template, llm) 177 | ) 178 | else: 179 | prompt_template=f""" 180 | history: {{history}} 181 | {current_node.description} 182 | you reply in the json format 183 | """ 184 | subgraph.add_node( 185 | current_node.uniq_id, 186 | lambda state, template=prompt_template, llm=llm, name=current_node.name: execute_step(name, state, template, llm) 187 | ) 188 | 189 | # Add INFO nodes 190 | info_nodes = find_nodes_by_type(node_map, "INFO") 191 | for info_node in info_nodes: 192 | # INFO nodes just append predefined information to the state history 193 | subgraph.add_node( 194 | info_node.uniq_id, 195 | lambda state, template=info_node.description, llm=llm, name=info_node.name: info_add(name, state, template, llm) 196 | ) 197 | 198 | # Add SUBGRAPH nodes 199 | subgraph_nodes = find_nodes_by_type(node_map, "SUBGRAPH") 200 | for sg_node in subgraph_nodes: 201 | subgraph.add_node( 202 | sg_node.uniq_id, 203 | lambda state, llm=llm, name=sg_node.name, sg_name=sg_node.name: sg_add(name, state, sg_name) 204 | ) 205 | 206 | # Edges 207 | # Find all next nodes from start_node 208 | next_node_ids = start_node.nexts 209 | next_nodes = [node_map[next_id] for next_id in next_node_ids] 210 | 211 | for next_node in next_nodes: 212 | logger(f"Next node ID: {next_node.uniq_id}, Type: {next_node.type}") 213 | subgraph.add_edge(START, next_node.uniq_id) 214 | 215 | # Find all next nodes from step_nodes 216 | for node in step_nodes + info_nodes + subgraph_nodes: 217 | next_nodes = [node_map[next_id] for next_id in node.nexts] 218 | 219 | for next_node in next_nodes: 220 | logger(f"{node.name} {node.uniq_id}'s next node: {next_node.name} {next_node.uniq_id}, Type: {next_node.type}") 221 | subgraph.add_edge(node.uniq_id, next_node.uniq_id) 222 | 223 | # Find all condition nodes 224 | condition_nodes = find_nodes_by_type(node_map, "CONDITION") 225 | for condition in condition_nodes: 226 | condition_template = f"""{condition.description} 227 | history: {{history}}, decide the condition result in the json format: 228 | "switch": True/False 229 | """ 230 | subgraph.add_node( 231 | condition.uniq_id, 232 | lambda state, template=condition_template, llm=llm, name=condition.name: condition_switch(name, state, template, llm) 233 | ) 234 | 235 | logger(f"{condition.name} {condition.uniq_id}'s condition") 236 | logger(f"true will go {condition.true_next}") 237 | logger(f"false will go {condition.false_next}") 238 | subgraph.add_conditional_edges( 239 | condition.uniq_id, 240 | conditional_edge, 241 | { 242 | "True": condition.true_next if condition.true_next else END, 243 | "False": condition.false_next if condition.false_next else END 244 | } 245 | ) 246 | return subgraph.compile() 247 | 248 | 249 | class MainGraphState(TypedDict): 250 | input: Union[str, None] 251 | 252 | def invoke_root(state: MainGraphState): 253 | subgraph = subgraph_registry["root"] 254 | response = subgraph.invoke( 255 | PipelineState( 256 | history="", 257 | task="", 258 | condition=False 259 | ) 260 | ) 261 | return {"input": None} 262 | 263 | 264 | def run_workflow_as_server(llm): 265 | # Load subgraph data 266 | with open("workflow.json", 'r') as file: 267 | graphs = json.load(file) 268 | 269 | # Process each subgraph 270 | for graph in graphs: 271 | subgraph_name = graph.get("name") 272 | node_map = parse_nodes_from_json(graph) 273 | 274 | # Register the tool functions dynamically if has tool node, must before build graph 275 | for tool_node in find_nodes_by_type(node_map, "TOOL"): 276 | tool_code = f"{tool_node.description}" 277 | exec(tool_code, globals()) 278 | 279 | 280 | subgraph = build_subgraph(node_map, llm) 281 | subgraph_registry[subgraph_name] = subgraph 282 | 283 | 284 | # Main Graph 285 | main_graph = StateGraph(MainGraphState) 286 | main_graph.add_node("subgraph", invoke_root) 287 | main_graph.set_entry_point("subgraph") 288 | main_graph = main_graph.compile() 289 | 290 | 291 | # ========================== 292 | # Run 293 | # ========================== 294 | for state in main_graph.stream( 295 | { 296 | "input": None, 297 | } 298 | ): 299 | logger(state) -------------------------------------------------------------------------------- /src/__version__.py: -------------------------------------------------------------------------------- 1 | __version__ = "2.0.0" -------------------------------------------------------------------------------- /src/llm.py: -------------------------------------------------------------------------------- 1 | # llm.py 2 | 3 | import os 4 | import json 5 | import requests 6 | from typing import Optional 7 | 8 | from pydantic import BaseModel, Field 9 | 10 | from langchain_core.prompts import PromptTemplate 11 | from langchain_core.output_parsers import StrOutputParser 12 | 13 | from util import logger 14 | 15 | 16 | # Clip the history for limited token 17 | def clip_history(history: str, max_chars: int = 16000) -> str: 18 | if len(history) > max_chars: 19 | return history[-max_chars:] 20 | return history 21 | 22 | def get_llm(llm_model, api_key): 23 | 24 | # openai case 25 | if "gpt" in llm_model.lower(): # If the llm contains 'gpt', use ChatOpenAI 26 | from langchain_community.chat_models import ChatOpenAI 27 | os.environ["OPENAI_API_KEY"] = api_key 28 | llm = ChatOpenAI(temperature=0, model="gpt-4o-mini").bind(response_format={"type": "json_object"}) 29 | logger("Using gpt-4o-mini") 30 | 31 | return llm 32 | # cannot work now, need langchain fix error 33 | if "google" in llm_model.lower(): 34 | logger("no suport google LLM") 35 | return None 36 | 37 | # ollama case 38 | if llm_model: 39 | from langchain_ollama import ChatOllama 40 | ollama_base_url = os.environ.get("OLLAMA_BASE_URL", "http://ollama:11434") # Default value if envvar is not set 41 | llm = ChatOllama( 42 | model=llm_model, 43 | base_url=ollama_base_url, 44 | format="json", 45 | temperature=0) 46 | 47 | logger(f"Using {llm_model}") 48 | return llm 49 | 50 | 51 | 52 | def ChatBot(llm, question): 53 | # Define the prompt template 54 | template = """ 55 | {question} 56 | you reply json in {{ reply:"" }} 57 | """ 58 | 59 | prompt = PromptTemplate.from_template(clip_history(template)) 60 | 61 | # Format the prompt with the input variable 62 | formatted_prompt = prompt.format(question=question) 63 | 64 | llm_chain = prompt | llm | StrOutputParser() 65 | generation = llm_chain.invoke(formatted_prompt) 66 | 67 | data = json.loads(generation) 68 | reply = data.get("reply", "") 69 | 70 | return reply 71 | 72 | 73 | def create_llm_chain(prompt_template: str, llm, history: str) -> str: 74 | """ 75 | Creates and invokes an LLM chain using the prompt template and the history. 76 | """ 77 | prompt = PromptTemplate.from_template(prompt_template) 78 | llm_chain = prompt | llm | StrOutputParser() 79 | inputs = {"history": history} 80 | generation = llm_chain.invoke(inputs) 81 | 82 | return generation 83 | 84 | def create_llm_chain_google(prompt_template: str, llm, history: Optional[str] = None) -> str: 85 | url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent" 86 | 87 | headers = { 88 | "Content-Type": "application/json" 89 | } 90 | 91 | # If history exists, include it in the prompt 92 | full_prompt = f"{history}\n{prompt_template}, you reply in json file" if history else prompt_template 93 | 94 | data = { 95 | "contents": [{ 96 | "parts": [{"text": full_prompt}] 97 | }] 98 | } 99 | 100 | params = { 101 | "key": "your google key" 102 | } 103 | 104 | try: 105 | response = requests.post(url, headers=headers, json=data, params=params) 106 | response.raise_for_status() 107 | 108 | json_response = response.json() 109 | 110 | # Extract the text from candidates[0].content.parts[0].text 111 | if (json_response.get("candidates") and 112 | len(json_response["candidates"]) > 0 and 113 | json_response["candidates"][0].get("content") and 114 | json_response["candidates"][0]["content"].get("parts") and 115 | len(json_response["candidates"][0]["content"]["parts"]) > 0): 116 | 117 | output = json_response["candidates"][0]["content"]["parts"][0]["text"] 118 | else: 119 | raise ValueError("Unexpected response structure from Gemini API") 120 | 121 | output = str(output) 122 | output = output[7:-3] 123 | output = json.dumps({"output": output}) 124 | logger("printing:") 125 | logger(output) 126 | 127 | return output 128 | 129 | except requests.exceptions.RequestException as e: 130 | raise Exception(f"API request failed: {str(e)}") 131 | except ValueError as e: 132 | raise Exception(f"Error processing response: {str(e)}") -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | # server.py 2 | 3 | import os 4 | from datetime import datetime 5 | import httpx 6 | from typing import Dict 7 | import asyncio 8 | 9 | from fastapi import FastAPI, HTTPException, Request 10 | from fastapi.responses import JSONResponse, StreamingResponse 11 | from fastapi.middleware.cors import CORSMiddleware 12 | 13 | from ServerTee import ServerTee 14 | from process_handler import ProcessHandler 15 | from FileTransmit import file_router 16 | 17 | # log name as today's date in YYYY-MM-DD format 18 | today_date = datetime.now().strftime("%Y-%m-%d") 19 | # Create log file path dynamically based on the date 20 | log_file_path = f"log/{today_date}.log" 21 | # Initialize ServerTee with the dynamically generated log file path 22 | tee = ServerTee(log_file_path) 23 | # Print the log file path for reference 24 | print(log_file_path) 25 | 26 | # Initialize FastAPI app 27 | app = FastAPI() 28 | 29 | # Add CORS middleware 30 | origins = [ 31 | "*" 32 | ] 33 | 34 | app.add_middleware( 35 | CORSMiddleware, 36 | allow_origins=origins, # List of allowed origins 37 | allow_credentials=True, 38 | allow_methods=["*"], # Allows all HTTP methods (GET, POST, PUT, DELETE, etc.) 39 | allow_headers=["*"], # Allows all headers 40 | ) 41 | 42 | # Dictionary to store ProcessHandler instances per user 43 | handlers = {} 44 | 45 | 46 | @app.post('/chatbot/{username}') 47 | async def process_string(request: Request, username: str): 48 | # Get the JSON data from the request 49 | data = await request.json() 50 | input_string = data.get('input_string', '') 51 | llm_model = data.get('llm_model', '') # Default to 'gemma2' if not provided 52 | api_key = data.get('api_key', '') 53 | 54 | # Process the string using the dynamically provided llm_model and api_key 55 | result = await ChatBot(get_llm(llm_model, api_key), input_string) 56 | 57 | # Return the result as JSON 58 | return JSONResponse(content={'result': result}) 59 | 60 | 61 | @app.post('/run/{username}') 62 | async def run_script(request: Request, username: str): 63 | user_workspace = os.path.join("workspace", username) 64 | 65 | data = await request.json() 66 | llm_model = data.get('llm_model', '') 67 | api_key = data.get('api_key', '') 68 | 69 | command = [ 70 | "python", "../../run_graph.py", 71 | "--llm", llm_model, 72 | "--key", api_key 73 | ] 74 | 75 | # Get or create a handler for the user 76 | if username not in handlers: 77 | handlers[username] = ProcessHandler() 78 | 79 | handler = handlers[username] 80 | # start process in background 81 | async def stream_response(): 82 | asyncio.create_task(handler.run(command, user_workspace)) # start the process as a task 83 | async for output in handler.get_stream(): 84 | if isinstance(output, dict): 85 | yield f"data: {output}\n\n" # Send final status 86 | break 87 | yield f"data: {output}\n\n" 88 | 89 | return StreamingResponse(stream_response(), media_type="text/event-stream") 90 | 91 | @app.get('/status/{username}') 92 | async def check_status(username: str): 93 | # Check if the handler exists and retrieve its status 94 | if username in handlers: 95 | status = await handlers[username].status() # Note: status() is an async function 96 | return {"running": status["is_running"]} # Make sure to return running status 97 | return {"running": False} 98 | 99 | 100 | # Include file router 101 | app.include_router(file_router) 102 | 103 | # Catch-all route for unmatched GET requests 104 | @app.api_route("/{anypath:path}", methods=["GET"]) 105 | async def catch_all(request: Request, anypath: str): 106 | print(f"Unmatched GET request: {anypath}") 107 | return JSONResponse(content={"message": f"Route {anypath} not found"}, status_code=404) 108 | 109 | 110 | # Run the app using Uvicorn 111 | if __name__ == "__main__": 112 | import uvicorn 113 | 114 | backend_port = int(os.environ.get("BACKEND_PORT", 8000)) # Default to 8000 if not set 115 | uvicorn.run(app, host="0.0.0.0", port=backend_port, reload=True) -------------------------------------------------------------------------------- /src/process_handler.py: -------------------------------------------------------------------------------- 1 | # process_handler.py 2 | 3 | import asyncio 4 | from asyncio import Queue as AsyncQueue 5 | import sys 6 | 7 | class ProcessHandler: 8 | def __init__(self): 9 | self._process = None 10 | self._output_queue = AsyncQueue() # Use asyncio.Queue 11 | self._is_running = False 12 | self._is_starting = False 13 | self._stream_tasks = [] # Store stream tasks 14 | 15 | async def run(self, command: list, cwd: str): 16 | if self._is_running or self._is_starting: 17 | await self._output_queue.put({"status": "error", "message": "Process already running"}) 18 | return 19 | try: 20 | self._is_starting = True 21 | # clear the queue before run 22 | while not self._output_queue.empty(): 23 | self._output_queue.get_nowait() 24 | 25 | self._process = await asyncio.create_subprocess_exec( 26 | *command, 27 | cwd=cwd, 28 | stdout=asyncio.subprocess.PIPE, 29 | stderr=asyncio.subprocess.PIPE 30 | ) 31 | self._is_running = True 32 | self._is_starting = False 33 | 34 | async def stream_output(stream, prefix): 35 | while True: 36 | line = await stream.readline() 37 | if line: 38 | message = f"{prefix}{line.decode().strip()}" 39 | print(message,flush=True) #flush output immediately 40 | if prefix == "STDOUT: ": # only add stdout 41 | await self._output_queue.put(message) 42 | 43 | else: 44 | break 45 | 46 | # Create tasks and store them to cancel later 47 | stdout_task = asyncio.create_task(stream_output(self._process.stdout, "STDOUT: ")) 48 | stderr_task = asyncio.create_task(stream_output(self._process.stderr, "STDERR: ")) 49 | self._stream_tasks = [stdout_task, stderr_task] 50 | 51 | # Don't wait, let tasks run 52 | await self._process.wait() 53 | 54 | if self._process.returncode == 0: 55 | await self._output_queue.put({"status": "success", "message": "Process completed successfully"}) 56 | else: 57 | await self._output_queue.put({"status": "error", "message": f"Process exited with code {self._process.returncode}"}) 58 | except Exception as e: 59 | await self._output_queue.put({"status": "error", "message": str(e)}) 60 | finally: 61 | # Cancel the tasks and wait for cancellation to complete 62 | for task in self._stream_tasks: 63 | task.cancel() 64 | try: 65 | await asyncio.gather(*self._stream_tasks, return_exceptions=True) # allow exception 66 | except asyncio.CancelledError: 67 | pass 68 | 69 | self._stream_tasks = [] 70 | self._is_starting = False 71 | self._process = None 72 | self._is_running = False 73 | async def status(self): 74 | return { 75 | "is_running": (self._is_running or self._is_starting) and self._process is not None, 76 | } 77 | 78 | def subscribe(self): 79 | return self._output_queue # return the queue for external subscription 80 | 81 | async def get_stream(self): 82 | while True: 83 | try: 84 | output = await asyncio.wait_for(self._output_queue.get(),timeout=0.1) 85 | yield output 86 | except asyncio.TimeoutError: 87 | if not self._is_running and self._process is None: 88 | break #close if process is not running 89 | continue -------------------------------------------------------------------------------- /src/run_graph.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | import time 4 | 5 | from llm import ChatBot, get_llm 6 | from WorkFlow import run_workflow_as_server 7 | 8 | def main(): 9 | # Create the argument parser 10 | parser = argparse.ArgumentParser(description="Run a graph processing task with LLM configuration.") 11 | 12 | # Add arguments 13 | parser.add_argument( 14 | "--llm", 15 | type=str, 16 | required=True, 17 | help="Specify the LLM model to use (e.g., gpt-4)." 18 | ) 19 | parser.add_argument( 20 | "--key", 21 | type=str, 22 | required=True, 23 | help="API key for authentication." 24 | ) 25 | 26 | # Parse the arguments 27 | args = parser.parse_args() 28 | 29 | # Access the arguments 30 | llm_model = args.llm 31 | api_key = args.key 32 | 33 | # Initialize the LLM using the provided model and API key 34 | llm_instance = get_llm(llm_model, api_key) 35 | run_workflow_as_server(llm_instance) 36 | 37 | if __name__ == "__main__": 38 | main() 39 | -------------------------------------------------------------------------------- /src/util.py: -------------------------------------------------------------------------------- 1 | # util.py 2 | 3 | import sys 4 | import os 5 | 6 | def logger(*args, **kwargs): 7 | output = "" 8 | for arg in args: 9 | output += str(arg) 10 | 11 | output = output.replace("\n", "\\n") 12 | print(output, **kwargs) 13 | sys.stdout.flush() -------------------------------------------------------------------------------- /supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | 4 | [unix_http_server] 5 | file=/var/run/supervisor.sock 6 | chmod=0700 7 | 8 | [rpcinterface:supervisor] 9 | supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface 10 | 11 | [program:cron] 12 | command=/usr/sbin/cron -f 13 | user=root 14 | stdout_logfile=/dev/stdout 15 | stdout_logfile_maxbytes=0 16 | stderr_logfile=/dev/stderr 17 | stderr_logfile_maxbytes=0 18 | 19 | [program:fastapi] 20 | directory=/app/src/ 21 | command=/bin/bash -c "uvicorn main:app --host 0.0.0.0 --port ${BACKEND_PORT:-8000}" 22 | user=root 23 | stdout_logfile=/dev/stdout 24 | stdout_logfile_maxbytes=0 25 | stderr_logfile=/dev/stderr 26 | stderr_logfile_maxbytes=0 --------------------------------------------------------------------------------