Автор Хассан Джирдех (@djirdehh)

Эта статья является второй статьей и учебным пособием, отправленным в информационном бюллетене frontendfresh.com. Подпишитесь нановостную рассылку Front-end Fresh, чтобы еженедельно получать советы, учебные пособия и проекты по разработке интерфейсов на ваш почтовый ящик!

На прошлой неделе мы создали сервер Node.js/Express, который предоставляет конечную точку /ask. Когда эта конечная точка срабатывает и мы включаем текст prompt, конечная точка взаимодействует с /completions API OpenAI для создания и возврата продолжения этого текста.

Когда мы проверили это с помощью примера приглашения, такого как "How is the weather in Dubai?", API вернул нам правильный ответ.

Сегодня мы собираемся создать пользовательский интерфейс (то есть пользовательский интерфейс), напоминающий чат-бот, где пользователь может ввести вопрос и получить ответ от созданного нами бэкэнд-API Node.js.

Скаффолдинг приложения React

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

У меня есть планы написать электронное письмо, в котором более подробно рассматривается Vite, но в целом Vite — это инструмент сборки и сервер разработки, предназначенный для оптимизации процесса разработки современных веб-приложений. Вспомните Webpack, но с более быстрым временем сборки/запуска и несколькими дополнительными улучшениями.

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

npm create vite@latest

Затем нам дадут несколько подсказок для заполнения. Мы укажем, что хотим, чтобы наш проект назывался custom_chat_gpt_frontend, и мы хотим, чтобы это было приложение React/JavaScript.

$ npm create vite@latest
✔ Project name: custom_chat_gpt_frontend
✔ Select a framework: › React
✔ Select a variant: › JavaScript

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

npm install

Когда зависимости проекта будут завершены, мы запустим наш интерфейсный сервер с:

npm run dev

Затем нам будет представлено работающее приложение-скаффолд по адресу https://localhost:5173/.

Создание разметки и стилей

Мы начнем нашу работу с того, что сначала сосредоточимся на создании разметки (т. е. HTML/JSX) и стилей (т. е. CSS) нашего приложения.

В созданном приложении React мы заметим, что для нас была создана куча файлов и каталогов. Мы будем работать полностью в каталоге src/. Для начала мы изменим автоматически сгенерированный код в нашем компоненте src/App.jsx, чтобы он просто возвращал «Hello world!».

import "./App.css";

function App() {
  return <h2>Hello world!</h2>;
}

export default App;

Мы удалим шаблонные стили CSS в нашем файле src/index.css и оставим только следующее.

html,
body,
#root {
  height: 100%;
  font-size: 14px;
  font-family: arial, sans-serif;
  margin: 0;
}

А в файле src/App.css мы удалим все изначально предоставленные классы CSS.

/* App.css CSS styles to go here */
/* ... */

Сохранив наши изменения, мы увидим «Hello world!» сообщение.

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

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

.app {
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  background-color: rgba(0, 0, 0, 0.1);
}

.app-container {
  width: 1000px;
  max-width: 100%;
  padding: 0 20px;
  text-align: center;
}

.spotlight__wrapper {
  border-radius: 12px;
  border: 1px solid #dfe1e5;
  margin: auto;
  max-width: 600px;
  background-color: #fff;
}

.spotlight__wrapper:hover,
.spotlight__wrapper:focus {
  background-color: #fff;
  box-shadow: 0 1px 6px rgb(32 33 36 / 28%);
  border-color: rgba(223, 225, 229, 0);
}

.spotlight__input {
  display: block;
  height: 56px;
  width: 80%;
  border: 0;
  border-radius: 12px;
  outline: none;
  font-size: 1.2rem;
  color: #000;
  background-position: left 17px center;
  background-repeat: no-repeat;
  background-color: #fff;
  background-size: 3.5%;
  padding-left: 60px;
}

.spotlight__input::placeholder {
  line-height: 1.5em;
}

