Создание веб-приложения в стиле ChatGPT на чистом Python с помощью Reflex

Чат-приложение

Перевод статьи «Build a ChatGPT-esque Web App in Pure Python using Reflex».

Последние несколько месяцев я играю со всеми новыми невероятными чат-ботами LLM, включая Llama 2, GPT-4, Falcon 40B и Claude 2. Меня постоянно мучает один вопрос: как создать собственный пользовательский интерфейс чат-бота, который будет обращаться ко всем этим замечательным LLM как к API?

Вариантов создания красивых пользовательских интерфейсов существует бесчисленное множество. Но у меня как у ML-инженера нет опыта работы с JavaScript или каким-либо другим языком фронтенда. Я искал способ создать свое веб-приложение, используя только тот язык, который я знаю сейчас, – Python!

Я решил использовать Reflex – достаточно новый фреймворк с открытым исходным кодом. Он позволил мне построить как бэкенд, так и фронтенд исключительно на Python.

Дисклеймер:  я работаю инженером-основателем в компании Reflex, где вношу свой вклад в разработку фреймворка с открытым исходным кодом.

В этом руководстве я расскажу, как создать с нуля полноценное приложение для чата с искусственным интеллектом на чистом Python. Весь код можно также найти в GitHub-репозитории.

Вы узнаете, как:

  1. Установить reflex и настроить среду разработки.
  2. Создать компоненты для определения и стилизации пользовательского интерфейса.
  3. Использовать состояние для добавления интерактивности в ваше приложение.
  4. Развернуть приложение с помощью одной команды, которой можно поделиться с другими людьми.

Настройка проекта

Начнем с создания нового проекта и настройки среды разработки. Сначала создайте новый каталог для проекта и перейдите в него.

~ $ mkdir chatapp
~ $ cd chatapp

Далее мы создадим виртуальную среду для нашего проекта. В данном примере для создания виртуальной среды мы будем использовать venv.

chatapp $ python3 -m venv .venv
$ source .venv/bin/activate

Теперь установим Reflex и создадим новый проект. Это создаст новую структуру каталогов в каталоге нашего проекта.

chatapp $ pip install reflex
chatapp $ reflex init
────────────────────────────────── Initializing chatapp ───────────────────────────────────
Success: Initialized chatapp
chatapp $ ls
assets          chatapp         rxconfig.py     .venv

Вы можете запустить приложение-шаблон, чтобы убедиться, что все работает.

chatapp $ reflex run
─────────────────────────────────── Starting Reflex App ───────────────────────────────────
Compiling:  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 1/1 0:00:00
─────────────────────────────────────── App Running ───────────────────────────────────────
App running at: http://localhost:3000

Перейдя по адресу http://localhost:3000, вы должны увидеть, что ваше приложение запущено.

Reflex также запускает сервер бэкенда, который управляет всеми состояниями и взаимодействует с фронтендом. Чтобы проверить, что сервер бэкенда запущен, перейдите по адресу http://localhost:8000/ping.

Теперь, когда проект настроен, давайте создадим наше приложение!

Базовый фронтенд

Начнем с определения фронтенда для нашего чат-приложения. В Reflex фронтенд можно разбить на независимые компоненты многократного использования. Дополнительную информацию можно найти в документации по компонентам.

Отображение вопроса и ответа

Мы модифицируем функцию index в файле chatapp/chatapp.py, чтобы вернуть компонент, отображающий один вопрос и ответ.

Текст, который должен выводиться:
- What is Reflex?
- A way to build web apps in pure Python!
# chatapp.py

import reflex as rx

def index() -> rx.Component:
    return rx.container(
        rx.box(
            "What is Reflex?",
            # The user's question is on the right.
            text_align="right",
        ),
        rx.box(
            "A way to build web apps in pure Python!",
            # The answer is on the left.
            text_align="left",
        ),
    )

# Add state and page to the app.
app = rx.App()
app.add_page(index)
app.compile()

Компоненты могут быть вложены друг в друга для создания сложных макетов. Здесь мы создаем родительский контейнер, содержащий два поля для вопроса и ответа.

Мы также добавляем некоторые базовые стили для компонентов. Компоненты принимают аргументы в виде ключевых слов – пропы, которые изменяют внешний вид и функциональность компонента. Мы используем проп text_align для выравнивания текста влево и вправо.

Переиспользование компонентов

