Как чат-компания, мы, естественно, хотели проверить модель OpenAI ChatGPT (и немного повеселиться с ней!), Пытаясь использовать ее в качестве чат-бота в разговоре между друзьями.

Так как у ChatGPT пока нет официального API, нам пришлось потрудиться 😅.

Понимание API

Интерфейс по умолчанию для взаимодействия с ChatGPT упрощен. В нем есть текстовое поле, несколько подсказок и область для живого разговора.

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

При этом мы пытаемся определить несколько вещей:

  1. Формат отправляемых и принимаемых данных
  2. Стратегия аутентификации
  3. Возможность создания собственного клиента путем воспроизведения запроса в среде за пределами браузера.

Глядя на сетевой трафик из Chrome, мы можем получить представление о том, как данные структурированы и обрабатываются.

Первое, что бросается в глаза, — это количество заголовков, отправляемых на последующие запросы. Из приведенного выше видео мы видим две основные конечные точки, вызываемые после отправки приглашения в API:

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

Conversation — это запрос, который нас интересует для нашего примера приложения. Чтобы лучше понять отправляемые и получаемые данные, давайте импортируем запрос в Postman как cURL:

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

💡 Примечание. Если бы вы исследовали токен дальше, вставив его в отладчик JWT, вы бы заметили относительно короткий TTL. Токен можно расширить с помощью session_token в качестве обновления, но мы не будем рассматривать это в этом посте.

По мере того, как мы переходим к телу, запрос требует, чтобы мы предоставили три параметра в формате JSON:

  1. Действие
  2. Список сообщений
  3. Идентификатор беседы
  4. Родительское сообщение
  5. Используемая модель GPT

С нашим запросом, добавленным в Postman, мы можем скрестить пальцы и надеяться, что он успешно выполнится 😁🤞:

Глядя на ответ от API в первый раз, было интересно! — возвращает поток текстовых данных с префиксом ключевого слова data, содержащий объект сообщения с ответом по частям.

Глядя на ответ в контексте официальной демонстрации, он, вероятно, допускает эффект «печатания», наблюдаемый, когда ИИ отвечает на ваше приглашение на веб-сайте.

Для нас нас не слишком волнует поток данных. Нас больше всего интересует конечный объект, который содержит готовый ответ:

{
  "message": {
    "id": "5a5e0a89-99d0-4d9c-a8ec-765f5ea26b83",
    "role": "assistant",
    "user": null,
    "create_time": null,
    "update_time": null,
    "content": {
      "content_type": "text",
      "parts": [
        "I am an AI language model and do not have the ability to experience emotions or feelings. However, I am here to assist you with any questions or information you may need. How can I help you today?"
      ]
    },
    "end_turn": null,
    "weight": "1.0",
    "metadata": {},
    "recipient": "all"
  },
  "conversation_id": "ab21dc8c-39d4-4589-90b6-ff5c5af364e3",
  "error": null
}

Благодаря нашему отличному пониманию API пришло время приступить к созданию нашего бэкенда 😁.

Создание нашего бэкэнда

Для нашего бэкэнда мы создадим простой сервер NodeJS, который выступает в качестве промежуточного звена между нашим клиентским приложением и GPT API.

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

  1. Наше приложение активирует ChatGPT с помощью команды косой черты
  2. Сервер NodeJS ответит на команду и передаст запрос в ChatGPT.
  3. ChatGPT вернет поток данных
  4. Node API проанализирует поток и вызовет ответ бота в чате.

После создания каталога и package.json мы можем установить пакеты, необходимые для нашего API:

npm install dotenv express node-fetch stream-chat

Идеальный! Теперь мы можем начать писать код.

В нашем файле index.js давайте начнем с определения очень простого веб-сервера с одним маршрутом ' POST ':

import express from "express";

const app = express();
app.use(express.json());

app.post("/gpt-request", async (request, response, next) => {
   response.status(200);
});

app.listen(3000, () => console.log(`Server running on 3000`));