.spotlight__answer {
  min-height: 115px;
  line-height: 1.5em;
  letter-spacing: 0.1px;
  padding: 10px 30px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.spotlight__answer p::after {
  content: "";
  width: 2px;
  height: 14px;
  position: relative;
  top: 2px;
  left: 2px;
  background: black;
  display: inline-block;
  animation: cursor-blink 1s steps(2) infinite;
}

@keyframes cursor-blink {
  0% {
    opacity: 0;
  }
}

Теперь мы перейдем к созданию разметки/JSX нашего компонента <App />. В файле src/App.jsx мы обновим компонент, чтобы он сначала возвращал несколько элементов-оболочек <div />.

import "./App.css";

function App() {
  return (
    <div className="app">
      <div className="app-container">
        <div className="spotlight__wrapper">
          /* ... */
        </div>
      </div>
    </div>
  );
}

export default App;

Внутри наших элементов-оболочек мы разместим элемент <input /> и элемент <div /> для представления раздела ввода и раздела ответа соответственно.

import lens from "./assets/lens.png";
import "./App.css";

function App() {
  return (
    <div className="app">
      <div className="app-container">
        <div className="spotlight__wrapper">
          <input
            type="text"
            className="spotlight__input"
            placeholder="Ask me anything..."
            style={{
              backgroundImage: `url(${lens})`,
            }}
          />
          <div className="spotlight__answer">
            Dubai is a desert city and has a warm and sunny climate throughout
          </div>
        </div>
      </div>
    </div>
  );
}

export default App;

Для элемента <input /> мы добавляем встроенное свойство стиля backgroundImage, где значением является изображение лупы .png, которое мы сохранили в нашем каталоге src/assets/. Вы можете найти копию этого изображения здесь.

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

Захват значения подсказки

Наш следующий шаг — зафиксировать значение prompt, которое вводит пользователь. Это необходимо сделать, поскольку мы намерены отправить это значение в API после отправки ввода. Мы зафиксируем введенное пользователем значение в свойстве состояния с меткой prompt и инициализируем его значением undefined.

import { useState } from "react";
import lens from "./assets/lens.png";
import "./App.css";

function App() {
  const [prompt, updatePrompt] = useState(undefined);

  return (
    /* ... */
  );
}

export default App;

Когда пользователь вводит элемент <input />, мы обновляем значение состояния prompt с помощью обработчика событий onChange().

import { useState } from "react";
import lens from "./assets/lens.png";
import "./App.css";

function App() {
  const [prompt, updatePrompt] = useState(undefined);

  return (
    <div className="app">
      <div className="app-container">
        <div className="spotlight__wrapper">
          <input
            // ...
            onChange={(e) => updatePrompt(e.target.value)}
          />
          // ...
        </div>
      </div>
    </div>
  );
}

export default App;

Мы хотим, чтобы ввод был «отправлен» в тот момент, когда пользователь нажимает клавишу «Ввод». Для этого мы воспользуемся обработчиком событий onKeyDown(), и он вызовет функцию sendPrompt(), которую мы создадим.

В функции sendPrompt() мы вернемся раньше, если пользователь введет ключ, который не является ключом "Enter". В противном случае мы будем console.log() значением состояния prompt.

import { useState } from "react";
import lens from "./assets/lens.png";
import "./App.css";

function App() {
  const [prompt, updatePrompt] = useState(undefined);

  const sendPrompt = async (event) => {
    if (event.key !== "Enter") {
      return;
    }
    console.log('prompt', prompt)
  }

  return (
    <div className="app">
      <div className="app-container">
        <div className="spotlight__wrapper">
          <input
            // ...
            onChange={(e) => updatePrompt(e.target.value)}
            onKeyDown={(e) => sendPrompt(e)}
          />
          // ...
        </div>
      </div>
    </div>
  );
}

export default App;

Теперь, если мы введем что-то во входные данные и нажмем клавишу «Ввод», нам будет представлено это входное значение в нашей консоли.

Запуск API

Последним шагом в нашей реализации является запуск API, когда пользователь нажимает клавишу «Enter» после ввода подсказки во входных данных.

Нам нужно зафиксировать два других свойства состояния, которые будут отражать информацию о нашем запросе API — состояние loading нашего запроса и состояние answer, возвращенное из успешного запроса. Мы инициализируем loading с помощью false и answer с undefined.

import { useState } from "react";
import lens from "./assets/lens.png";
import "./App.css";

function App() {
  const [prompt, updatePrompt] = useState(undefined);
  const [loading, setLoading] = useState(false);
  const [answer, setAnswer] = useState(undefined);

  const sendPrompt = async (event) => {
    // ...
  }

  return (
    // ...
  );
}

export default App;

В нашей функции sendPrompt() мы будем использовать оператор try/catch для обработки ошибок, которые могут возникнуть при асинхронном запросе к нашему API.

const sendPrompt = async (event) => {
  if (event.key !== "Enter") {
    return;
  }

  try {
  
  } catch (err) {
  
  }
}

В начале блока try мы установим для свойства состояния loading значение true. Затем мы подготовим параметры нашего запроса, а затем используем собственный метод браузера fetch() для запуска нашего запроса. Мы направим наш запрос на конечную точку с меткой api/ask (через секунду мы объясним почему).

const sendPrompt = async (event) => {
  if (event.key !== "Enter") {
    return;
  }

  try {
    setLoading(true);

    const requestOptions = {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ prompt }),
    };

    const res = await fetch("/api/ask", requestOptions);
  } catch (err) {

  }
}

Если ответ не будет успешным, мы выдадим ошибку (и console.log() ее). В противном случае мы получим значение ответа и обновим им наше свойство состояния answer.

Это делает нашу функцию sendPrompt() в ее законченном состоянии следующей:

const sendPrompt = async (event) => {
  if (event.key !== "Enter") {
    return;
  }

  try {
    setLoading(true);

    const requestOptions = {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ prompt }),
    };

    const res = await fetch("/api/ask", requestOptions);

    if (!res.ok) {
      throw new Error("Something went wrong");
    }

    const { message } = await res.json();
    setAnswer(message);
  } catch (err) {
    console.error(err, "err");
  } finally {
    setLoading(false);
  }
};

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

