Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix: Synchronized Response Bio with Request #51

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ This project generates Twitter bios for you using AI.

## How it works

This project uses the [ChatGPT API](https://openai.com/api/) and [Vercel Edge functions](https://vercel.com/features/edge-functions) with streaming. It constructs a prompt based on the form and user input, sends it to the chatGPT API via a Vercel Edge function, then streams the response back to the application.
This project uses the [ChatGPT API](https://openai.com/api/) and the [Vercel AI SDK](https://sdk.vercel.ai/docs) with streaming. It constructs a prompt based on the form and user input, sends it to the ChatGPT API with a Vercel Edge Function, then streams the response back to the application UI.

If you'd like to see how I built this, check out the [video](https://youtu.be/JcE-1xzQTE0) or [blog post](https://vercel.com/blog/gpt-3-app-next-js-vercel-edge-functions).
> This template has recently been updated for the AI SDK, simplifying the amount of code needed. I previously published a [video](https://youtu.be/JcE-1xzQTE0) and [blog post](https://vercel.com/blog/gpt-3-app-next-js-vercel-edge-functions) showing the older approach.

## Running Locally

Expand All @@ -17,7 +17,7 @@ After cloning the repo, go to [OpenAI](https://beta.openai.com/account/api-keys)
Then, run the application in the command line and it will be available at `http://localhost:3000`.

```bash
npm run dev
pnpm run dev
```

## One-Click Deploy
Expand Down
39 changes: 39 additions & 0 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Configuration, OpenAIApi } from 'openai-edge';
import { OpenAIStream, StreamingTextResponse } from 'ai';

// Create an OpenAI API client (that's edge friendly!)
const config = new Configuration({
apiKey: process.env.OPENAI_API_KEY,
});
const openai = new OpenAIApi(config);

// Set the runtime to edge for best performance
export const runtime = 'edge';

export async function POST(req: Request) {
const { vibe, bio } = await req.json();

// Ask OpenAI for a streaming completion given the prompt
const response = await openai.createChatCompletion({
model: 'gpt-3.5-turbo',
stream: true,
messages: [
{
role: 'user',
content: `Generate 2 ${vibe} twitter biographies with no hashtags and clearly labeled "1." and "2.". ${
vibe === 'Funny'
? "Make sure there is a joke in there and it's a little ridiculous."
: null
}
Make sure each generated biography is less than 160 characters, has short sentences that are found in Twitter bios, and base them on this context: ${bio}${
bio.slice(-1) === '.' ? '' : '.'
}`,
},
],
});

// Convert the response into a friendly text-stream
const stream = OpenAIStream(response);
// Respond with the stream
return new StreamingTextResponse(stream);
}
File renamed without changes.
38 changes: 38 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Analytics } from '@vercel/analytics/react';
import { Metadata } from 'next';
import '../styles/globals.css';

const title = 'Twitter Bio Generator';
const description = 'Generate your next Twitter bio in seconds.';

export const metadata: Metadata = {
metadataBase: new URL('https://twitterbio.io'),
title,
description,
openGraph: {
title,
description,
locale: 'en_US',
type: 'website',
},
twitter: {
card: 'summary_large_image',
title,
description,
},
};

export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
<Analytics />
</body>
</html>
);
}
File renamed without changes
150 changes: 51 additions & 99 deletions pages/index.tsx → app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,100 +1,50 @@
import type { NextPage } from "next";
import Head from "next/head";
import Image from "next/image";
import { useRef, useState } from "react";
import { Toaster, toast } from "react-hot-toast";
import DropDown, { VibeType } from "../components/DropDown";
import Footer from "../components/Footer";
import Github from "../components/GitHub";
import Header from "../components/Header";
import LoadingDots from "../components/LoadingDots";
import {
createParser,
ParsedEvent,
ReconnectInterval,
} from "eventsource-parser";
'use client';

const Home: NextPage = () => {
const [loading, setLoading] = useState(false);
const [bio, setBio] = useState("");
const [vibe, setVibe] = useState<VibeType>("Professional");
const [generatedBios, setGeneratedBios] = useState<String>("");
import Image from 'next/image';
import { useRef, useState, ChangeEvent } from 'react';
import { Toaster, toast } from 'react-hot-toast';
import DropDown, { VibeType } from '../components/DropDown';
import Footer from '../components/Footer';
import Github from '../components/GitHub';
import Header from '../components/Header';
import { useChat } from 'ai/react';

export default function Page() {
const bio = useRef<string | null>(null);
const [vibe, setVibe] = useState<VibeType>('Professional');
const bioRef = useRef<null | HTMLDivElement>(null);

const scrollToBios = () => {
if (bioRef.current !== null) {
bioRef.current.scrollIntoView({ behavior: "smooth" });
bioRef.current.scrollIntoView({ behavior: 'smooth' });
}
};

const prompt = `Generate 2 ${vibe} twitter biographies with no hashtags and clearly labeled "1." and "2.". ${
vibe === "Funny"
? "Make sure there is a joke in there and it's a little ridiculous."
: null
}
Make sure each generated biography is less than 160 characters, has short sentences that are found in Twitter bios, and base them on this context: ${bio}${
bio.slice(-1) === "." ? "" : "."
}`;

const generateBio = async (e: any) => {
e.preventDefault();
setGeneratedBios("");
setLoading(true);
const response = await fetch("/api/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
const { input, handleInputChange, handleSubmit, isLoading, messages } =
useChat({
body: {
vibe,
bio: bio.current,
},
onResponse() {
scrollToBios();
},
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 onParse = (event: ParsedEvent | ReconnectInterval) => {
if (event.type === "event") {
const data = event.data;
try {
const text = JSON.parse(data).text ?? ""
setGeneratedBios((prev) => prev + text);
} catch (e) {
console.error(e);
}
}
}
const onSubmit = (e: any) => {
handleSubmit(e);
};

// https://web.dev/streams/#the-getreader-and-read-methods
const reader = data.getReader();
const decoder = new TextDecoder();
const parser = createParser(onParse);
let done = false;
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
const chunkValue = decoder.decode(value);
parser.feed(chunkValue);
}
scrollToBios();
setLoading(false);
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
bio.current = e.target.value;
handleInputChange(e);
};

const lastMessage = messages[messages.length - 1];
const generatedBios = lastMessage?.role === "assistant" ? lastMessage.content : null;

return (
<div className="flex max-w-5xl mx-auto flex-col items-center justify-center py-2 min-h-screen">
<Head>
<title>Twitter Bio Generator</title>
<link rel="icon" href="/favicon.ico" />
</Head>

<Header />
<main className="flex flex-1 w-full flex-col items-center justify-center text-center px-4 mt-12 sm:mt-20">
<a
Expand All @@ -110,7 +60,7 @@ const Home: NextPage = () => {
Generate your next Twitter bio using chatGPT
</h1>
<p className="text-slate-500 mt-5">47,118 bios generated so far.</p>
<div className="max-w-xl w-full">
<form className="max-w-xl w-full" onSubmit={onSubmit}>
<div className="flex mt-10 items-center space-x-3">
<Image
src="/1-black.png"
Expand All @@ -120,20 +70,20 @@ const Home: NextPage = () => {
className="mb-5 sm:mb-0"
/>
<p className="text-left font-medium">
Copy your current bio{" "}
Copy your current bio{' '}
<span className="text-slate-500">
(or write a few sentences about yourself)
</span>
.
</p>
</div>
<textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
value={input}
onChange={handleInput}
rows={4}
className="w-full rounded-md border-gray-300 shadow-sm focus:border-black focus:ring-black my-5"
placeholder={
"e.g. Senior Developer Advocate @vercel. Tweeting about web development, AI, and React / Next.js. Writing nutlope.substack.com."
'e.g. Senior Developer Advocate @vercel. Tweeting about web development, AI, and React / Next.js. Writing nutlope.substack.com.'
}
/>
<div className="flex mb-5 items-center space-x-3">
Expand All @@ -144,30 +94,34 @@ const Home: NextPage = () => {
<DropDown vibe={vibe} setVibe={(newVibe) => setVibe(newVibe)} />
</div>

{!loading && (
{!isLoading && (
<button
className="bg-black rounded-xl text-white font-medium px-4 py-2 sm:mt-10 mt-8 hover:bg-black/80 w-full"
onClick={(e) => generateBio(e)}
type="submit"
>
Generate your bio &rarr;
</button>
)}
{loading && (
{isLoading && (
<button
className="bg-black rounded-xl text-white font-medium px-4 py-2 sm:mt-10 mt-8 hover:bg-black/80 w-full"
disabled
>
<LoadingDots color="white" style="large" />
<span className="loading">
<span style={{ backgroundColor: 'white' }} />
<span style={{ backgroundColor: 'white' }} />
<span style={{ backgroundColor: 'white' }} />
</span>
</button>
)}
</div>
</form>
<Toaster
position="top-center"
reverseOrder={false}
toastOptions={{ duration: 2000 }}
/>
<hr className="h-px bg-gray-700 border-1 dark:bg-gray-700" />
<div className="space-y-10 my-10">
<output className="space-y-10 my-10">
{generatedBios && (
<>
<div>
Expand All @@ -180,16 +134,16 @@ const Home: NextPage = () => {
</div>
<div className="space-y-8 flex flex-col items-center justify-center max-w-xl mx-auto">
{generatedBios
.substring(generatedBios.indexOf("1") + 3)
.split("2.")
.substring(generatedBios.indexOf('1') + 3)
.split('2.')
.map((generatedBio) => {
return (
<div
className="bg-white rounded-xl shadow-md p-4 hover:bg-gray-100 transition cursor-copy border"
onClick={() => {
navigator.clipboard.writeText(generatedBio);
toast("Bio copied to clipboard", {
icon: "✂️",
toast('Bio copied to clipboard', {
icon: '✂️',
});
}}
key={generatedBio}
Expand All @@ -201,11 +155,9 @@ const Home: NextPage = () => {
</div>
</>
)}
</div>
</output>
</main>
<Footer />
</div>
);
};

export default Home;
}
Binary file added app/twitter-image.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 8 additions & 10 deletions components/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,31 @@
import Link from "next/link";
import Link from 'next/link';

export default function Footer() {
return (
<footer className="text-center h-16 sm:h-20 w-full sm:pt-2 pt-4 border-t mt-5 flex sm:flex-row flex-col justify-between items-center px-3 space-y-3 sm:mb-0 mb-3">
<div>
Powered by{" "}
Powered by{' '}
<a
href="https://openai.com/blog/chatgpt"
target="_blank"
rel="noreferrer"
className="font-bold hover:underline transition underline-offset-2"
>
ChatGPT{" "}
ChatGPT{' '}
</a>
and{" "}
and{' '}
<a
href="https://vercel.com/"
href="https://sdk.vercel.ai/docs"
target="_blank"
rel="noreferrer"
className="font-bold hover:underline transition underline-offset-2"
>
Vercel Edge Functions.
Vercel AI SDK
</a>
</div>
<div className="flex space-x-4 pb-4 sm:pb-0">
<Link
href="https://twitter.com/nutlope"
className="group"
aria-label="TaxPal on Twitter"
aria-label="Nutlope Twitter"
>
<svg
aria-hidden="true"
Expand All @@ -39,7 +37,7 @@ export default function Footer() {
<Link
href="https://github.com/Nutlope/twitterbio"
className="group"
aria-label="TaxPal on GitHub"
aria-label="GitHub"
>
<svg
aria-hidden="true"
Expand Down