Introduzione

Di seguito mostreremo l'implementazione di una semplice web app che, usando le API pubbliche di OpenAI, permetterà di interagire con ChatGPT. Lo sviluppo lato server farà uso del framework Python Flask e la parte lato client verrà gestita con la libreria JavaScript React e farà uso dello standard JSON Web Token (JWT) per effettuare chiamate autenticate alle API del backend in Python.

Repository del codice

https://github.com/gabrieleromanato/chatgpt-react-app

Python?

Per quanto possa sembrare alquanto assurdo oggi, Python nei contesti ufficiali delle aziende e delle PA italiane non viene considerato come un'alternativa valida a soluzioni basate su Java o .NET. Motivo? Non è "affidabile".

Il pregiudizio sull'Open Source

Alla base di questa motivazione vi è il pregiudizio ancora molto radicato secondo cui il mondo Open Source è formato per lo più da amatori poco seri che smanettano nel loro garage. E se il loro codice non viene gestito da aziende come Oracle o Microsoft, usarlo è un "rischio".

JavaScript e React

JavaScript è tollerato in tal senso in quanto ormai la sua diffusione lo ha reso un must e anche se è uno standard del Web consolidatissimo, sentirete spesso confonderlo con Java da quelle stesse persone che confondono React (una libreria JS) con un linguaggio di programmazione. Poco importa quindi se un candidato sa cos'è l'hook useCallback() di React ma ignora il significato e l'uso dei callback in JavaScript.

Nozioni da conoscere lato server

  • Protocollo HTTP
  • Paradigma REST
  • Anatomia di una transazione HTTP
  • Formato JSON
  • Autenticazione tramite header HTTP

Nozioni da conoscere lato client: JS

  • Standard AJAX
  • Fetch API JavaScript
  • Destrutturazione di oggetti e array JavaScript
  • Metodi degli array JavaScript (map(), filter()...)

Nozioni da conoscere lato client: CSS

  • CSS 3
  • CSS 2.1
  • Design Responsive e Media Query
  • Principio del Mobile First

Nozioni da conoscere in Python

  • Decorators
  • Classi e classi di dati (data classes)
  • Moduli e import
  • Gestione delle eccezioni
  • Tipi di dati serializzabili e non serializzabili come JSON

Struttura dell'app Flask

server
├── flaskr
│   ├── __init__.py
│   ├── api.py
│   ├── cache.py
│   └── utils.py
└── requirements.txt
					

Il file requirements.txt

flask
flask-cors
flask-jwt-extended
requests
python-dotenv
redis
waitress
					

Requisiti per Python sul server

  1. Installare python-is-python3
  2. Installare python3-venv

Installazione sul server: Python

$ cd /path/to/server
$ python -m venv venv
$ source venv/bin/activate
$ pip install --no-cache-dir -r requirements.txt

Il file .env

/path/to/server/flaskr/.env


API_KEY=api-key-di-open-ai
JWT_SECRET_KEY=secret-del-json-web-token
USERNAME=username-per-il-client-react
PASSWORD=password-per-il-client-react
REDIS_PORT=6379
REDIS_HOST=0.0.0.0

Installazione sul server: systemd

$ sudo nano /etc/systemd/system/chatapi.service

Installazione sul server: systemd

[Service]
WorkingDirectory=/home/path/to/server
ExecStart=/bin/bash -c 'source venv/bin/activate && waitress-serve --call "flaskr:create_app"'
Restart=always
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=chatapi
User=chatapi
Group=chatapi
Environment=FLASK_APP=flaskr

[Install]
WantedBy=multi-user.target

Installazione sul server: systemd

$ sudo systemctl enable /etc/systemd/system/chatapi.service
$ sudo systemctl start chatapi
$ sudo systemctl status chatapi

Installazione sul server: nginx

$ sudo nano /etc/nginx/sites-available/chatapi

Installazione sul server: nginx

upstream chatapi{
  server   127.0.0.1:8080;
}

server {
  server_name chatapi.example.com;

  location / {
    proxy_pass  http://chatapi;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Prefix /;
  }
}

					

Installazione sul server: nginx

$ sudo ln -s /etc/nginx/sites-available/chatapi /etc/nginx/sites-enabled/chatapi
$ sudo nginx -t
$ sudo systemctl restart nginx

Requisiti per Let's Encrypt con nginx

  1. Installare certbot
  2. Installare python3-certbot-nginx