Затем давайте настроим наш файл .env с секретами, чтобы оживить наш проект:

OPENAI_AUTHORIZATION_KEY=YOUR-AUTH-TOKEN-COPIED-FROM-CHROME
OPENAI_COOKIE=YOUR-COOKIE-COPIED-FROM-CHROME

STREAM_API_KEY=YOUR-STREAM-KEY
STREAM_API_SECRET=YOUR-STREAM-SECRET

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

Как только ваш .env будет заполнен, мы можем перейти к загрузке значений в наш файл index.js:

import * as dotenv from 'dotenv';

...

dotenv.config({path: "../.env"})

const OPENAI_AUTHORIZATION_KEY = process.env.OPENAI_AUTHORIZATION_KEY;
const OPENAI_COOKIE = process.env.OPENAI_COOKIE;
const STREAM_API_KEY = process.env.STREAM_API_KEY;
const STREAM_API_SECRET = process.env.STREAM_API_SECRET;

Регистрация нашего вебхука 🪝

Ключевая часть нашего приложения зависит от прослушивания триггеров команд и ответа на них. Эти триггеры можно создавать с помощью вебхуков и пользовательских команд.

Чтобы создать наш вебхук, давайте создадим новый экземпляр StreamClient:

const serverClient = StreamChat.getInstance(STREAM_API_KEY, STREAM_API_SECRET);

Затем мы можем запросить Stream, чтобы получить список команд и создать новый, если наша команда «gpt» не существует.

Чтобы протестировать наш локальный сервер и зарегистрировать обработчик URL-адресов, нам потребуется использовать такой инструмент, как ngrok, для обслуживания нашего локального сервера (если вы не решите его где-то развернуть):

export async function configureStream(serverClient) {
  const {commands} = await serverClient.listCommands();
  const commandExists = commands.find((command) => command.name === "gpt");
  if (!commandExists) {
      serverClient.createCommand({
          name: "gpt",
          description: "Have a question? Ask your friendly GPT AI for help!",
          args: "[question]",
      })
          .then(_ => console.log(`Added command for Gpt`))
          .catch((err) => console.error(`Something went wrong adding Hugo custom command ${err}`));

      serverClient.updateAppSettings({
          custom_action_handler_url: "YOUR-NGROK-ENDPOINT",
      })
          .then(r => console.log(r))
          .catch(e => console.error(`Unable to add custom action URL ${e}`));
  }
}

Определив наш метод конфигурации, мы можем снова запустить сервер с помощью ngrok, чтобы создать собственный обработчик команд и URL-адрес.

💡 Боковая панель. Существует множество различных типов каналов, которые вы можете использовать с помощью Stream. Каждый тип оптимизирован для разных вариантов использования, и некоторые функции могут быть включены или отключены в зависимости от ваших предпочтений. Например, при прямых трансляциях по умолчанию отключены уведомления о прочтении, поскольку они обычно не используются в приложениях для прямых трансляций.

Вернувшись в наш код, мы можем начать реализацию маршрута /gpt-request для обработки подключения к ChatGPT:

app.post("/gpt-request", async (request, response, next) => {
    const message = request.body.message;
    if (message.command === "gpt") {
        try {
            const text = message.args;

            const aiResponse = await fetch("https://chat.openai.com/backend-api/conversation", {
                "headers": {
                   "authority": "chat.openai.com",
                    "accept": " text/event-stream",
                    "authorization": OPENAI_AUTHORIZATION_KEY,
                    "content-type": "application/json",
                    "cookie": OPENAI_COOKIE,
                    "origin": "https://chat.openai.com",
                    "referer": "https://chat.openai.com/chat",
                    "sec-ch-ua": `"Not?A_Brand";v="8", "Chromium";v="108", "Google Chrome";v="108"`,
                    "sec-ch-ua-mobile": `?0`,
                    "sec-ch-ua-platform": `macOS`,
                    "sec-fetch-dest": `empty`,
                    "sec-fetch-mode": `cors`,
                    "sec-fetch-site": `same-origin`,
                    "user-agent": `Mozilla/5.0 (Macintosh; Intel Mac OS X 10channel7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36`,
                    "x-openai-assistant-app-id": "",
                },
                "body": JSON.stringify({
                    "action": "next",
                    "messages": [{
                        "id": "ffa75905-d80e-4c74-bbd1-7adfe6ba523e",
                        "role": "user",
                        "content": {"content_type": "text", "parts": text.split(" ")}
                    }],
                    "conversation_id": "ab21dc8c-39d4-4589-90b6-ff5c5af364e3",
                    "parent_message_id": "577372cf-a7f5-425e-8723-5d46bb98b7b0",
                    "model": "text-davinci-002-render"
                }),
                "method": "POST"
            });

            if (aiResponse.status === 200) {
                  // TODO: Handle and parse response 
            }
            next();
        } catch (exception) {
            console.log(`Exception Occurred`);
            console.error(exception);
        }
    }

});

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

