Научете как да изградите уеб приложение, което позволява на потребителите да изпращат подкани към OpenAI API и предава поточно отговора обратно към потребителите с Nextjs 13.2, като използва директорията на приложението и манипулаторите на маршрути.

В тази публикация ще проучим как да подобрим потребителското изживяване на уеб приложение чрез поточно предаване на OpenAI API отговори, използвайки най-новия технологичен стек, включително Nextjs 13.2, GPT-3.5-turbo и Edge Functions. Ще научите как да създадете уеб приложение, което позволява на потребителите да изпращат подкани към OpenAI API и да получават отговори в реално време за поточно предаване. Като използваме директорията на приложението Nextjs и манипулаторите на маршрути, можем да създадем безпроблемно и ангажиращо потребителско изживяване. Така че нека се потопим и научим как да приложим тази вълнуваща функция.

Ще използваме следните технологии:

  • Next.js (бета функции) като рамка на React
  • Next.js Route Handler за API маршрути от страна на сървъра като бекенд
  • Tailwind CSS за стилизиране
  • OpenAI API за достъп до GPT-3.5-turbo
  • TypeScript като език за програмиране


Приготвяме се да започнем

Преди да започнете с внедряването, уверете се, че имате инсталиран Node.js.

Сега нека започнем, като създадем ново следващо приложение. Отворете терминал в директорията, където искате да стартирате вашия проект, и използвайте следната команда, за да създадете ново приложение:

npx create-next-app@latest

Въведете име на проект, изберете да за typescript, eslint, src/directory, app/directory и натиснете Enter за псевдонима за импортиране по подразбиране.

Нека отворим проекта във VSCode и да продължим с настройката там. Чувствайте се свободни да използвате всеки редактор на код, който харесвате.

След това искаме да инсталираме tailwindcss и неговите партньорски зависимости чрез npm и след това да изпълним командата init, за да генерираме както tailwind.config.cjs, така и postcss.config.cjs. Също така добавих prettier-plugin-tailwindcss към основната инсталация:

npm install -D tailwindcss postcss autoprefixer prettier-plugin-tailwindcss
npx tailwindcss init -p

Добавете пътищата към всички ваши шаблонни файлове във вашия tailwind.config.cjs файл:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ["./src/**/*.{js,ts,jsx,tsx}"],
  theme: {
    extend: {},
  },
  plugins: [],
};

Добавете директивите @tailwind за всеки от слоевете на Tailwind към вашия ./src/app/globals.css файл, като замените всичко, което вече е там:

@tailwind base;
@tailwind components;
@tailwind utilities;

Изтрийте page.module.css и заменете кода в page.tsx със следното:

export default function Home() {
  return (
    <main className="flex min-h-screen w-full flex-col items-center justify-center gap-4 px-8 text-center">
      <div>
        <h1 className="my-4 text-6xl font-bold">My GPT App</h1>
      </div>
    </main>
  );
}

На този етап настройката е завършена. Продължете и го тествайте, като стартирате npm run dev и проверите http://localhost:3000/ в браузъра.

Създайте интерфейса

Сега, след като завършихме настройката на нашия проект, можем да започнем да изграждаме интерфейса на нашето уеб приложение. В този раздел ще създадем прост формуляр, в който потребителите могат да въвеждат подкани, които да изпращат до OpenAI API.

Описва какво трябва да направи потребителят

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

export default function Home() {
  return (
    <main className="flex min-h-screen w-full flex-col items-center justify-center gap-4 text-center">
      <h1 className="my-4 text-6xl font-bold">My GPT App</h1>
      <div className="flex flex-col items-center gap-2 font-mono md:flex-row">
        <div className="bg-neuborder-neutral-900 flex h-8 w-8 items-center justify-center rounded-full bg-neutral-900 dark:bg-white">
          <div className="text-2xl text-neutral-50 dark:text-neutral-900">
            1
          </div>
        </div>
        <p className="font-bold">
          Ask a question.
          <span className="text-neutral-400">(Max. 200 characters)</span>
        </p>
      </div>
    </main>
  );
}

След като помолихме потребителя да зададе въпрос, трябва да предоставим поле за въвеждане. Искаме да предоставим textarea с maxLength от 200. За да можем да използваме входа като подкана, искаме да го съхраним в състояние. Нашият page.tsx обаче е сървърен компонент по подразбиране, което означава, че не можем да използваме useState.

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

Нека да продължим и да създадем нов файл, наречен ClientSection.tsx, където указваме да използваме клиента, а не сървъра, като използваме "use client" в горната част на файла. Това ни позволява да импортираме и използваме куки като useState, които ще използваме по-късно.

"use client";

import { useState } from "react";

export default function ClientSection() {
  return <div>Client Section</div>;
}