Теперь, когда у нас есть компонент, отображающий один вопрос и ответ, мы можем переиспользовать его для отображения нескольких вопросов и ответов. Мы перенесем компонент в отдельную функцию question_answer и вызовем ее из функции index.

Диалог из двух вопросов и ответов.
def qa(question: str, answer: str) -> rx.Component:
    return rx.box(
        rx.box(question, text_align="right"),
        rx.box(answer, text_align="left"),
        margin_y="1em",
    )


def chat() -> rx.Component:
    qa_pairs = [
        (
            "What is Reflex?",
            "A way to build web apps in pure Python!",
        ),
        (
            "What can I make with it?",
            "Anything from a simple website to a complex web app!",
        ),
    ]
    return rx.box(
        *[
            qa(question, answer)
            for question, answer in qa_pairs
        ]
    )


def index() -> rx.Component:
    return rx.container(chat())

Инпут чата

Теперь мы хотим, чтобы пользователь мог ввести вопрос. Для этого мы используем компонент input для ввода текста и компонент button для отправки вопроса.

Диалог из двух вопросов и ответов и поле для ввода вопроса внизу. Рядом с полем ввода кнопка "Ask".
def action_bar() -> rx.Component:
    return rx.hstack(
        rx.input(placeholder="Ask a question"),
        rx.button("Ask"),
    )


def index() -> rx.Component:
    return rx.container(
        chat(),
        action_bar(),
    )

Стилизация

Давайте добавим в приложение некоторые стили. Более подробную информацию о стилях можно найти в документации по стилизации. Чтобы сохранить чистоту кода, мы перенесем стилизацию в отдельный файл chatapp/style.py.

# style.py

# Common styles for questions and answers.
shadow = "rgba(0, 0, 0, 0.15) 0px 2px 8px"
chat_margin = "20%"
message_style = dict(
    padding="1em",
    border_radius="5px",
    margin_y="0.5em",
    box_shadow=shadow,
    max_width="30em",
    display="inline-block",
)
# Set specific styles for questions and answers.
question_style = message_style | dict(
    bg="#F5EFFE", margin_left=chat_margin
)
answer_style = message_style | dict(
    bg="#DEEAFD", margin_right=chat_margin
)
# Styles for the action bar.
input_style = dict(
    border_width="1px", padding="1em", box_shadow=shadow
)
button_style = dict(bg="#CEFFEE", box_shadow=shadow)

Мы импортируем стили в chatapp.py и используем их в компонентах. На этом этапе приложение должно выглядеть следующим образом:

Тот же диалог с полем для ввода вопросов, но добавлены цвета: вопросы сиреневые, ответы голубые, кнопка "Ask" салатная.
# chatapp.py
import reflex as rx

from chatapp import style

def qa(question: str, answer: str) -> rx.Component:
    return rx.box(
        rx.box(
            rx.text(question, style=style.question_style),
            text_align="right",
        ),
        rx.box(
            rx.text(answer, style=style.answer_style),
            text_align="left",
        ),
        margin_y="1em",
    )

def chat() -> rx.Component:
    qa_pairs = [
        (
            "What is Reflex?",
            "A way to build web apps in pure Python!",
        ),
        (
            "What can I make with it?",
            "Anything from a simple website to a complex web app!",
        ),
    ]
    return rx.box(
        *[
            qa(question, answer)
            for question, answer in qa_pairs
        ]
    )

def action_bar() -> rx.Component:
    return rx.hstack(
        rx.input(
            placeholder="Ask a question",
            style=style.input_style,
        ),
        rx.button("Ask", style=style.button_style),
    )

def index() -> rx.Component:
    return rx.container(
        chat(),
        action_bar(),
    )

app = rx.App()
app.add_page(index)
app.compile()

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

Состояние

Теперь давайте сделаем приложение чата интерактивным, добавив состояние (англ. state). Состояние – это место, где мы определяем все переменные, которые могут изменяться в приложении, и все функции, которые могут их изменять. Подробнее о состоянии можно узнать в документации по состоянию.

Определение состояния

Мы создадим новый файл  state.py в каталоге chatapp. Наше состояние будет отслеживать текущий заданный вопрос и историю чата. Мы также определим обработчик события answer, который будет обрабатывать текущий вопрос и добавлять ответ в историю чата.

# state.py

import reflex as rx


class State(rx.State):
    # The current question being asked.
    question: str
    # Keep track of the chat history as a list of (question, answer) tuples.
    chat_history: list[tuple[str, str]]
    def answer(self):
        # Our chatbot is not very smart right now...
        answer = "I don't know!"
        self.chat_history.append((self.question, answer))