Далее мы будем использовать пакет node-fetch для вызова ChatGPT API с заголовками и форматом, скопированными из Google Chrome.

Примечание. Не забудьте заменить токен авторизации и файл cookie вашей личной аутентификацией и файлом cookie, скопированным из Chrome или Postman.

💡 Совет для профессионалов: если щелкнуть правой кнопкой мыши сетевой запрос в Chrome, вы можете скопировать запрос как выборку NodeJS, что сгенерирует приведенный выше код.

if (aiResponse.status === 200) {
    const results = await aiResponse.text();
    const aiText = parseGPTResponse(results); // We will create this next
}

Получив ответ, мы можем проверить статус ответа, а затем получить текст, отправленный API.

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

Для достижения этой цели давайте создадим новый метод parseGPTResponse для анализа ответа:

function parseGPTResponse(formattedString) {
  const dataChunks = formattedString.split("data:");
  const responseObjectText = dataChunks[dataChunks.length - 2].trim();
  const responseObject = JSON.parse(responseObjectText);
  return responseObject.message.content.parts[0];
}

Мы разделим данные на основе префикса data:, что оставит нам массив объектов сообщений. После создания массива мы можем извлечь окончательное значение из предпоследнего значения массива.

С идентифицированным объектом мы можем проанализировать объект и развернуть его, чтобы получить текст 🙂.

Отправка сообщений

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

if (aiResponse.status === 200) {
    const results = await aiResponse.text();
    const aiText = parseGPTResponse(results);

    const channelSegments = message.cid.split(":");
    const channel = serverClient.channel(channelSegments[0], channelSegments[1]);
    message.text = "";
    channel.sendMessage({
        text: aiText,
        user: {
            id: "admin",
            image: "https://openai.com/content/images/2022/05/openai-avatar.png",
            name: "ChatGPT bot",
        },
    }).catch((error) => console.error(error));
    response.json({
        status: true,
        text: "",
    });
}

Чтобы отправить сообщение, нам нужно создать новый объект channel, используя серверный SDK Stream с типом канала и идентификатором. Они могут быть созданы путем разделения параметра message.cid входящего объекта.

Как только канал создан, мы можем вызвать sendMessage, передав текст и отправив ответ пользователя.

Что касается пользовательского объекта, мы можем немного поразвлечься и дать ему имя и URL-адрес фотографии. Это будет отображаться во внешнем интерфейсе как пользователь в канале.

Создание внешнего интерфейса

Для простоты мы будем использовать Stream’s UI SDK для быстрого создания интерфейса для взаимодействия с API. Мы будем использовать Flutter SDK для этого урока, но того же результата можно добиться, используя любой из других Frontend-клиентов Stream.

Давайте начнем с создания нового проекта Flutter и добавления соответствующих зависимостей:

flutter create gpt_chat
cd gpt_chat && flutter pub add stream_chat_flutter

Затем давайте откроем файл main.dart и удалим сгенерированный код следующим образом:

import 'package:gpt_chat/users.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';

// We set our Stream API Key as a `--dart-define` value
const String streamApi = String.fromEnvironment("STREAM_API");

Future<void> main() async {
  // Let's create our client passing it the API and setting the default log level
  final streamClient = StreamChatClient(
    streamApi,
    logLevel: Level.INFO,
  );

  // Set the user for the current running application.
  // To quickly generate a test token for users, check out our JWT generator (https://getstream.io/chat/docs/react/token_generator/)
  await streamClient.connectUser(
    User(id: kDemoUserNash.userId),
    kDemoUserNash.token,
  );

  // Configure the channel we would like to use for messages.
  // Note: You should create the users ahead of time in Stream's dashboard else you will encounter an error.
  final streamChannel = streamClient.channel(
    'messaging',
    id: 'gpt-demo',
    extraData: {
      "name": "Humans and AI",
      "members": ["nash", "jeroen"]
    },
  );

  // Listen for events on our newly created channel. New messages, status changes, etc.
  await streamChannel.watch();

  runApp(
    GTPChat(
      chatClient: streamClient,
      chatChannel: streamChannel,
    ),
  );
}

Приведенный выше код извлекает ключ API приложения из окружения, а затем создает новый экземпляр класса StreamChatClient, с которым мы можем взаимодействовать.

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

Наконец, мы можем создать канал, дать ему имя, добавить пользователей, с которыми мы хотели бы пообщаться, а затем вызвать channel.watch.

На стороне потока это действие создаст канал, если он не существует, а затем прослушивает события на канале, такие как новые сообщения, индикаторы, изменения статуса и т. д.

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

Flutter SDK Stream полагается на InheritedWidgets для распространения информации по всему приложению, поэтому, прежде чем мы сможем отобразить любой из наших списков сообщений или диалогов, виджет домашней страницы/корня должен быть обернут StreamChat и StreamChannel:

class GTPChat extends StatefulWidget {
  const GTPChat({
    super.key,
    required this.chatClient,
    required this.chatChannel,
  });

  final StreamChatClient chatClient;
  final Channel chatChannel;

  @override
  State<GTPChat> createState() => _GTPChatState();
}

class _GTPChatState extends State<GTPChat> {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'GPT and Stream',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const App(),

      // Data is propagated down the widget tree via inherited widgets. Before
      // we can use Stream widgets in our code, we need to set the value of the client.
      builder: (context, child) => StreamChat(
        client: widget.chatClient,
        child: StreamChannel(
          channel: widget.chatChannel,
          child: child!,
        ),
      ),
    );
  }
}

Наконец, мы можем перейти к созданию нашего виджета App и реализации пользовательского интерфейса, с которым мы хотим, чтобы наши пользователи взаимодействовали:

class App extends StatefulWidget {
  const App({Key? key}) : super(key: key);

  @override
  State<App> createState() => _AppState();
}

class _AppState extends State<App> {
  @override
  Widget build(BuildContext context) {
    return const ChannelPage();
  }
}

class ChannelPage extends StatelessWidget {
  const ChannelPage({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: const StreamChannelHeader(),
      body: Column(
        children: const <Widget>[
          Expanded(
            child: StreamMessageListView(),
          ),
          StreamMessageInput(),
        ],
      ),
    );
  }
}

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

С нашим кодом давайте запустим наше приложение и посмотрим на результаты!

Собираем все вместе!

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

Если все работает, как задумано, когда пользователь в чате набирает /gpt hello, наш сервер NodeJS должен принять команду и подключиться к ChatGPT для ответа.

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

Если вы столкнетесь с ошибками при вызове команды, обязательно перепроверьте свой токен авторизации и файл cookie. OpenAI также начал ограниченные внешние вызовы API, поэтому вам может потребоваться убедиться, что вызов все еще работает вне браузера. Файлы проекта можно найти на моем Github, если вам интересно их опробовать.

Создать что-то классное с помощью Stream или OpenAI? Напишите нам в Twitter @ getstream_io или отметьте нас на LinkedIn. Нам не терпится увидеть, что вы строите!

Первоначально опубликовано на https://getstream.io.