След това искаме да посочим състоянията, които ще ни трябват. За хубав UX почти винаги искаме да имаме състояние за зареждане. Ние също се нуждаем от състояние за нашия вход. И ние също се нуждаем от състояние за отговора, който ще получим от OpenAI API.

  const [loading, setLoading] = useState(false);
  const [input, setInput] = useState("");
  const [response, setResponse] = useState<String>("");

След като дефинираме нашите състояния, можем да създадем полето за въвеждане. В този момент нашето приложение и файлът ClientSection.tsx изглеждат по следния начин:

"use client";

import { useState } from "react";

export default function ClientSection() {
  const [loading, setLoading] = useState(false);
  const [input, setInput] = useState("");
  const [response, setResponse] = useState<String>("");

  return (
    <div className="w-full max-w-xl">
      <textarea
        value={input}
        onChange={(e) => setInput(e.target.value)}
        rows={4}
        maxLength={200}
        className="focus:ring-neu w-full rounded-md border border-neutral-400
         p-4 text-neutral-900 shadow-sm placeholder:text-neutral-400 focus:border-neutral-900"
        placeholder={"e.g. What is React?"}
      />
    </div>
  );
}

Създайте бутон

Сега, когато потребителят може да зададе въпрос, имаме нужда от бутон, за да го изпратим до OpenAI API като подкана. Нека добавим бутон, който извиква функцията generateResponse, която ще дефинираме по-късно. За да подобрим UX, ние добавяме бутон за състоянието, когато зареждането е вярно, и един, когато зареждането е невярно:

{!loading ? (
  <button
    className="w-full rounded-xl bg-neutral-900 px-4 py-2 font-medium text-white hover:bg-black/80"
    onClick={(e) => generateResponse(e)}
  >
    Generate Response &rarr;
  </button>
) : (
  <button
    disabled
    className="w-full rounded-xl bg-neutral-900 px-4 py-2 font-medium text-white"
  >
    <div className="animate-pulse font-bold tracking-widest">...</div>
  </button>
)}

Добавяне на отговора

Искаме да видим и отговора:

      {response && (
        <div className="mt-8 rounded-xl border bg-white p-4 shadow-md transition hover:bg-gray-100">
          {response}
        </div>
      )}

Дефиниране на generateResponse()

Последното нещо, което липсва тук, е функцията generateResponse, която всъщност прави API заявка към OpenAI. За да имаме повече контрол върху това как изглежда подканата, която ще изпратим, можем да я уточним по следния начин:

  const prompt = `Q: ${input} Generate a response with less than 200 characters.`;

Сега нека дефинираме функцията generateReponse. Това е функцията, която се извиква, когато потребителят щракне върху бутона „Генериране на отговор“. Първо извиква e.preventDefault(), за да предотврати поведението на подаване на формуляр по подразбиране.

След това задава състоянието response на празен низ и състоянието loading на true.

След това прави POST заявка до /api/generate крайната точка, като предава JSON обект със свойство prompt в тялото на заявката. Променливата prompt е дефинирана извън функцията и съдържа низ от текст, който ще се използва като вход за модела, генериращ отговора.

Ако отговорът на заявката не е ok, се извежда грешка.

Ако отговорът е успешен, данните се четат като ReadableStream и се инициира цикъл за четене на данните от потока. API TextDecoder() се използва за преобразуване на данните в низова стойност, която се добавя към предишната стойност на response с помощта на функцията setResponse().

И накрая, функцията задава състоянието loading на false. Ако състоянието response не е празно, то рендира div елемент, който показва генерирания отговор.

Краен компонент ClientSection.tsx

Това е последният ClientSection.tsx компонент, включително функцията generateResponse:

"use client";

import { useState } from "react";

export default function ClientSection() {
  const [loading, setLoading] = useState(false);
  const [input, setInput] = useState("");
  const [response, setResponse] = useState<String>("");

  const prompt = `Q: ${input} Generate a response with less than 200 characters.`;

  const generateResponse = async (e: React.MouseEvent<HTMLButtonElement>) => {
    e.preventDefault();
    setResponse("");
    setLoading(true);

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

    if (!response.ok) {
      throw new Error(response.statusText);
    }

    // This data is a ReadableStream
    const data = response.body;
    if (!data) {
      return;
    }

    const reader = data.getReader();
    const decoder = new TextDecoder();
    let done = false;

    while (!done) {
      const { value, done: doneReading } = await reader.read();
      done = doneReading;
      const chunkValue = decoder.decode(value);
      setResponse((prev) => prev + chunkValue);
    }
    setLoading(false);
  };

  return (
    <div className="w-full max-w-xl">
      <textarea
        value={input}
        onChange={(e) => setInput(e.target.value)}
        rows={4}
        maxLength={200}
        className="focus:ring-neu w-full rounded-md border border-neutral-400
         p-4 text-neutral-900 shadow-sm placeholder:text-neutral-400 focus:border-neutral-900"
        placeholder={"e.g. What is React?"}
      />
      {!loading ? (
        <button
          className="w-full rounded-xl bg-neutral-900 px-4 py-2 font-medium text-white hover:bg-black/80"
          onClick={(e) => generateResponse(e)}
        >
          Generate Response &rarr;
        </button>
      ) : (
        <button
          disabled
          className="w-full rounded-xl bg-neutral-900 px-4 py-2 font-medium text-white"
        >
          <div className="animate-pulse font-bold tracking-widest">...</div>
        </button>
      )}
      {response && (
        <div className="mt-8 rounded-xl border bg-white p-4 shadow-md transition hover:bg-gray-100">
          {response}
        </div>
      )}
    </div>
  );
}