Привязка состояния к компонентам

Теперь мы можем импортировать состояние в chatapp.py и ссылаться на него в наших компонентах фронтенда. Мы модифицируем компонент chat для использования состояния вместо текущих фиксированных вопросов и ответов.

Выводится только поле для ввода вопроса и кнопка "Ask".
# chatapp.py

from chatapp.state import State


...


def chat() -> rx.Component:
    return rx.box(
        rx.foreach(
            State.chat_history,
            lambda messages: qa(messages[0], messages[1]),
        )
    )


...


def action_bar() -> rx.Component:
    return rx.hstack(
        rx.input(
            placeholder="Ask a question",
            on_change=State.set_question,
            style=style.input_style,
        ),
        rx.button(
            "Ask",
            on_click=State.answer,
            style=style.button_style,
        ),
    )

Обычные циклы for не подходят для итерации по переменным состояния, поскольку эти значения могут меняться и не известны на момент компиляции. Вместо этого для перебора истории чата мы используем компонент foreach.

Мы также привяжем событие входа on_change к обработчику события set_question, который будет обновлять переменную состояния question при вводе пользователем текста.

Мы привязываем событие on_click кнопки к обработчику событий answer, который будет обрабатывать вопрос и добавлять ответ в историю чата. Обработчик события set_question является встроенным неявно определенным обработчиком события. Каждый базовый var имеет такой обработчик. Подробнее об этом можно узнать в документации по событиям в разделе “Setters”.

Очистка ввода

В настоящее время значение input не очищается после нажатия пользователем кнопки. Мы можем исправить это, привязав значение ввода к question при помощи value=State.question, и очищать его при запуске обработчика события для answer при помощи self.question = ''.

# chatapp.py


def action_bar() -> rx.Component:
    return rx.hstack(
        rx.input(
            value=State.question,
            placeholder="Ask a question",
            on_change=State.set_question,
            style=style.input_style,
        ),
        rx.button(
            "Ask",
            on_click=State.answer,
            style=style.button_style,
        ),
    )
# state.py


def answer(self):
    # Our chatbot is not very smart right now...
    answer = "I don't know!"
    self.chat_history.append((self.question, answer))
    self.question = ""

Потоковый текст

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

# state.py
import asyncio


...


async def answer(self):
    # Our chatbot is not very smart right now...
    answer = "I don't know!"
    self.chat_history.append((self.question, ""))
    # Clear the question input.
    self.question = ""
    # Yield here to clear the frontend input before continuing.
    yield
    for i in range(len(answer)):
        # Pause to show the streaming effect.
        await asyncio.sleep(0.1)
        # Add one letter at a time to the output.
        self.chat_history[-1] = (
            self.chat_history[-1][0],
            answer[: i + 1],
        )
        yield

Использование API

Мы будем использовать API OpenAI, чтобы придать нашему чат-боту интеллектуальность. Для этого нам необходимо изменить обработчик событий, чтобы отправлять запрос к API.

# state.py

import os
import openai

openai.api_key = os.environ["OPENAI_API_KEY"]


...


def answer(self):
    # Our chatbot has some brains now!
    session = openai.ChatCompletion.create(
        model="gpt-3.5-turbo",
        messages=[
            {"role": "user", "content": self.question}
        ],
        stop=None,
        temperature=0.7,
        stream=True,
    )

    # Add to the answer as the chatbot responds.
    answer = ""
    self.chat_history.append((self.question, answer))

    # Clear the question input.
    self.question = ""
    # Yield here to clear the frontend input before continuing.
    yield

    for item in session:
        if hasattr(item.choices[0].delta, "content"):
            answer += item.choices[0].delta.content
            self.chat_history[-1] = (
                self.chat_history[-1][0],
                answer,
            )
            yield

Наконец, у нас есть наш чат-бот с искусственным интеллектом!

Заключение

Следуя этому руководству, вы успешно создали приложение Chat App, используя API-ключ OpenAI, исключительно на языке Python.

Для запуска этого приложения теперь можно выполнить простую команду:

$ reflex run

Чтобы развернуть приложение, т.е. предоставить его другим пользователям, мы можем выполнить следующую команду:

$ reflex deploy

Я надеюсь, что это руководство вдохновит вас на создание собственных приложений, основанных на LLM. Мне не терпится увидеть, что у вас получится в итоге, поэтому, пожалуйста, пишите в социальных сетях или в комментариях.

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *