Tutorial Membangun Chatbot Dengan React Dan OpenAI
Saat saya menguji ini dengan contoh prompt seperti"Bagaimana cuaca di Dubai?"
, API mengembalikan jawaban yang valid kepada saya.
Hari ini, saya akan membuat Antar-muka pengguna (yakni UI) yang seperti chatbot tempat pengguna bisa menulis pertanyaan dan terima jawaban dari API backend Node.js yang saya bikin.
Scaffolding program react
saya akan membuat UI program saya dengan perpustakaan React JavaScript. Untuk mengawali, pertama kali kita ingin merancah lingkungan peningkatan React secara cepat dan kita akan melakukan dengan kontribusi Vite.
Saya mempunyai gagasan untuk menulis email yang mengulas lebih dalam mengenai Vite, tapi secara singkat, Vite adalah alat build dan server peningkatan yang direncanakan untuk memaksimalkan pengalaman peningkatan program situs kekinian. Pikir Webpack tapi sama waktu pembikinan/mulai yang bisa lebih cepat dan beberapa kenaikan tambahan.
Untuk mengawali scaffolding program React saya, saya akan ikuti sisi dokumentasi Mengawali Vite, dan saya akan jalankan yang berikut di terminal saya.
npm create vite@latest
saya kemudian akan diberikan beberapa petunjuk untuk diisi. saya akan menyatakan bahwa saya ingin proyek saya diberi nama custom_chat_gpt_frontend
dan saya ingin itu menjadi aplikasi React/JavaScript.
$ npm create vite@latest
✔ Project name: custom_chat_gpt_frontend
✔ Select a framework: › React
✔ Select a variant: › JavaScript
saya kemudian dapat menavigasi ke direktori proyek dan menjalankan yang berikut untuk menginstal dependensi proyek.
npm install
Ketika dependensi proyek selesai diinstal, saya akan menjalankan server front-end saya dengan:
npm run dev
saya kemudian akan disajikan dengan aplikasi scaffolded yang sedang berjalan.
Membuat markup dan gaya
saya akan memulai pekerjaan saya dengan terlebih dahulu berfokus pada pembuatan markup (yaitu HTML/JSX) dan gaya (yaitu CSS) aplikasi saya.
Dalam aplikasi React scaffolded, saya akan melihat banyak file dan direktori telah dibuat untuk saya. saya akan bekerja sepenuhnya di dalam direktori src/
. Untuk memulai, kita akan memodifikasi kode yang dibuat secara otomatis di komponen src/App.jsx
kita untuk mengembalikan "Hello world!".
import "./App.css";
function App() {
return <h2>Hello world!</h2>;
}
export default App;
saya akan menghapus gaya CSS scaffolded di file src/index.css
saya dan hanya memiliki yang berikut ini.
html,
body,
#root {
height: 100%;
font-size: 14px;
font-family: arial, sans-serif;
margin: 0;
}
Dan di file src/App.css
, saya akan menghapus semua kelas CSS yang awalnya disediakan.
/* App.css CSS styles to go here */
/* ... */
Menyimpan perubahan saya, saya akan disajikan dengan "Halo dunia!" pesan.
saya tidak akan menghabiskan banyak waktu di email ini untuk menguraikan bagaimana gaya UI saya. Untuk meringkas dengan cepat, aplikasi final kita hanya akan berisi satu bagian bidang masukan yang menangkap apa yang diketik pengguna dan jawaban yang dikembalikan dari API.
Kita akan menata UI aplikasi kita dengan CSS standar. saya akan menempelkan CSS berikut ke file src/App.css
saya yang akan berisi semua CSS yang saya perlukan.
.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;
}
}
Sekarang kita akan beralih ke pembuatan markup/JSX dari komponen <App />
kita. Di file src/App.jsx
, kita akan memperbarui komponen untuk mengembalikan beberapa elemen <div />
pembungkus terlebih dahulu.
import "./App.css";
function App() {
return (
<div className="app">
<div className="app-container">
<div className="spotlight__wrapper">
/* ... */
</div>
</div>
</div>
);
}
export default App;
Di dalam elemen pembungkus saya, saya akan menempatkan elemen <input />
dan elemen <div />
untuk masing-masing mewakili bagian input dan bagian jawaban.
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;
Untuk elemen <input />
, saya menambahkan properti gaya inline backgroundImage
di mana nilainya adalah gambar .png
dari kaca pembesar yang telah saya simpan di direktori src/assets/
saya. Anda dapat menemukan salinan gambar ini di sini.
Dengan perubahan saya disimpan, saya sekarang akan disajikan dengan UI aplikasi seperti yang saya harapkan.
Menangkap nilai prompt
Langkah kita selanjutnya adalah menangkap nilai prompt
yang diketik pengguna. Ini perlu dilakukan karena saya bermaksud mengirimkan nilai ini ke API saat input telah dikirimkan. saya akan menangkap nilai input pengguna di prompt
berlabel properti negara dan saya akan menginisialisasi dengan 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;
Saat pengguna mengetikkan elemen <input />
, kita akan memperbarui nilai state prompt
dengan menggunakan event handler 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;
saya ingin input "dikirim" pada saat pengguna menekan tombol "Enter". Untuk melakukannya, kita akan menggunakan event handler onKeyDown()
dan memicu fungsi sendPrompt()
yang akan kita buat.
Dalam fungsi sendPrompt()
, kita akan kembali lebih awal jika pengguna memasukkan kunci yang bukan kunci "Enter"
. Jika tidak, saya akan console.log()
nilai status 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;
Sekarang, jika kita mengetik sesuatu ke input dan menekan tombol "Enter", kita akan disajikan dengan nilai input tersebut di konsol kita.
Memicu API
Langkah terakhir untuk implementasi kita adalah memicu API saat pengguna menekan tombol "Enter" setelah mengetik prompt di input.
saya ingin mengambil dua properti status lainnya yang akan mencerminkan informasi permintaan API saya — status pemuatan permintaan saya dan jawaban yang dikembalikan dari permintaan yang berhasil. saya akan menginisialisasi loading
dengan false
dan answer
dengan 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;
Dalam fungsi sendPrompt()
saya, saya akan menggunakan pernyataan try/catch
untuk menangani kesalahan yang mungkin terjadi dari permintaan asinkron ke API saya.
const sendPrompt = async (event) => {
if (event.key !== "Enter") {
return;
}
try {
} catch (err) {
}
}
Di awal blok percobaan, saya akan menyetel properti pemuatan status ke true. saya kemudian akan menyiapkan opsi permintaan saya dan kemudian menggunakan metode ambil ()
browser asli untuk memicu permintaan saya. saya akan membuat permintaan saya mencapai titik akhir berlabel api/ask
(saya akan menjelaskan alasannya sebentar lagi).
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) {
}
}
Jika respons tidak berhasil, saya akan melemparkan kesalahan (dan console.log()
itu). Jika tidak, saya akan menangkap nilai respons dan memperbarui properti status jawaban saya dengannya.
Ini membuat fungsi sendPrompt()
saya dalam keadaan lengkapnya terlihat seperti berikut:
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);
}
};
Sebelum saya beralih ke pengujian bahwa permintaan saya berfungsi seperti yang diharapkan, saya akan menambahkan beberapa perubahan lagi ke komponen saya.
Ketika properti status loading
saya benar, saya ingin input dinonaktifkan dan saya juga ingin menampilkan indikator berputar sebagai pengganti gambar lensa pembesar (untuk menyampaikan kepada pengguna bahwa permintaan sedang "loading").
saya akan menampilkan indikator pemintalan dengan mendikte nilai gaya backgroundImage
dari elemen <input />
secara kondisional berdasarkan status nilai loading
. saya akan menggunakan GIF pemintal ini yang akan saya simpan di direktori src/assets/
saya.
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>
);
}
Di bagian jawaban dari markup saya, saya akan menambahkan tag paragraf yang berisi nilai {answer}
jika ditentukan.
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>
);
}
Hal terakhir yang ingin kita lakukan adalah mengembalikan nilai status {answer}
ke tidak terdefinisi jika pengguna menghapus input. Kita akan melakukannya dengan bantuan ReactuseEffect()
Hook.
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;
Itu semua perubahan yang akan saya lakukan pada komponen <App />
saya! Ada satu hal kecil yang harus kita lakukan sebelum kita dapat menguji aplikasi kita.
Proksi permintaan
Dalam proyek Vite React saya, saya ingin membuat permintaan API ke server backend yang berjalan pada asal yang berbeda (yaitu port yang berbeda dilocalhost:5000)
dari yang dilayani oleh aplikasi web (localhost:5173)
. Namun, karena kebijakan asal yang sama yang diberlakukan oleh browser web, permintaan semacam itu dapat diblokir karena alasan keamanan.
Untuk menyiasatinya saat bekerja dalam lingkungan pengembangan, saya dapat mengatur proxy terbalik di server frontend (yaitu server Vite saya) untuk meneruskan permintaan ke server backend, secara efektif membuat API server backend tersedia di asal yang sama dengan frontend app.
Vite memungkinkan kita melakukan ini dengan memodifikasi nilai server.proxy
di file konfigurasi Vite (yaitu vite.config.js)
Di file vite.config.js
yang sudah ada di proyek saya, saya akan menentukan proxy untuk menjadi titik akhir /api
. Endpoint /api
akan diteruskan ke http://localhost:5000
.
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": {
target: "http://localhost:5000",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
});
Sekarang, ketika front-end kita membuat permintaan ke /api/ask
, itu diteruskan ke server backend yang berjalan
Menguji aplikasi saya
saya telah selesai membangun aplikasi chatbot sederhana saya. Mari kita uji pekerjaan kita!
Pertama, kita perlu menjalankan server Node/Express dari tutorial terakhir. saya akan menavigasi ke direktori proyek itu dan menjalankan node index.js
untuk mewujudkannya.
$ custom_chat_gpt: node index.js
saya akan menyimpan perubahan saya di aplikasi front-end saya, dan memulai ulang server front-end.
$ custom_chat_gpt_frontend: npm run dev
Di UI aplikasi front-end saya, saya akan memberikan prompt dan tekan "Enter". Harus ada periode pemuatan singkat sebelum jawabannya diisi dan ditampilkan kepada saya!
saya bahkan dapat mencoba dan menanyakan chatbot saya sesuatu yang lebih spesifik seperti "What are the best doughnuts in Toronto Canada?"
.
cukup lucu, ketika saya mencari toko roti Castro's Lounge di sini di Toronto, saya mendapatkan bar dan tempat musik live, bukan toko roti. Dan Glazed & Confused Donuts tampaknya berada di Syracuse, New York — bukan Toronto. Tampaknya masih ada ruang untuk menyempurnakan chatbot kita sedikit lebih baik — kita akan membicarakannya di email tutorial terakhir seri ini, minggu depan . Menutup pikiran
- Anda dapat menemukan kode sumber untuk artikel ini di frontend-fresh/articles_source_code/custom_chat_gpt_frontend/.
-
Untuk mengontrol panjang informasi yang dikembalikan dari titik akhir
/completions
OpenAI, Anda dapat mengubah bidang pengaturanmax_token
di konfigurasi OpenAI (lihat contoh di sini).
Sekian untuk hari ini!