Installazione sul server: Let's Encrypt

$ sudo certbot --nginx -d chatapi.example.com

Richiesta alle API di Open AI


import requests

def send_request_to_api(api_key, text):
    completions_endpoint = 'https://api.openai.com/v1/chat/completions'
    post_data = {
        'model': 'gpt-3.5-turbo',
        'messages': [{'role': 'user', 'content': text}]
    }
    headers = {'Content-Type': 'application/json', 'Authorization': f'Bearer {api_key}'}
    try:
        r = requests.post(completions_endpoint, json=post_data, headers=headers)
        return r.json()
    except requests.exceptions.RequestException:
        return None

Ottenere il testo di risposta


def get_choices_text_from_api(choices):
    choices_text = []
    if not choices or not isinstance(choices, list) or len(choices) == 0:
        return choices_text
    for choice in choices:
        message = choice.get('message')
        content = message.get('content') if message and message.get('content') else ''
        choices_text.append(content)
    return choices_text

La route di Flask


from flask import (
    Blueprint, jsonify, request, current_app
)
from flask_jwt_extended import jwt_required
from flaskr.utils import send_request_to_api, get_choices_text_from_api
from flaskr.cache import redis_connection

bp = Blueprint('api', __name__, url_prefix='/v1')

@bp.route('/question', methods=['POST'])
@jwt_required()
def question():
    text = request.json.get('question', '')
    if not text:
        return jsonify({'error': 'Invalid request.'})
    cached_question = redis_connection.get(text)
    if cached_question:
        return jsonify({'response': cached_question })
    req = send_request_to_api(current_app.config['API_KEY'], text)
    if not req:
        return jsonify({'error': 'Request failed or timed out.'})
    choices_text = get_choices_text_from_api(req.get('choices', []))
    answer = '\n'.join(choices_text)
    redis_connection.set(text, answer)
    return jsonify({'response': answer})

Login e JWT


from flask_jwt_extended import create_access_token

@bp.route('/login', methods=['POST'])
def login():
    username = request.json.get('username', '')
    password = request.json.get('password', '')
    if username != current_app.config['USERNAME'] or password != current_app.config['PASSWORD']:
        return jsonify({'error': 'Invalid login.'})
    access_token = create_access_token(identity=username)
    return jsonify({'token': access_token})

CORS


import os
from flask import Flask
from flask_cors import CORS
from flask_jwt_extended import JWTManager
from dotenv import load_dotenv

load_dotenv()


def create_app(test_config=None):

    app = Flask(__name__, instance_relative_config=True)

    CORS(app)

    app.config.from_mapping(
        API_KEY=os.getenv('API_KEY'),
        JWT_SECRET_KEY=os.getenv('JWT_SECRET_KEY'),
        USERNAME=os.getenv('USERNAME'),
        PASSWORD=os.getenv('PASSWORD'),
        REDIS_PORT=os.getenv('REDIS_PORT'),
        REDIS_HOST=os.getenv('REDIS_HOST')
    )

    jwt = JWTManager(app)

    from . import api
    app.register_blueprint(api.bp)

    if test_config is None:
        app.config.from_pyfile('config.py', silent=True)
    else:
        app.config.from_mapping(test_config)

    try:
        os.makedirs(app.instance_path)
    except OSError:
        pass

    return app

Struttura del client


client
├── node_modules
├── package-lock.json
├── package.json
├── public
└── src

Richieste AJAX alle API


import axios from "axios";

const APIURL = 'https://chatapi.example.com/v1/';

export async function doLogin(data) {
    try {
        const resp = await axios.post(`${APIURL}login`, data);
        return resp.data;
    } catch(err) {
        return { error: err.message };
    }
}

export async function sendQuestion(text) {
    const config = {
        headers: {
            Authorization: `Bearer ${localStorage.getItem('token')}`
        }
    };
    const data = {
        question: text
    };
    try {
        const resp = await axios.post(`${APIURL}question`, data, config);
        return resp.data;
    } catch(err) {
        return { error: err.message };
    }
}

Login nel client


import '../styles/Login.css';
import { useRef, useState } from 'react';
import { doLogin  } from '../api';