В този момент интерфейсът е готов. Може би сте забелязали, че изпращаме POST заявка до “/api/generate”. Все още обаче не сме дефинирали тази крайна точка. Ще разгледаме по-задълбочено бекенда в следващите раздели.

Създайте бекенда

Сега, след като очертахме нашия интерфейс, можем да започнем да изграждаме бекенда на нашето уеб приложение. В този раздел ще настроим манипулатора на маршрута “/api/generate” и ще разгледаме по-задълбочено OpenAI API.

OpenAI API ключ

Първото нещо, което ще направим, е да се регистрираме за OpenAI акаунт и да получим API ключ. След като създадем акаунт или влезем, можем да генерираме таен ключ тук.

Копирайте ключа и създайте нов файл в главната директория на проекта, наречен .env.local, и добавете ключа към файла. Уверете се, че използвате свой собствен ключ тук:

# This file will not be committed to version control.

OPENAI_API_KEY=sk-dhBvJwkWioem2bSGMTcTRT3BlbkFJcF9dfBPHrPEu2dD6YtrS

Проверете в раздела Използване в таблото за управление на OpenAi, ако имате останало използване. Обикновено ще получите някои безплатно, когато се регистрирате. Ако не разполагате с достатъчно използване, ще получите Код на грешка 429.

Дефиниране на потока OpenAI

Следващата стъпка е да дефинирате OpenAI API потока, който ще се използва за изпращане на подкани и получаване на отговори. Това включва настройка на функция, която създава нов OpenAI API поток, задава подходящите заглавки и опции и връща обекта на потока. След това обектът на потока може да се използва за изпращане на заявки към OpenAI API и получаване на отговори в реално време.

Нека създадем нова папка, наречена utils в директорията src и да добавим файл към нея, наречен openAIStream.ts (src/utils/openAIStream.ts).

Първо ще инсталираме библиотека, която ще използваме като анализатор за потоци от изпратени от сървъра събития (SSE), които са тип HTTP отговор, използван за сървърни push събития. Библиотеката ви позволява да анализирате данните от SSE поток и да извличате информация като типа на събитието, данни и други метаданни.

Инсталирайте го със следната команда:

npm install eventsource-parser

Сега можем да дефинираме openAIStream.ts :

import {
  createParser,
  ParsedEvent,
  ReconnectInterval,
} from "eventsource-parser";

export type ChatGPTAgent = "user" | "system";

export interface ChatGPTMessage {
  role: ChatGPTAgent;
  content: string;
}

export interface OpenAIStreamPayload {
  model: string;
  messages: ChatGPTMessage[];
  temperature: number;
  top_p: number;
  frequency_penalty: number;
  presence_penalty: number;
  max_tokens: number;
  stream: boolean;
  n: number;
}

export async function OpenAIStream(payload: OpenAIStreamPayload) {
  const encoder = new TextEncoder();
  const decoder = new TextDecoder();

  let counter = 0;

  const res = await fetch("https://api.openai.com/v1/chat/completions", {
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.OPENAI_API_KEY ?? ""}`,
    },
    method: "POST",
    body: JSON.stringify(payload),
  });

  const stream = new ReadableStream({
    async start(controller) {
      // callback
      function onParse(event: ParsedEvent | ReconnectInterval) {
        if (event.type === "event") {
          const data = event.data;
          // https://beta.openai.com/docs/api-reference/completions/create#completions/create-stream
          if (data === "[DONE]") {
            controller.close();
            return;
          }
          try {
            const json = JSON.parse(data);
            const text = json.choices[0].delta?.content || "";
            if (counter < 2 && (text.match(/\n/) || []).length) {
              // this is a prefix character (i.e., "\n\n"), do nothing
              return;
            }
            const queue = encoder.encode(text);
            controller.enqueue(queue);
            counter++;
          } catch (e) {
            // maybe parse error
            controller.error(e);
          }
        }
      }

      // stream response (SSE) from OpenAI may be fragmented into multiple chunks
      // this ensures we properly read chunks and invoke an event for each SSE event stream
      const parser = createParser(onParse);
      // https://web.dev/streams/#asynchronous-iteration
      for await (const chunk of res.body as any) {
        parser.feed(decoder.decode(chunk));
      }
    },
  });

  return stream;
}

Експортираме два интерфейса (ChatGPTMessage и OpenAIStreamPayload) и функция (OpenAIStream).

ChatGPTMessage е интерфейс, който дефинира структурата на съобщение, изпратено между потребител и система, използвайки OpenAI API. Той има две свойства: role, което може да бъде или „потребител“, или „система“, и content, което е съдържанието на низа на съобщението.

OpenAIStreamPayload е интерфейс, който дефинира структурата на полезния товар, който трябва да бъде изпратен до OpenAI API, когато се иска поток от съобщения. Има няколко свойства, включително model, messages, temperature, top_p, frequency_penalty, presence_penalty, max_tokens, stream и n.

OpenAIStream е функция, която приема OpenAIStreamPayload обект като аргумент и връща четим поток. Функцията изпраща POST заявка към API на OpenAI с полезния товар и следи за отговор на поток от събития, изпратени от сървъра (SSE). След това анализира отговора на потока в отделни събития, филтрира събитието „ГОТОВО“ и кодира текстовото съдържание на всяко събитие като Uint8Array. И накрая, функцията връща обект ReadableStream, който може да бъде използван от извикващия.

Създаване на манипулатора на маршрута

Следващата стъпка е да дефинирате манипулатора на маршрута (еквивалент на API маршрути). Както можете да видите в директорията на приложението, api/hello/route.ts вече е създаден за нас. Ако поставите отметка на http://localhost:3000/api/hello в браузъра, можете да видите HTTP отговора, който е дефиниран в този файл.

Нека създадем нова папка в директорията api, наречена generate и нов файл, наречен route.ts (src/app/api/generate/route.ts). Това ни позволява да дефинираме крайната точка на HTTP, която извикваме, когато щракваме върху бутона Generate Response:

import { OpenAIStream, OpenAIStreamPayload } from "@/utils/openAIStream";

if (!process.env.OPENAI_API_KEY) {
  throw new Error("Missing env var from OpenAI");
}

export const config = {
  runtime: "edge",
};

export async function POST(req: Request): Promise<Response> {
  const { prompt } = (await req.json()) as {
    prompt?: string;
  };

  if (!prompt) {
    return new Response("No prompt in the request", { status: 400 });
  }

  const payload: OpenAIStreamPayload = {
    model: "gpt-3.5-turbo",
    messages: [{ role: "user", content: prompt }],
    temperature: 0.7,
    top_p: 1,
    frequency_penalty: 0,
    presence_penalty: 0,
    max_tokens: 1000,
    stream: true,
    n: 1,
  };

  const stream = await OpenAIStream(payload);
  return new Response(stream);
}

Когато изпращаме заявка до тази крайна точка чрез щракване върху бутона, функцията анализира входящия Request обект и извлича свойството prompt от неговото JSON тяло. Ако prompt не бъде намерено, функцията връща Response с код на състоянието 400 и съобщение „Няма подкана в заявката“.

Ако prompt съществува, функцията създава полезен обект с необходимите свойства, за да направи заявка към OpenAI API, като използва функцията OpenAIStream от модула @/utils/openAIStream. Обектът на полезния товар включва името на модела GPT-3, подканата на потребителя и различни конфигурационни параметри като температура, честота, наказание за присъствие и максимален брой токени за генериране.

След това функцията POST извиква функцията OpenAIStream с полезен обект, който връща поток от текст, генериран от OpenAI API. Функцията връща Response обект с потока като негово тяло.

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

Резюме

В тази статия научихме как да създадем уеб приложение, което предава поточно OpenAI API отговори в реално време с помощта на Next.js 13.2, GPT-3.5-turbo и Edge Functions. Обхванахме настройката на интерфейса, създаването на формуляр с поле за въвеждане и бутон за изпращане на подкани към OpenAI API и показване на отговора. Ние също така обхванахме настройката на бекенда, получаването на OpenAI API ключ, дефинирането на OpenAI потока и създаването на манипулатора на маршрута за обработка на API заявката.

Надявам се, че сте намерили тази статия за информативна и полезна, за да започнете с поточните отговори и OpenAI API. Приятно кодиране!

Ако искате да внедрите приложение като това, вижте връзката по-долу:



Вдъхновение

Вече има няколко сайта, създадени по този начин, като Twitter Bio, Rephraser, GenzTranslator и ChefGPT. Също така вижте публикацията в блога и видеото в YouTube от Vercel.