Как разработать чат-бота с помощью React и OpenAI
1 марта 2023 г.
В последней статье мы создали сервер Node.js/Express, который предоставляет конечная точка /ask
. Когда эта конечная точка срабатывает и мы включаем текстовое приглашение
, конечная точка взаимодействует с API /completions
OpenAI для создания и возврата продолжения этого текста.
Когда мы проверили это с помощью примера подсказки типа "Как погода в Дубае?"
, API вернул нам действительный ответ.
Сегодня мы собираемся создать пользовательский интерфейс (т. е. пользовательский интерфейс), напоминающий чат-бота, где пользователь может ввести вопрос и получить ответ от созданного нами серверного API Node.js.
Оглавление
- Создание шаблона приложения для реагирования
- Создание разметки & стили
- Захват значения подсказки
- Запуск API
- Проксирование запроса
- Тестирование нашего приложения
- Заключительные мысли
Скаффолдинг приложения React
Мы будем создавать пользовательский интерфейс нашего приложения с помощью библиотеки React JavaScript. Для начала нам нужно быстро создать среду разработки 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
Затем нам будет представлено запущенное приложение с каркасом по адресу http://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 "./App.css";
import lens from "./assets/lens.png";
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 "./App.css";
import lens from "./assets/lens.png";
function App() {
const [prompt, updatePrompt] = useState(undefined);
return (
/* ... */
);
}
export default App;
Когда пользователь вводит текст в элемент <input />
, мы обновляем значение состояния prompt
с помощью события onChange()
. обработчик.
import { useState } from "react";
import "./App.css";
import lens from "./assets/lens.png";
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 "./App.css";
import lens from "./assets/lens.png";
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, когда пользователь нажимает клавишу "Ввод" после ввода подсказки во входных данных.
Нам нужно зафиксировать два других свойства состояния, которые будут отражать информацию о нашем запросе API — состояние loading
нашего запроса и ответ
, возвращенный из успешного запроса. Мы инициализируем loading
с false
и answer
с undefined
.
import { useState } from "react";
import "./App.css";
import lens from "./assets/lens.png";
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< /код> значение. Мы будем использовать этот спиннер GIF который мы сохраним в нашем каталоге
src/assets/
.
import { useState } from "react";
import "./App.css";
import loadingGif from "./assets/loading.gif";
import lens from "./assets/lens.png";
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 "./App.css";
import loadingGif from "./assets/loading.gif";
import lens from "./assets/lens.png";
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
будет переадресована на http://localhost:5000
.
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": {
target: "http://localhost:5000",
changeOrigin: true,
rewrite: (path) => path.replace(/^/api/, ""),
},
},
},
});
Теперь, когда наш внешний интерфейс делает запрос к /api/ask
, он перенаправляется на внутренний сервер, работающий по адресу http://localhost:5000/ask
. р>
Тестирование нашего приложения
Мы закончили создание нашего простого приложения для чат-бота. Давайте проверим нашу работу!
Во-первых, нам нужно, чтобы наш сервер Node/Express из последнего учебника работал. Мы перейдем в этот каталог проекта и запустим node index.js
, чтобы начать работу.
$ custom_chat_gpt: node index.js
Мы сохраним наши изменения в интерфейсном приложении и перезапустим интерфейсный сервер.
$ custom_chat_gpt_frontend: npm run dev
В пользовательском интерфейсе нашего внешнего приложения мы предоставим подсказку и нажмем «Ввод». Должен пройти короткий период загрузки, прежде чем ответ будет заполнен и показан нам!
Мы даже можем попытаться задать нашему чат-боту что-то более конкретное, например "Какие пончики самые вкусные в Торонто, Канада?"
.
Довольно забавно, когда я ищу пекарню Castro's Lounge здесь, в Торонто, я получаю бар и концертная площадка, а не пекарня. И застекленный & Confused Donuts находится в Syracuse, New York — not Toronto. Похоже, наш чат-бот еще можно немного улучшить — мы поговорим об этом в нашем последнем учебном письме из этой серии на следующей неделе 🙂.
Заключительные мысли
- Исходный код этой статьи можно найти по адресу frontend-fresh/articles_source_code/custom_chat_gpt_frontend/< /а>.
- Чтобы контролировать длину информации, возвращаемой из конечной точки OpenAI
/completions
, вы можете изменить поле настройкиmax_tokens
в конфигурации OpenAI (см. пример здесь).
Исходная статья была разослана свежим интерфейсом а> информационный бюллетень.
Оригинал