Когда наше свойство состояния loading равно true, мы хотим, чтобы ввод был отключен, и мы также хотим отображать вращающийся индикатор вместо изображения увеличительной линзы (чтобы сообщить пользователю, что запрос «загружается»).

Мы будем отображать вращающийся индикатор, условно диктуя значение стиля backgroundImage элемента <input /> на основе статуса значения loading. Мы будем использовать этот spinner GIF, который мы сохраним в нашем каталоге src/assets/.

import { useState } from "react";
import loadingGif from "./assets/loading.gif";
import lens from "./assets/lens.png";
import "./App.css";

function App() {
  // ...
  return (
    <div className="app">
      <div className="app-container">
        <div className="spotlight__wrapper">
          <input
            // ...
            disabled={loading}
            style={{
              backgroundImage: loading ? `url(${loadingGif})` : `url(${lens})`,
            }}
            // ...
          />
          // ...
        </div>
      </div>
    </div>
  );
}

В разделе ответа нашей разметки мы условно добавим тег абзаца, который содержит значение {answer}, если оно определено.

import { useState } from "react";
import loadingGif from "./assets/loading.gif";
import lens from "./assets/lens.png";
import "./App.css";

function App() {
  // ...
  return (
    <div className="app">
      <div className="app-container">
        <div className="spotlight__wrapper">
          // ...
          <div className="spotlight__answer">{answer && <p>{answer}</p>}</div>
        </div>
      </div>
    </div>
  );
}

Последнее, что нам нужно сделать, это установить значение состояния {answer} обратно на undefined, если пользователь когда-либо очистит ввод. Мы сделаем это с помощью хука React useEffect().

import { useState, useEffect } from "react";
// ...

function App() {
  const [prompt, updatePrompt] = useState(undefined);
  const [loading, setLoading] = useState(false);
  const [answer, setAnswer] = useState(undefined);

  useEffect(() => {
    if (prompt != null && prompt.trim() === "") {
      setAnswer(undefined);
    }
  }, [prompt]);

  // ...

  return (
    // ...
  );
}

export default App;

Это все изменения, которые мы внесем в наш компонент <App />! Прежде чем мы сможем протестировать наше приложение, нам нужно сделать одну небольшую вещь.

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

В нашем проекте Vite React мы хотим отправлять запросы API на внутренний сервер, работающий в другом источнике (то есть на другом порту localhost:5000), чем тот, с которого обслуживается веб-приложение (localhost:5173). Однако из-за политики одного источника, применяемой веб-браузерами, такие запросы могут быть заблокированы из соображений безопасности.

Чтобы обойти это при работе в среде разработки, мы можем настроить обратный прокси-сервер на внешнем сервере (например, на нашем сервере Vite) для пересылки запросов на внутренний сервер, эффективно делая API внутреннего сервера доступным из того же источника, что и внешний интерфейс. приложение.

Vite позволяет нам сделать это, изменив значение server.proxy в файле конфигурации Vite (то есть vite.config.js).

В файле vite.config.js, который уже существует в нашем проекте, мы укажем прокси-сервер в качестве конечной точки /api. Конечная точка /api будет переадресована на https://localhost:5000.

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      "/api": {
        target: "https://localhost:5000",
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ""),
      },
    },
  },
});

Теперь, когда наш внешний интерфейс делает запрос к /api/ask, он перенаправляется на внутренний сервер, работающий по адресу https://localhost:5000/ask.

Тестирование нашего приложения

Мы закончили создание нашего простого приложения для чат-бота. Проверим нашу работу!

Во-первых, нам нужно, чтобы наш сервер Node/Express из последнего руководства работал. Мы перейдем в этот каталог проекта и запустим node index.js, чтобы начать работу.

$ custom_chat_gpt: node index.js

Мы сохраним наши изменения в нашем внешнем приложении и перезапустим внешний сервер.

$ custom_chat_gpt_frontend: npm run dev

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

Мы даже можем попробовать задать нашему чат-боту что-то более конкретное, например "What are the best doughnuts in Toronto Canada?".

Забавно, но когда я ищу пекарню Castro’s Lounge здесь, в Торонто, я получаю бар и место для живой музыки, а не пекарню. А Glazed & Confused Donuts, похоже, находится в Сиракузах, Нью-Йорк, а не в Торонто. Похоже, наш чат-бот еще можно немного улучшить — мы поговорим об этом в нашем последнем учебном письме из этой серии на следующей неделе 🙂.

Заключительные мысли

  • Вы можете найти исходный код этой статьи по адресу frontend-fresh/articles_source_code/custom_chat_gpt_frontend/.
  • Чтобы контролировать длину информации, возвращаемой из конечной точки OpenAI /completions, вы можете изменить поле настройки max_tokens в конфигурации OpenAI (см. пример здесь).
  • Подпишитесь на https://www.frontendfresh.com/, чтобы получать больше подобных руководств еженедельно!

Вот и все на сегодня! 🙂

— Хасан (@djirdehh)

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

Инструмент с открытым исходным кодом Bit помогает более чем 250 000 разработчиков создавать приложения с компонентами.

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

Подробнее

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

Микро-интерфейсы

Система дизайна

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

Монорепо

Узнать больше: