├── .gitignore ├── README.md ├── api ├── .env.sample ├── Dockerfile ├── justfile ├── requirements.txt └── src │ ├── __init__.py │ ├── config.py │ ├── db_pg.py │ ├── domain.py │ ├── main.py │ └── utils.py ├── docker-compose.yml ├── frontend ├── Dockerfile ├── justfile ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.css │ ├── App.js │ ├── App.test.js │ ├── ChatDetails.js │ ├── ChatDetailsHeader.js │ ├── ChatList.js │ ├── MessageInput.js │ ├── RasaAdminAPI.js │ ├── config.js │ ├── index.css │ ├── index.js │ ├── logo.svg │ ├── reportWebVitals.js │ └── setupTests.js └── yarn.lock └── screenshot.png /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | frontend/build 14 | 15 | # misc 16 | .DS_Store 17 | .env 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | __pycache__ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rasa Admin web-app (humble Rasa-X replacement) 2 | 3 | A simple Rasa backoffice web-app for tracking your Rasa bot 4 | conversations with users.. And more. 5 | 6 |  7 | 8 | ### Features 9 | - Watch Bot<->users conversations 10 | - Send a message to user ( as Bot ) 11 | 12 | ### Built with 13 | - Frontend: React JS 14 | - API Backend: FastAPI, Pydantic, asyncpg 15 | 16 | ## Prerequisites 17 | Your Rasa should have an active PostgreSQL tracker store, 18 | or, if you wish to install one, see this [guide](https://rasa.com/docs/rasa/tracker-stores/). 19 | Use the same DB connection details for the next step. 20 | 21 | > Tested only with PostgreSQL. 22 | 23 | ## Installation steps summary 24 | 1. Install and run the backend API service (python). 25 | 2. Install and run the ReactJS app. 26 | > All calls to tracker store database and to your Rasa server are handled by 27 | the backend api. 28 | 29 | ## 1. Install and run a local backend API service 30 | 31 | ### Install API 32 | ```sh 33 | cd api 34 | pyenv virtualenv 3.11.3 rasa-admin-api 35 | pyenv activate rasa-admin-api 36 | pyenv local rasa-admin-api 37 | pip install -e . 38 | ``` 39 | 40 | ### Configure 41 | Copy .env.sample to .env, 42 | then edit to provide PostgreSQL and RASA server details. 43 | ```sh 44 | cd api 45 | cp .env.sample .env 46 | vim .env 47 | ``` 48 | 49 | ### Run API 50 | If you have [just](https://just.systems/) tool installed: 51 | 52 | ```sh 53 | just run 54 | ``` 55 | 56 | Or, if not: 57 | 58 | ```sh 59 | uvicorn src.main:app --reload --port 5000 60 | ``` 61 | 62 | ## 2. Install and run local ReactJS app 63 | ```sh 64 | cd frontend 65 | yarn install 66 | yarn start 67 | ``` 68 | 69 | Open your browser http://localhost:3000 70 | 71 | ### Upcoming 72 | - Human hand-off — ability to pause and resume bot-user conversation during hand-off. 73 | - Auto refresh 74 | 75 | Feel free to suggest features or submit PRs! 76 | 77 | ## License 78 | MIT License 79 | 80 | Nester (c) 2024 81 | -------------------------------------------------------------------------------- /api/.env.sample: -------------------------------------------------------------------------------- 1 | # Hostname or ip of your Rasa PostgreSQL server 2 | POSTGRES_HOST = 'localhost' 3 | # TCP Port of your Rasa PostgresSQL server 4 | POSTGRES_PORT = '5432' 5 | # Rasa database name 6 | POSTGRES_DB_NAME = 'rasa' 7 | # Rasa database user and password 8 | POSTGRES_USER = 'postgres' 9 | POSTGRES_PASSWORD = 'postgres' 10 | 11 | # --- Optionals (for sending messages to users) --- 12 | # Rasa API server URL 13 | RASA_SERVER_API_URL = "http://localhost:5005" 14 | # Rasa API JWT secret key (see https://rasa.com/docs/rasa/next/http-api/) 15 | RASA_SERVER_API_JWT_SECRET_KEY = "" 16 | -------------------------------------------------------------------------------- /api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11.3-slim 2 | 3 | WORKDIR /code/api 4 | 5 | COPY ./requirements.txt ./ 6 | 7 | RUN pip install --no-cache-dir --upgrade -r requirements.txt 8 | 9 | COPY ./src src 10 | COPY .env .env 11 | 12 | CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "5000"] 13 | -------------------------------------------------------------------------------- /api/justfile: -------------------------------------------------------------------------------- 1 | # API justfile 2 | 3 | PACKAGE := 'src.main' 4 | 5 | [private] 6 | default: 7 | @just --list --unsorted 8 | 9 | run: 10 | uvicorn {{ PACKAGE }}:app --reload --port 5000 11 | 12 | install: venv 13 | pip install -e . 14 | 15 | install-dev: venv 16 | pip install -e .[test] 17 | 18 | venv: 19 | -pyenv virtualenv 3.11.3 rasa-admin-api 20 | pyenv local rasa-admin-api 21 | -pyenv activate rasa-admin-api 22 | -------------------------------------------------------------------------------- /api/requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi==0.97.0 2 | httpx==0.24.1 3 | pydantic==1.10.8 4 | python-dotenv==1.0.0 5 | uvicorn==0.22.0 6 | asyncpg==0.27.0 7 | requests==2.31.0 8 | PyJWT==2.8.0 9 | -------------------------------------------------------------------------------- /api/src/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | rasa-admin-api namespace module 3 | """ 4 | # -*- coding: utf-8 -*- 5 | 6 | __doc__ = "rasa-admin-api" 7 | __license__ = "Copyright" 8 | __version__ = "0.1.0" 9 | __author__ = "" 10 | __email__ = "" 11 | __url__ = "http://github.com/nesterapp/rasa-admin" 12 | -------------------------------------------------------------------------------- /api/src/config.py: -------------------------------------------------------------------------------- 1 | # rasa-admin api config 2 | # --- 3 | import pathlib 4 | 5 | project_root = pathlib.Path(__file__).parent.parent.resolve() 6 | 7 | UVICORN_LOGGING_LEVEL = 'DEBUG' # 'INFO' / 'WARNING' / 'ERROR' / 'DEBUG' 8 | -------------------------------------------------------------------------------- /api/src/db_pg.py: -------------------------------------------------------------------------------- 1 | import asyncpg 2 | from asyncpg import Pool 3 | from dotenv import load_dotenv 4 | import os 5 | 6 | class PostgresDB: 7 | def __init__(self): 8 | self.pool: Pool = None 9 | self.load_env() 10 | self.dsn = self.build_dsn() 11 | 12 | def load_env(self): 13 | # dotenv_path = ".env" 14 | load_dotenv() 15 | 16 | def build_dsn(self): 17 | db_host = os.getenv("POSTGRES_HOST") 18 | db_port = os.getenv("POSTGRES_PORT") 19 | db_name = os.getenv("POSTGRES_DB_NAME") 20 | db_user = os.getenv("POSTGRES_USER") 21 | db_password = os.getenv("POSTGRES_PASSWORD") 22 | return f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}" 23 | 24 | async def connect(self): 25 | self.pool = await asyncpg.create_pool(dsn=self.dsn) 26 | 27 | async def disconnect(self): 28 | await self.pool.close() 29 | 30 | async def execute(self, query: str, *args): 31 | connection: asyncpg.Connection 32 | async with self.pool.acquire() as connection: 33 | await connection.execute(query, *args) 34 | 35 | async def execute_transaction(self, queries): 36 | connection: asyncpg.Connection 37 | async with self.pool.acquire() as connection: 38 | async with connection.transaction(): 39 | for query, *args in queries: 40 | await connection.execute(query, *args) 41 | 42 | async def fetchrow(self, query: str, *args) -> asyncpg.Record: 43 | connection: asyncpg.Connection 44 | async with self.pool.acquire() as connection: 45 | result = await connection.fetchrow(query, *args) 46 | return result 47 | 48 | async def fetch(self, query: str, *args) -> list[asyncpg.Record]: 49 | connection: asyncpg.Connection 50 | async with self.pool.acquire() as connection: 51 | result = await connection.fetch(query, *args) 52 | return result 53 | -------------------------------------------------------------------------------- /api/src/domain.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Optional 3 | 4 | from pydantic import BaseModel, validator 5 | 6 | class EventData(BaseModel): 7 | event: str 8 | text: Optional[str] 9 | 10 | class Event(BaseModel): 11 | id: int 12 | sender_id: str 13 | type_name: str 14 | timestamp: float 15 | intent_name: Optional[str] 16 | action_name: Optional[str] 17 | data: EventData 18 | 19 | @validator('data', pre=True) 20 | def data_str_to_dict(cls, v): 21 | if isinstance(v, dict): 22 | return v 23 | elif isinstance(v, str): 24 | try: 25 | d = json.loads(v) 26 | except: 27 | raise ValueError('Failed to convert data text to dictionary') 28 | return d 29 | return v 30 | 31 | class ChatHeader(BaseModel): 32 | sender_id: str 33 | timestamp: float 34 | 35 | class Chat(BaseModel): 36 | sender_id: str 37 | events: list[Event] 38 | 39 | class MessagePayload(BaseModel): 40 | text: str -------------------------------------------------------------------------------- /api/src/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import requests 3 | import os 4 | 5 | from contextlib import asynccontextmanager 6 | from fastapi import FastAPI, Depends, HTTPException 7 | from fastapi.middleware.cors import CORSMiddleware 8 | from dotenv import load_dotenv 9 | 10 | from src import config 11 | from src.db_pg import PostgresDB 12 | from src.domain import Chat, ChatHeader, Event, MessagePayload 13 | from src.utils import encode_jwt_token 14 | 15 | logger = logging.getLogger('uvicorn') 16 | logger.setLevel(config.UVICORN_LOGGING_LEVEL) 17 | load_dotenv() 18 | 19 | @asynccontextmanager 20 | async def lifespan(app: FastAPI): 21 | db = PostgresDB() 22 | await db.connect() 23 | app.state.database = db 24 | 25 | yield 26 | # Anything after yield is called at shutdown 27 | await app.state.database.disconnect() 28 | 29 | 30 | app = FastAPI(lifespan=lifespan) 31 | 32 | origins = [ 33 | "http://localhost:8000", 34 | "http://localhost:8080", 35 | "http://localhost:3000", 36 | "http://127.0.0.1:3000", 37 | ] 38 | 39 | app.add_middleware( 40 | CORSMiddleware, 41 | allow_origins=origins, 42 | allow_credentials=True, 43 | allow_methods=["*"], 44 | allow_headers=["*"], 45 | ) 46 | 47 | def get_database() -> PostgresDB: 48 | return app.state.database 49 | 50 | 51 | @app.get("/_status/healthz") 52 | async def root(): 53 | return {"message": "OK"} 54 | 55 | 56 | @app.get("/chats", response_model=list[ChatHeader]) 57 | async def get_chats(db: PostgresDB = Depends(get_database)): 58 | """Get all chat headers, not including messages (only sender_id and timestamp)""" 59 | 60 | rows = await db.fetch( 61 | """ 62 | SELECT sender_id, 63 | MIN("timestamp") AS timestamp 64 | FROM events 65 | GROUP BY sender_id 66 | ORDER BY timestamp DESC; 67 | """ 68 | ) 69 | chats = [ChatHeader(**row) for row in rows] 70 | return chats 71 | 72 | 73 | @app.get("/chats/{sender_id}", response_model=Chat) 74 | async def get_chat_details(sender_id: str, db: PostgresDB = Depends(get_database)) -> Chat: 75 | """Get specific chat with all of it's related events 76 | 77 | Args: 78 | sender_id 79 | """ 80 | rows = await db.fetch( 81 | """ 82 | SELECT 83 | id, sender_id, type_name, 84 | timestamp, intent_name, 85 | action_name, (data::jsonb) AS data 86 | FROM events 87 | WHERE sender_id = $1 88 | ORDER BY id desc 89 | """ 90 | , 91 | sender_id 92 | ) 93 | if not rows: 94 | raise HTTPException(status_code=404, detail="Conversation not found") 95 | 96 | events = [Event(**row) for row in rows] 97 | chat = Chat(sender_id=sender_id, events=events) 98 | return chat 99 | 100 | 101 | @app.post("/chats/{sender_id}/message") 102 | async def send_message( 103 | sender_id: str, 104 | message: MessagePayload 105 | ): 106 | """Send a text message from Rasa Bot to a user via channel 107 | 108 | Args: 109 | sender_id: the recipient id for the message 110 | message: the text message to send. i.e payload { "text": "foobar" } 111 | 112 | see: https://rasa.com/docs/rasa/pages/http-api#operation/addConversationTrackerEvents 113 | """ 114 | 115 | rasa_url = os.getenv("RASA_SERVER_API_URL") 116 | jwt_secret = os.getenv("RASA_SERVER_API_JWT_SECRET_KEY") 117 | assert rasa_url, jwt_secret 118 | 119 | url = f"{rasa_url}/conversations/{sender_id}/tracker/events" 120 | 121 | query_params = { 122 | "output_channel": "telegram", # TODO: why failing to send with "latest" 123 | "execute_side_effects": "true" 124 | } 125 | 126 | payload = { 127 | "event": "bot", 128 | "text": message.text 129 | } 130 | 131 | if not jwt_secret: 132 | raise HTTPException(status_code=400, detail="JWT secret is missing") 133 | jwt_token = encode_jwt_token(jwt_secret) 134 | headers = { 135 | "Authorization": f"Bearer {jwt_token}", 136 | "Content-Type": "application/json" 137 | } 138 | 139 | response = requests.post(url, params=query_params, json=payload, headers=headers) 140 | 141 | if response.status_code == 200: 142 | return {"message": "Message sent successfully"} 143 | else: 144 | return {"message": "Failed to send message"} 145 | -------------------------------------------------------------------------------- /api/src/utils.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | import os 3 | 4 | def encode_jwt_token(jwt_secret_key: str) -> str: 5 | 6 | payload = { 7 | "user": { 8 | "role": "admin" 9 | } 10 | } 11 | 12 | return jwt.encode(payload, jwt_secret_key, algorithm="HS256") 13 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: "3.3" 3 | 4 | services: 5 | api: 6 | image: rasa-admin/api:latest 7 | build: 8 | context: ./api 9 | ports: 10 | - 5000:5000 11 | networks: 12 | - frontend 13 | - backend 14 | frontend: 15 | image: rasa-admin/frontend:latest 16 | build: 17 | context: ./frontend 18 | ports: 19 | - 3000:3000 20 | networks: 21 | - frontend 22 | 23 | networks: 24 | frontend: 25 | backend: 26 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Client base image 2 | FROM node:21.7.0-alpine as base 3 | WORKDIR /app 4 | COPY package.json yarn.lock ./ 5 | 6 | ENV NODE_ENV production 7 | 8 | RUN yarn install --frozen-lockfile --production && \ 9 | yarn cache clean 10 | RUN yarn global add serve 11 | 12 | COPY public ./public 13 | COPY src ./src 14 | RUN yarn build 15 | 16 | CMD [ "serve", "-s", "./build" ] 17 | -------------------------------------------------------------------------------- /frontend/justfile: -------------------------------------------------------------------------------- 1 | [private] 2 | default: 3 | @just --list --unsorted 4 | 5 | dev: 6 | yarn start 7 | 8 | build: 9 | yarn build 10 | 11 | test: 12 | yarn test -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rasa-admin", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.17.0", 7 | "@testing-library/react": "^13.4.0", 8 | "@testing-library/user-event": "^13.5.0", 9 | "axios": "^1.5.0", 10 | "react": "^18.2.0", 11 | "react-dom": "^18.2.0", 12 | "react-scripts": "5.0.1", 13 | "web-vitals": "^2.1.4" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject" 20 | }, 21 | "eslintConfig": { 22 | "extends": [ 23 | "react-app", 24 | "react-app/jest" 25 | ] 26 | }, 27 | "browserslist": { 28 | "production": [ 29 | ">0.2%", 30 | "not dead", 31 | "not op_mini all" 32 | ], 33 | "development": [ 34 | "last 1 chrome version", 35 | "last 1 firefox version", 36 | "last 1 safari version" 37 | ] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nesterapp/rasa-admin/e21f9d983b26887b1e332acdcf6e38b9915c8704/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 |24 | {formatTimestamp(event.timestamp)} 25 |
26 |27 | {event.data.text} 28 |
29 |Select a chat
34 | )} 35 |{senderId ? `Sender ID: ${senderId}` : 'Select a chat'}
9 |