export default function Login({ setLoggedIn }) {
    const [error, setError] = useState('');
    const usernameRef = useRef('');
    const passwordRef = useRef('');

    document.title = 'Login | ChatGPT App';

    const handleLogin = () => {
        setError('');
        const data = {
            username: usernameRef.current.value,
            password: passwordRef.current.value
        };
        doLogin(data).then(res => {
            if(res.error) {
                return setError(res.error);
            }
            localStorage.setItem('token', res.token);
            setLoggedIn(true);
        });
    };

    return (
        <div className="login-wrap">
            <form>
                <div className="form-group mb-3">
                    <label htmlFor="username" className="mb-2">Username</label>
                    <input type="text" ref={usernameRef} className="form-control" />
                </div>
                <div className="form-group mb-3">
                    <label htmlFor="password" className="mb-2">Password</label>
                    <input type="password" ref={passwordRef} className="form-control" />
                </div>
                <div>
                    <button onClick={handleLogin} type="button" className="btn btn-primary btn-block w-100">
                        Login
                    </button>
                    {error && <div className="alert alert-danger mt-4">{error}</div> }
                </div>
            </form>
        </div>
    )
}

Gestire la chat


import '../styles/FormMessage.css';
import { useRef } from 'react';
import SendMessageIcon from './ui/SendMessageIcon';

export default function FormMessage({ addUserMessage }) {
    const msgRef = useRef('');
    const handleSubmitByKey = evt => {
        const code = evt.code;
        if(code === 'Enter') {
            evt.preventDefault();
            return addUserMessage(null, msgRef.current.value);
        }
    };

    return (
        <form className="form-message mt-auto" onSubmit={(e) => addUserMessage(e, msgRef.current.value)}>
            <div className="form-group">
                <textarea onKeyDown={(e) => handleSubmitByKey(e)} placeholder="Send a message." className="form-control" ref={msgRef}></textarea>
                <button type="submit">
                    <SendMessageIcon />
                </button>
            </div>
        </form>
    );
}

Aggiungere messaggi


import '../styles/Messages.css';
import ReactMarkdown from 'react-markdown';
import Loader from './Loader';

export default function Messages({ messages, isLoading }) {
    return (
        <ol className="messages">
            {messages.map((msg, i) => (

                <li key={i} className={`message ${msg.type}`}>
                    {msg.type === 'chat' ? <ReactMarkdown>{msg.text}</ReactMarkdown> : msg.text}
                </li>
            ))}
            {isLoading && <Loader />}
        </ol>
    )
}

Il componente principale: App.js


import { useState, useEffect } from 'react';
import '../styles/App.css';
import Login from './Login';
import FormMessage from './FormMessage';
import Messages from './Messages';
import Refresh from './Refresh';
import { sendQuestion } from '../api';

export default function App() {
  const [loggedIn, setLoggedIn] = useState(false);
  const [messages, setMessages] = useState([]);
  const [isLoading, setIsLoading] = useState(false);

  const addChatMessage = text => {
      setIsLoading(true);
      sendQuestion(text).then(resp => {
          if(resp.response) {
             setMessages(previous => {
                return [...previous, { text: resp.response, type: 'chat' }];
             });
          } else {
             setMessages(previous => {
                return [...previous, { text: 'Oops, something went wrong!', type: 'error' }];
             });
          }
          setIsLoading(false);
      }).catch(err => console.log(err));
  };



  const addUserMessage = (evt = null, text = '') => {
     if(evt) {
         evt.preventDefault();
     }
     setMessages(previous => {
        return [...previous, { text, type: 'user' }];
     });
     addChatMessage(text);
  };

  const refreshChat = () => {
      setMessages([]);
      localStorage.removeItem('token');
      window.location = window.location.href;
  };

    document.title = 'ChatGPT App';


    useEffect(() => {

     setLoggedIn(localStorage.getItem('token') !== null);
     setIsLoading(false);
     setMessages([]);
  }, []);


  return (
    <main className="app">
        {!loggedIn && <Login setLoggedIn={setLoggedIn} /> }
        {loggedIn &&
        <div className="chat-wrap">
          <Messages messages={messages} isLoading={isLoading} />
          <FormMessage addUserMessage={addUserMessage} />
          <Refresh refreshChat={refreshChat} />
        </div>  }
    </main>
  );
}

Gestione dell'app con NPM


# Installazione package
npm install

# Server live di sviluppo
npm run start

# Build di produzione
npm run build

Conclusione

Le API offerte da ChatGPT aprono infiniti scenari di applicazione dove il semplice sviluppo di una chat di base è solo una delle tante possibilità.