Интерфейс чата
Интерфейс чата

В июне у OpenAI вышла новость, что в модель GPT можно передавать API сторонних приложений, что открывает широкий круг возможностей для создания специализированных агентов. Мы с командой решили написать свой чат для работы с GPT4 от Open AI и другими ML/LLM моделями c возможностью кастомизации под внутренние нужды компании. Проект выложен в открытый доступ, скачать можно по ссылке. Сейчас он находится в активной разработке, так что будем рады видеть ваши замечания / пожелания в комментариях. Также присылайте ваши pull requests с исправлениями. 

На бэкенде был выбран Python, Django Rest Framework. На фронтенде React, Redux, Saga, Sass. Начнем с бэкенда. Им занимался Егор. Далее про серверную часть проекта он пишет от себя.

Создание сообщения в чате
Создание сообщения в чате

Backend

Backend составляющая моего проекта работает на Django. Также для API используется Django Rest Framework. На сегодня эти инструменты являются очень популярными, для на них уже есть множество готовых библиотек, что ускорило процесс программирования.

Начнем с того, что пользователи легче запоминают свой электронный адрес, чем username, который может быть занят в отличие от уникальной почты. Поэтому я решил сделать свою модель User, с некоторыми изменениями в отличии от базовой модели в Django. Не стоит забывать, что модель пользователя важно продумать на начальных этапах проекта!

Модель пользователя
Модель пользователя

Собственная модель пользователя

Начнем с изменений:
1. Создать модель User. Я добавил флаг is_email_confirmed, чтобы можно было отследить подтверждение почты. Авторизация теперь будет происходить через email.  Опцию с выбором своего username решил оставить.

class User(AbstractBaseUser, PermissionsMixin):
   username = models.CharField(db_index=True, max_length=25, unique=True)
   email = models.EmailField(db_index=True, unique=True)


   is_active = models.BooleanField(default=False)
   is_staff = models.BooleanField(default=False)
   is_email_confirmed = models.BooleanField(default=False)


   created_at = models.DateTimeField(auto_now_add=True)
   updated_at = models.DateTimeField(auto_now=True)


   USERNAME_FIELD = 'email'
   REQUIRED_FIELDS = ['username']
   objects = UserManager()

2. Добавить Manager, чтобы пользователи добавлялись в базу данных.

class UserManager(BaseUserManager):
   def create_user(self, username, email, password=None):
       if username is None:
           raise TypeError('Users must have a username.')
       if email is None:
           raise TypeError('Users must have an email address.')
       user = self.model(username=username, email=self.normalize_email(email))
       user.set_password(password)
       user.save()
       return user


   def create_superuser(self, username, email, password):
       if password is None:
           raise TypeError('Superusers must have a password.')
       user = self.create_user(username, email, password)
       user.is_superuser = True
       user.is_email_confirmed = True
       user.is_staff = True
       user.save()
       return user

3. Назначить модель User в настройка settings.py:

AUTH_USER_MODEL = "user_app.User"

Хотелось, чтобы пользователь мог редактировать свою личную информацию, для создания образа живого общения  Поэтому я решил добавить профиль, в котором хранится персональная информация, в том числе,аватарка.

 Profile модель:

class Profile(models.Model):
   user_id = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
   avatar = models.ImageField(upload_to=get_image_filename, blank=True)
   first_name = models.TextField(max_length=32)
   last_name = models.TextField(max_length=32)

Но если сделать проверку регистрацией, то заметим, что у создастся только модель User, без Profile. Чтобы это исправить, и при создании модели пользователя добавлялась модель профиля, следует применить сигнал. Для определения приемников сигналов в Django используется декоратор receiver. И следующий код автоматически будет создавать наш профиль для пользователя:

@receiver(post_save, sender=User)
def create_profile(sender, instance, created, **kwargs):
   if created:
       Profile.objects.create(user_id=instance)

JWT-авторизация

Теперь чтобы взаимодействовать с системой, нужно авторизоваться. Для этого на проекте решил выбрать JWT-авторизацию, ведь она считается одним из безопасных способов передачи информации между двумя участниками.

Основной сценарий использования выглядит следующим образом: с помощью access token мы можем обращаться к приватным ресурсам, но когда истекает срок действия данного токена, мы отправляем refresh token и получаем новую пару access token + refresh token. Refresh token тоже может истечь, но он имеет гораздо больший срок действия и если срок действия refresh token истекает, достаточно повторно пройти процедуру авторизации, чтобы получить JWT.

Цикл JWT
Цикл JWT

Для данной реализации уже есть готовая библиотека Simple JWT. Она отлично справляется с генерацией access и refresh токенов, но для обеспечения безопасности от CSRF-атак и XSS-атак я решил передавать refresh токен в HttpOnly Cookie. Для этого пришлось изменить базовые настройки JWT, и добавить в response cookie. Код добавления refresh токена в cookie:

response.set_cookie(
   key=settings.SIMPLE_JWT['AUTH_COOKIE_REFRESH'],
   value=token,
   expires=settings.SIMPLE_JWT['REFRESH_TOKEN_LIFETIME'],
   secure=settings.SIMPLE_JWT['AUTH_COOKIE_SECURE'],
   httponly=settings.SIMPLE_JWT['AUTH_COOKIE_HTTP_ONLY'],
   samesite=settings.SIMPLE_JWT['AUTH_COOKIE_SAMESITE']
)

Не обошлось и без трудностей. Больше всего времени занял вопрос, почему токен не сохраняется должным образом и просто весит в response? В данном случае следует рассмотреть файл settings.py и добавить в него параметр

CORS_ALLOW_CREDENTIALS = True

И не забыть изменить настройки SIMPLE_JWT в этом же файле:

'AUTH_COOKIE_SECURE': False - нужно, при подключении https

'AUTH_COOKIE_HTTP_ONLY' : True - Флаг HttpOnly 

'AUTH_COOKIE_SAMESITE': 'Lax' - файлы Cookie полностью блокируются для межсайтовых запросов

Также, следует помнить, что на стороне Frontend запрос должен быть с параметром withCredentials: true

Обращение к NLP (GPT)

Теперь, когда мы авторизовались, мы можем обратиться к модели и получить от неё ответ. Обращение идет обычным сообщением, а что есть у сообщения? Правильно, свой диалог, в рамках которого оно идет. Опираясь на эту логику, можно общаться с nlp с учётом контекста предыдущих сообщений. Пожалуй, настало время написать запрос. Текст нашего запроса будет таким “Write bubble sort in Python”, ведь кто не начинал программирование с написанием сортировки пузырьком :). Как только нажали на кнопку Enter, отправляется запрос к нашему API, от которого мы и получим ответ. Ответ записывается при помощи функции take_answer(), к которой передаем запрос:

answer_text = take_answer(validated_data['message_text'], validated_data['dialog_id'])
message = Message.objects.create(answer_text=answer_text, **validated_data)

OpenAI предоставляет доступ к своему API (ссылка https://platform.openai.com/overview). В проектке взаимодействие с API организовано по следующему принципу:

Диаграмма последовательности взаимодействия слоев приложения
Диаграмма последовательности взаимодействия слоев приложения

Обращение к OpenAI идет с помощью функции ChatCompletion.create(), предлагаю её детальнее изучить:

 previous_messages = Message.objects.filter(dialog_id=dialog_id).order_by('-id')[:5]
    messages = [
        {"role": "system", "content": "You are a helpful assistant."}
    ]

    for message in previous_messages:
        user_message = {"role": "user", "content": message.message_text}
        ai_message = {"role": "assistant", "content": message.answer_text}
        messages.extend([user_message, ai_message])

    user_prompt = {"role": "user", "content": prompt}
    messages.append(user_prompt)

    response = openai.ChatCompletion.create(
        model="gpt-4",
        messages=messages
    )

Мы берем предыдущие запросы и ответы в количестве 5 штук и отправляем OpenAI в ChatCompletion ( ссылка   https://platform.openai.com/docs/api-reference/completions/create ),  модель решил пока не менять без особых нужд. Теперь настало время рассмотреть response, который уже должен придти:

{
  "id": 1,
  "message_text": "Write bubble sort in Python",
  "answer_text": "Certainly! Here's an example of the bubble sort algorithm implemented in Python:\n\n```python\ndef bubble_sort(arr):\n    n = len(arr)\n    \n    for i in range(n):\n        # Last i elements are already in place\n        for j in range(0, n-i-1):\n            # Swap if the element found is greater than the next element\n            if arr[j] > arr[j+1]:\n                arr[j], arr[j+1] = arr[j+1], arr[j]\n\n# Example usage\narr = [64, 34, 25, 12, 22, 11, 90]\nbubble_sort(arr)\nprint(\"Sorted array: \", arr)\n```\n\nThis implementation sorts the input list in ascending order using the bubble sort algorithm.",
  "dialog_id": 1
}

И как же я обрадовался, когда понял, что данный response можно преобразовать в Markdown для пользователя. Иначе это было бы совершенно нечитаемо и пришлось бы придумать или искать решение, как отобразить ответ на экране.

Frontend

Далее я расскажу про свою часть (Никита). Я разбил приложение на следующие Redux модули:

  • Auth (авторизация, регистрация, восстановление пароля);

  • Chat (работа с сообщениями, диалогами)

  • User (данные пользователя username, emai, loading, error)

  • Profile (данные пользователя такие как Имя, Фамиля, Фото).

Далее я остановлюсь подробнее на том как организован роутинг, регистрация, авторизация и чат.

Роутинг

Роутинг я добавил для страницы страниц авторизации, регистрации, чата, страницы настроек, профиля. Для чата использование роутинга очень актуально, позволяет использовать ссылку для открытия определенного состояния.

Роутинг я разбил на два файла: routes и index. В routes.js я создал несколько списков роутов, которые предполагают различные уровни доступа. Напримре, для авторизованного пользователя и неавторизованного, а также публичные роуты, поведение которых не должно меняться в зависимости от того авторизован пользователь или нет. Такой подход позволяет сократить число редиректов и проверок, которые используются в приложении. И если пользователь, оказывается на странице, куда у него доступа нет, то роутер автоматически перенаправляет его на страницу авторизации.

onst authProtectedRoutes = [
  { path: "/chats/", component: <Chats /> },
  { path: "/chats/:id", component: <Chats /> },
  { path: "/profile", component: <Profile /> },
  { path: "/settings", component: <Settings /> },
  { path: "/logout", component: <Logout /> },


  // this route should be at the end of all other routes
  // eslint-disable-next-line react/display-name
  {
    path: "/",
    exact: true,
    component: <Navigate to="/chats" />,
  },
];


const publicRoutes = [
  { path: "/email-verify/:token", component: <EmailVerification/> },
  { path: "/password-reset/:token", component: <PasswordReset/> },
  { path: "/forget-password", component: <ForgetPassword /> },
];


const authRoutes = [
  { path: "/login", component: <Login /> },
  { path: "/register", component: <Register /> },
];

Index.js основной компонент роутера, который возвращает список роутеров и в зависимости от типа списка устанавливает различные правила для каждого роута. Пример роута из index.js для внутреннего дашборда проекта, который не доступен для авторизованных пользователей:

{/* private/auth protected routes */}
{authProtectedRoutes.map((route, idx) =>
  <Route
    path={route.path}
    layout={AuthLayout}
    element={
      <AuthProtected>
        <AuthLayout>
          {route.component}
        </AuthLayout>
      </AuthProtected>
    }
    key={idx}
  />
)}

Регистрация / авторизация

Процесс регистрации
Процесс регистрации

Регистрация

Регистрация  состоит из нескольких этапов:

  • Окно регистрации, валидация пользовательских данных.

  • Отправка email со ссылкой для подтверждения данных пользователей.

  • Сразу после регистрации пользователь перенаправляется на страницу /chats с ограниченным доступом. При этом у пользователя стоит флаг “is_email_confirmed” = false.

  • После подтверждения email необходимо вывести уведомление о том, что email подтвержден в dashboard, если пользователь уже авторизован. Если пользователь не авторизован, то переадресуем на страницу логина и выводим сообщение о том, что email подтвержден.

Диаграмма состояний процесса регистрации
Диаграмма состояний процесса регистрации

Валидация при регистрации

Валидация организована через ReactHook useFormik.

const formik = useFormik({
        initialValues: {            
            email: '',
            password: ''
        },
        validationSchema: Yup.object({            
            email: Yup.string().email('Enter proper email').required('Required'),
            password: Yup.string()
                .required('Required')
        }),
        onSubmit: values => {
            console.log('Register page', 'onSubmit', values.email, values.password );
            props.registerUser(values.email, values.password );
        },
    });

При этом в через Hook useEffect отслеживаем ошибки, которые приходят с сервера и тоже их отображаем во View:

useEffect(() => {
        if (props.error && props.error.errors) {
            const propsErrors = props.error.errors;
            setErrors(propsErrors);
            let formErrors = {};
            for (let key in propsErrors) {
                formErrors[key] = propsErrors[key][0];
            }
            formik.setErrors(formErrors);
        }
    }, [props.error]);

Подтверждение email

Подтверждение email реализуется через компонент EmailVerification.js. Если токен пришел верный, то компонент переадресует на страницу авторизации, если неверный, то выводит ошибку.

<CardBody className="p-4">
  {/* In case if user is already logged in it manages at router */}
    {props.emailConfirmed ? (
    // We have to show successfull message at login page
      <Navigate to={{ pathname: "/login", state: { from: props.location } }} />
        ) : (
          <Alert color="danger">
            Confirmation failed.
          </Alert>
        )}
        <Link to="/login" className="font-weight-medium text-primary"> {t('Signin now')} </Link>
  </CardBody>

Авторизация 

Диаграмма состояний процесса авторизации
Диаграмма состояний процесса авторизации

Основную логику для работы с авторизацией, я перенес в helper autUtils.js Для работы с авторизацией мы используем механизм OAuth2. Для отслеживания авторизации пользователя используется два токена access и refresh токенами. Соответственно пока access токен активен пользователь имеет доступ к внутренней структуре проекта. Когда токен истекает, я запрашиваю обновление токена через API используя refresh токен. Для проверки срока давности токена используется модуль jwtDecode.

/**
 * Refresh the access token
 * We use API here, because we use it in middleware in ApiAuthorizedClient
 */
const refreshAccessToken = async () => {
    let tokens = getTokens();
    if (tokens && tokens.access) {
        const decoded = jwtDecode(tokens.access);
        const currentTime = Date.now() / 1000;
        if (decoded.exp > currentTime) {
            // Token is still valid, return it
            return tokens.access;
        }
    }


    // Token is not in localStorage or is expired, refresh it
    try {
        const response = await axios.post(`${API_URL}/token/refresh/`, { refresh: tokens.refresh });
        if (response.data && response.data.access) {
            setTokens(response.data); // Save the new token to localStorage
            return response.data.access;
        }
    } catch (error) {
        console.error('Error refreshing access token:', error);
        localStorage.removeItem("tokens");
        throw error;
    }
}


/**
 * Checks if access token is expired and refreshes it if necessary
 */
const checkAndRefreshToken = async () => {
    const tokens = getTokens();
    if (!tokens) {
        throw new Error('User not authenticated');
    }


    try {
        const decoded = jwtDecode(tokens.access);
        const currentTime = Date.now() / 1000;
        if (decoded.exp < currentTime) {
            console.warn('Access token expired. Refreshing...');
            const newAccessToken = await refreshAccessToken();
            return newAccessToken;
        } else {
            return tokens.access;
        }
    } catch(error) {
        console.warn('Error decoding token:', error);
        throw error;
    }
}

Чат

Доступная функциональность чата
Доступная функциональность чата

Чат разделен на следующие компоненты:

  • Chats.js содержит перечень чатов из левой колонки. Компонент содержит логику по работе с путями такими как chats/:id

  • ChatInput.js компонент отвечает за работу поля ввода.

  • Index.js содержит логику вывода списка сообщений.

  • UserHead содержит логику работы чаты: удаление, возврат к списку диалогов для мобильной версии.

API от OpenAI возвращает ответы в формате Markdown. Для вывода ответов от чата, который содержат форматирование я использовал компонент ReactMarkdown. Для кодовых вставок React Syntax Highlighter

Импортируем необходимые компоненты:

import React, { useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { solarizedlight } from 'react-syntax-highlighter/dist/esm/styles/prism';

Выводим ответы от GPT с использованием форматирования:

<div className="conversation-list">
  <div className="user-chat-content">
    <div className="ctext-wrap">
      <div className="ctext-wrap-content">
        <ReactMarkdown components={{code: CodeBlock}} className="mb-0">
          {chat.answer_text}
        </ReactMarkdown>
      </div>
  </div>
</div>

Несколько не очевидно было как реализовать корректно переключение с экрана нового чата, который находится по пути /chats на определенный чат /chats:id.

Логика сейчас следующая:

  1. При создании первого сообщения идет отправка на POST запроса на создание диалога. 

  2. При получении ответа о том, что диалог создан идет отрисовка нового списка диалогов.

  3. Идет отправка POST запроса на отправку сообщения к LLM модели.

  4. После завершения отрисовки списка диалогов реализуется редирект на новую вкладку.

  5. Ожидаем ответа от модели и выводим сообщение в чате.

Основная часть бизнес логики, которая отвечает за переключение на новый диалог, реализована в компоненте Chats.js

useEffect(() => {
        props.getAuthorizedUser();
        props.fetchDialogues();
    }, []);


    useEffect(() => {
        const dialogues = props.dialogues;
        if (dialogues && dialogues.length === 0) {
            return;
        }


        setRecentChatList(dialogues);
       
        const activeDialogueId = props.activeDialogueId;
        if (id === 0 && activeDialogueId > 0) {
            //added new dialogue
            navigate(`/chats/${activeDialogueId}`);
        }
    }, [props.dialogues]);


    useEffect(() => {
        if (props.activeDialogueId === id){
            return;
        }
        props.setActiveDialogue(id);
        openUserChat(id);
    }, [id]);


    const openUserChat = (dialogueId) => {
        //check /chats page or initalStage
        if (dialogueId === 0) {
            return;
        }
        //TODO: we have to check if we already have actual messages into state
        props.fetchMessages(dialogueId);
    };

Функция в Chat/saga.js, которая реализует POST запросы к серверу для создания нового диалога и чата:

function* addDialogue(action) {
  const data = action.payload;
  try {
      const dialogue = yield api.post('/dialogues/', {
          "user_id": data.user_id,
          "name": data.name
      });
      yield put({ type: ADD_DIALOGUE_SUCCESS, payload: dialogue });
      if (data.message) {
        yield put({ type: ADD_MESSAGE_REQUEST, payload: {
          "message_text": data.message,
          "dialog_id": dialogue.id
        }});    
      }
  } catch (error) {
    console.log(error);
  }
}

Ближайшие планы

  1. Вывод ошибок и сообщений от сервера - сейчас это реализовано частично для регистрации и авторизации. Вывод сообщений о загрузке.

  2. Вывод сообщений от сервера потоком, как это реализовано в ChatGpt. Сразу отображать сообщение от пользователя.

  3. Добавить логику работы с профилем пользователя.

  4. Добавить возможность работы с различными типами агентов (ML моделями)

Ссылка на Github
Ссылка на проект

Комментарии (11)


  1. rSedoy
    20.07.2023 08:25
    +1

    Самый большой косяк, это из джанговского синхронного view делать http запросы к API OpenAI.


    1. BrNikita Автор
      20.07.2023 08:25

      От Егора (хабр не опубликовал его комментарий):
      "Спасибо большое за замечание. Добавили задачу, она будет является приоритетной (https://github.com/soshace/fosterflow/issues/23). Есть ли у вас еще замечания или пожелания?"


      1. rSedoy
        20.07.2023 08:25
        +1

        Ну вот если дальше продолжить размышления на эту тему, варианты решения:
        1. Запросы уносим в фон, минус - городить дополнительный огород для всей этой обработки, по мне так себе решение.
        2. В django практический рабочий async, используем его, минус - DRF не умеет async, писать самостоятельно кучу всего на замену DRF, тоже не очень хорошее. Но возможно уже есть какие-то сторонние решения, я эту тему сильно не исследовал.
        3. Отказаться от django в пользу асинхронных фреймворков, тот же FastAPI, минус - переход на другой ORM, отсутствие удобной админки. Но эти минусы не такие уже и критичные.

        Тут еще помянули вебсокеты, но так и django с ними не очень (channels мне вообще не зашли, какой-то оверхед по сравнению с удобствами в асинхронных фреймворках)


        1. taranegor
          20.07.2023 08:25

          Думаю, что воспользуемся вторым методом. У Django есть на это документация, и я предполагаю, что можно будет что-то придумать.
          https://docs.djangoproject.com/en/4.2/topics/async/


    1. taranegor
      20.07.2023 08:25

      Спасибо большое за замечание. Добавили задачу, она будет является приоритетной (https://github.com/soshace/fosterflow/issues/23). Есть ли у вас еще замечания или пожелания?


  1. hssergey
    20.07.2023 08:25
    +1

    В вашей архитектуре есть проблема в том, что запрос к API OpenAI делается прямо в обработке HTTP-запроса от фронтэнда. И если OpenAI будет долго отвечать, то может как успеть оборваться соединение с фронтэндом, так и обработчик запроса может быть прибит по таймауту. По уму надо подключать например Celery, запрос OpenAI делать в отдельной асинхронной таске, а фронтэнду сразу же вернуть статус, что запрос отправлен. И дальше фронтэнд через какое-то время должен сделать еще запрос чтобы забрать ответ от OpenAI и вывести его клиенту. Либо можно пойти еще дальше и подключить вэбсокеты, тогда сервер сам отправит ответ от OpenAI на фронтэнд, когда он придет.


    1. taranegor
      20.07.2023 08:25

      Спасибо за комментарий! Согласен, использование Celery для асинхронных задач здесь подходит. Идея с вебсокетами также звучит обещающе, но вариант с Celery мне нравится больше. Далее если выбирать между встроенной в Django Async или Celery, то какой из вариантов имеет больше преимуществ?


  1. vagon333
    20.07.2023 08:25

    А зачем в модели разделены user_app_user и user_app_profile?
    Я так понимаю, в базе получатся 2 раздельные таблицы со связью 1:1 ?


    1. taranegor
      20.07.2023 08:25

      Да таблицы имеют связь 1:1, это сделано для того, чтобы не вносить изменения в модель пользователя, что позволяет избегать дополнительных миграций. Модель User желательно планировать на ранних этапах, дальнейшие изменения и нововведения будут происходить в модели Profile


  1. toysamuyandriy
    20.07.2023 08:25

    Здравствуйте, Никита. Спасибо большое за материал. У меня только остался вопрос к самой идее. Объясните, пожалуйста. Правильно ли я понял, что ваша модель чата - она работает по той же платной модели от open AI где взимается оплата за токены? И чтобы обучить чат понимать как работает, к примеру, моя компания, то так же каждый раз я должен отсылать ему раз запрос с информацией о моей компании, чтобы он понимал ее и на основе этой информации мог дать ответ? Или ваша модель позволят работать с информацией и не отсылать каждый раз подсказку чату? То есть можно ли научить чат и обяснить ему все необходимые нюансы и только отправлять / получать ответы без доп инструкции, или нужно каждый раз необходимую информацию посылать иначе он не будет знать нюансов и специфики в работе моей компании?


    1. BrNikita Автор
      20.07.2023 08:25

      Здравствуйте, в текущей реализации сделан пока только интерфейс к платной GPT модели от Open AI, чат не делает ничего более того, что делает СhatGPT.

      В следующей версии я планирую добавить альтернативные LLM модели. Возможно, сделаем пример взаимодействия с API стороннего сервиса через модель GPT (о чем написано в самом начале статьи).

      То, что вы описываете. Я вижу два варианта:
      1. Передавать весь контекст с каждым запросом. Для этого можно выбрать модель с большим контекстным окном и прописать, чтобы весь контекст прикрплялся к каждому сообщению автоматически.
      2. Использовать модель обученную на ваших данных.

      То, о чем вы пишете, это интересная задача, я подумаю над тем как её можно было бы удобнее реализовать в проекте.

      Добавил в идеи: https://github.com/soshace/fosterflow/issues/24