构建你自己的 AI Meme 生成器并学习如何使用 OpenAI 的函数调用☎️
TL;DR
简介
第 1 部分
待续…
目录
TL;DR
在这个由两部分组成的教程中,我们将使用以下内容构建一个全栈即时 Meme Generator 应用程序:
- React & NodeJS 与 TypeScript
- OpenAI 的函数调用 API
- ImgFlip.com 的meme 创建器 API
您可以在这里查看我们将要构建的应用程序的部署版本:Memerator
如果您只想查看完成的应用程序的代码,请查看Memerator 的 GitHub Repo
开始之前
我们正在Wasp上努力帮助您尽可能轻松地构建 Web 应用程序 - 包括制作这些每周发布的教程!
如果您能通过在 GitHub 上关注我们的 repo 来帮助我们,我们将非常感激:https://www.github.com/wasp-lang/wasp 🙏
简介
打电话给我,可能的话
借助OpenAI 的聊天完成 API,开发者现在可以实现一些非常酷炫的功能。它基本上实现了 ChatGPT 功能,但以可调用 API 的形式,可以集成到任何应用中。
但是在使用 API 时,许多开发人员希望 GPT 以 JSON 等格式返回数据,以便他们可以在其应用程序的功能中使用。
不幸的是,如果你要求 ChatGPT 以特定格式返回数据,它并不总是正确的。因此,OpenAI 发布了函数调用。
正如他们所描述的,函数调用允许开发人员“......向 GPT 描述函数,并让模型智能地选择输出包含参数的 JSON 对象来调用这些函数。”
这是将自然语言转换为 API 调用的好方法。
那么,还有什么比使用它来调用Imgflip.com 的 meme 创建器 API更好的方法来学习如何使用 GPT 的函数调用功能呢?
让我们一起构建
在这个由两部分组成的教程中,我们将使用以下内容构建一个全栈 React/NodeJS 应用程序:
- 验证
- 通过 OpenAI 的函数调用和 ImgFlip.com 的 API 生成表情包
- 每日 cron 任务来获取新的 meme 模板
- 模因编辑和删除
- 以及更多!
我已经部署了此应用程序的工作版本,您可以在此处试用:https://damemerator.netlify.app — 尝试一下,让我们开始吧。
在本教程的第 1 部分中,我们将设置应用程序并生成和显示模因。
在第 2 部分中,我们将添加更多功能,例如重复的 cron 作业以获取更多 meme 模板,以及编辑和删除 meme 的功能。
顺便说一下,两个快速提示:
- 如果您需要随时参考该应用程序的完成代码来帮助您完成本教程,您可以在此处查看该应用程序的 GitHub Repo。
- 如果您有任何疑问,请随时进入Wasp Discord 服务器并询问我们!
第 1 部分
配置
我们要把它做成一个全栈的 React/NodeJS Web 应用,所以需要先完成相关设置。不过不用担心,这根本不会花很长时间,因为我们会使用Wasp作为框架。
Wasp 帮我们完成了所有繁重的工作。你马上就会明白我的意思。
设置 Wasp 项目
首先,通过在终端中运行以下命令来安装 Wasp:
curl -sSL <https://get.wasp-lang.dev/installer.sh> | sh
接下来,让我们克隆我为您准备的Memeratorstart
应用程序分支:
git clone -b start https://github.com/vincanger/memerator.git
然后导航到Memerator
目录并在 VS Code 中打开项目:
cd Memerator && code .
您会注意到 Wasp 使用如下文件结构设置您的全栈应用程序:
.
├── main.wasp # The wasp config file.
└── src
├── client # Your React client code (JS/CSS/HTML) goes here.
├── server # Your server code (Node JS) goes here.
└── shared # Your shared (runtime independent) code goes here.
我们先来看看这个main.wasp
文件。你可以把它看作是应用的“骨架”,或者说是指令。这个文件为你配置了大部分全栈应用的功能:
app Memerator {
wasp: {
version: "^0.11.3"
},
title: "Memerator",
client: {
rootComponent: import { Layout } from "@client/Layout",
},
db: {
system: PostgreSQL,
prisma: {
clientPreviewFeatures: ["extendedWhereUnique"]
}
},
auth: {
userEntity: User,
methods: {
usernameAndPassword: {}
},
onAuthFailedRedirectTo: "/login",
onAuthSucceededRedirectTo: "/"
},
dependencies: [
("openai", "4.2.0"),
("axios", "^1.4.0"),
("react-icons", "4.10.1"),
]
}
entity User {=psl
id Int @id @default(autoincrement())
username String @unique
password String
memes Meme[]
isAdmin Boolean @default(false)
credits Int @default(2)
psl=}
entity Meme {=psl
id String @id @default(uuid())
url String
text0 String
text1 String
topics String
audience String
template Template @relation(fields: [templateId], references: [id])
templateId String
user User @relation(fields: [userId], references: [id])
userId Int
createdAt DateTime @default(now())
psl=}
entity Template {=psl
id String @id @unique
name String
url String
width Int
height Int
boxCount Int
memes Meme[]
psl=}
route HomePageRoute { path: "/", to: HomePage }
page HomePage {
component: import { HomePage } from "@client/pages/Home",
}
route LoginRoute { path: "/login", to: LoginPage }
page LoginPage {
component: import Login from "@client/pages/auth/Login"
}
route SignupRoute { path: "/signup", to: SignupPage }
page SignupPage {
component: import Signup from "@client/pages/auth/Signup"
}
如您所见,我们的main.wasp
配置文件包含:
- 依赖项,
- 身份验证方法,
- 数据库类型,以及
- 数据库模型(“实体”)
- 客户端页面和路由
您可能还注意到了{=psl psl=}
上面实体中的这种语法。这表示这些psl
括号之间的任何内容实际上都代表着另一种语言,在本例中是Prisma Schema Language。Wasp 内部使用了 Prisma,因此如果您以前使用过 Prisma,应该很容易理解。
另外,请确保安装了Wasp VS 代码扩展,以便获得良好的语法突出显示和最佳的整体开发体验。
设置数据库
我们仍然需要设置 Postgres 数据库。
通常这会非常烦人,但有了 Wasp 就变得非常容易。
- 只需安装并运行Docker Deskop ,
- 打开一个单独的终端选项卡/窗口,
cd
进入Memerator
目录,然后运行
wasp start db
这将启动你的应用并将其连接到 Postgres 数据库。无需执行任何其他操作!🤯
只需让此终端选项卡与 docker 桌面一起在后台打开并运行即可。
现在,在另一个终端选项卡中运行
wasp db migrate-dev
环境变量
在项目的根目录中,您将找到一个.env.server.example
如下所示的文件:
# set up your own credentials on https://imgflip.com/signup and rename this file to .env.server
# NOTE: make sure you register with Username and Password (not google)
IMGFLIP_USERNAME=
IMGFLIP_PASSWORD=
# get your api key from https://platform.openai.com/
OPENAI_API_KEY=
JWT_SECRET=asecretphraseatleastthirtytwocharacterslong
将此文件重命名为.env.server
并按照其中的说明获取:
启动你的应用程序
一切设置正确后,您现在应该可以运行
wasp start
运行时wasp start
,Wasp 将安装所有必要的 npm 包,在端口 3001 上启动我们的 NodeJS 服务器,并在端口 3000 上启动我们的 React 客户端。
在浏览器中访问localhost:3000进行查看。我们的应用基础应该如下所示:
生成模因
样板代码已经设置了用于生成模因的客户端表单:
- 主题
- 目标受众
这些信息将发送到后端,以便使用函数调用来调用 OpenAI API。然后,我们将此信息发送到imglfip.com API来生成 meme。
但是imgflip API 的/caption_image端点需要 meme 模板 ID。为了获取该 ID,我们首先需要从 imgflip 的/get_memes端点获取可用的 meme 模板。
服务器端代码
src/server/
创建一个名为的新文件utils.ts
:
import axios from 'axios';
import { stringify } from 'querystring';
import HttpError from '@wasp/core/HttpError.js';
type GenerateMemeArgs = {
text0: string;
text1: string;
templateId: string;
};
export const fetchMemeTemplates = async () => {
try {
const response = await axios.get('https://api.imgflip.com/get_memes');
return response.data.data.memes;
} catch (error) {
console.error(error);
throw new HttpError(500, 'Error fetching meme templates');
}
};
export const generateMemeImage = async (args: GenerateMemeArgs) => {
console.log('args: ', args);
try {
const data = stringify({
template_id: args.templateId,
username: process.env.IMGFLIP_USERNAME,
password: process.env.IMGFLIP_PASSWORD,
text0: args.text0,
text1: args.text1,
});
// Implement the generation of meme using the Imgflip API
const res = await axios.post('https://api.imgflip.com/caption_image', data, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
const url = res.data.data.url;
console.log('generated meme url: ', url);
return url as string;
} catch (error) {
console.error(error);
throw new HttpError(500, 'Error generating meme image');
}
};
这为我们提供了一些实用函数来帮助我们获取所有可以用来生成 meme 图像的 meme 模板。
请注意,对/caption_image端点的 POST 请求需要以下数据:
- 我们的 imgflip用户名和密码
- 我们将使用的 meme 模板的ID
- meme 顶部的文本,即text0
- meme 底部的文本,即text1
text0 和 text1 参数将由我们可爱的朋友 ChatGPT 生成。但为了让 GPT 做到这一点,我们还必须设置它的 API 调用。
为此,请创建一个src/server/
名为 的新文件actions.ts
。
然后返回到您的main.wasp
配置文件并在文件底部添加以下 Wasp Action:
//...
action createMeme {
fn: import { createMeme } from "@server/actions.js",
entities: [Meme, Template, User]
}
Action是 Wasp 操作的一种,用于更改后端的某些状态。它本质上是一个在服务器上调用的 NodeJS 函数,但 Wasp 会帮你完成所有设置。
这意味着您无需担心为 Action 构建 HTTP API、管理服务器端请求处理,甚至无需处理客户端响应和缓存。您只需编写业务逻辑!
如果您已安装 Wasp VS Code 扩展,则会看到如上所示的错误。将鼠标悬停在错误上方,然后点击Quick Fix
> create function createMeme
。
如果文件存在,这将createMeme
在你的文件中构建一个函数(如下所示) 。太酷了!actions.ts
import { CreateMeme } from '@wasp/actions/types'
type CreateMemeInput = void
type CreateMemeOutput = void
export const createMeme: CreateMeme<CreateMemeInput, CreateMemeOutput> = async (args, context) => {
// Implementation goes here
}
您可以看到它也为您导入了 Action 类型。
因为我们将从前端表单发送meme 的topics
数组和预期字符串,最后我们将返回新创建的实体,这就是我们应该定义的类型。audience
Meme
请记住,Meme
实体是我们在配置文件中定义的数据库模型main.wasp
。
知道了这一点,我们可以将的内容改为actions.ts
:
import type { CreateMeme } from '@wasp/actions/types'
import type { Meme } from '@wasp/entities';
type CreateMemeArgs = { topics: string[]; audience: string };
export const createMeme: CreateMeme<CreateMemeArgs, Meme> = async ({ topics, audience }, context) => {
// Implementation goes here
}
在我们实现其余逻辑之前,让我们先了解一下我们的createMeme
函数应该如何工作以及我们的Meme
意志是如何生成的:
- 获取我们要使用的 imgflip meme 模板
- 将其名称、主题和目标受众发送到 OpenAI 的聊天完成 API
- 告诉 OpenAI 我们希望将结果作为参数返回,以便以 JSON 格式传递给下一个函数,即 OpenAI 的函数调用
- 将这些参数传递给 imgflip /caption-image端点并获取我们创建的 meme 的 url
- 将 meme url 和其他信息作为
Meme
实体保存到我们的数据库中
考虑到所有这些,继续actions.ts
用已完成的createMeme
操作完全替换我们中的内容:
import HttpError from '@wasp/core/HttpError.js';
import OpenAI from 'openai';
import { fetchMemeTemplates, generateMemeImage } from './utils.js';
import type { CreateMeme } from '@wasp/actions/types';
import type { Meme, Template } from '@wasp/entities';
type CreateMemeArgs = { topics: string[]; audience: string };
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
export const createMeme: CreateMeme<CreateMemeArgs, Meme> = async ({ topics, audience }, context) => {
if (!context.user) {
throw new HttpError(401, 'You must be logged in');
}
if (context.user.credits === 0 && !context.user.isAdmin) {
throw new HttpError(403, 'You have no credits left');
}
const topicsStr = topics.join(', ');
let templates: Template[] = await context.entities.Template.findMany({});
if (templates.length === 0) {
const memeTemplates = await fetchMemeTemplates();
templates = await Promise.all(
memeTemplates.map(async (template: any) => {
const addedTemplate = await context.entities.Template.upsert({
where: { id: template.id },
create: {
id: template.id,
name: template.name,
url: template.url,
width: template.width,
height: template.height,
boxCount: template.box_count
},
update: {}
});
return addedTemplate;
})
);
}
// filter out templates with box_count > 2
templates = templates.filter((template) => template.boxCount <= 2);
const randomTemplate = templates[Math.floor(Math.random() * templates.length)];
console.log('random template: ', randomTemplate);
const sysPrompt = `You are a meme idea generator. You will use the imgflip api to generate a meme based on an idea you suggest. Given a random template name and topics, generate a meme idea for the intended audience. Only use the template provided`;
const userPrompt = `Topics: ${topicsStr} \n Intended Audience: ${audience} \n Template: ${randomTemplate.name} \n`;
let openAIResponse: OpenAI.Chat.Completions.ChatCompletion;
try {
openAIResponse = await openai.chat.completions.create({
messages: [
{ role: 'system', content: sysPrompt },
{ role: 'user', content: userPrompt },
],
functions: [
{
name: 'generateMemeImage',
description: 'Generate meme via the imgflip API based on the given idea',
parameters: {
type: 'object',
properties: {
text0: { type: 'string', description: 'The text for the top caption of the meme' },
text1: { type: 'string', description: 'The text for the bottom caption of the meme' },
},
required: ['templateName', 'text0', 'text1'],
},
},
],
function_call: {
name: 'generateMemeImage',
},
model: 'gpt-4-0613',
});
} catch (error: any) {
console.error('Error calling openAI: ', error);
throw new HttpError(500, 'Error calling openAI');
}
console.log(openAIResponse.choices[0]);
/**
* the Function call returned by openAI looks like this:
*/
// {
// index: 0,
// message: {
// role: 'assistant',
// content: null,
// function_call: {
// name: 'generateMeme',
// arguments: '{\n' +
// ` "text0": "CSS you've been writing all day",\n` +
// ' "text1": "This looks horrible"\n' +
// '}'
// }
// },
// finish_reason: 'stop'
// }
if (!openAIResponse.choices[0].message.function_call) throw new HttpError(500, 'No function call in openAI response');
const gptArgs = JSON.parse(openAIResponse.choices[0].message.function_call.arguments);
console.log('gptArgs: ', gptArgs);
const memeIdeaText0 = gptArgs.text0;
const memeIdeaText1 = gptArgs.text1;
console.log('meme Idea args: ', memeIdeaText0, memeIdeaText1);
const memeUrl = await generateMemeImage({
templateId: randomTemplate.id,
text0: memeIdeaText0,
text1: memeIdeaText1,
});
const newMeme = await context.entities.Meme.create({
data: {
text0: memeIdeaText0,
text1: memeIdeaText1,
topics: topicsStr,
audience: audience,
url: memeUrl,
template: { connect: { id: randomTemplate.id } },
user: { connect: { id: context.user.id } },
},
});
return newMeme;
};
此时,上面的代码应该是不言自明的,但我想强调几点:
- 该
context
对象会被 Wasp 传递给所有操作和查询。它包含Prisma客户端,可以访问你在main.wasp
配置文件中定义的数据库实体。 - 我们首先在数据库中查找 imgflip meme 模板。如果没有找到,则使用
fetchTemplates
之前定义的实用函数获取它们。然后将upsert
它们存入数据库。 - 有些 meme 模板需要超过 2 个文本框,但在本教程中,我们仅使用带有 2 个文本输入的 meme 模板以使其更容易。
- 我们从此列表中选择一个随机模板作为我们模因的基础(这实际上是一种偶然生成一些有趣的模因内容的好方法)。
functions
我们通过和属性向 OpenAI API 提供有关其可以创建参数的函数的信息function_call
,这告诉它始终为我们的函数返回 JSON 参数,generateMemeImage
太棒了!但是一旦我们开始生成表情包,我们就需要一种方法来在前端显示它们。
现在让我们创建一个 Wasp 查询。查询的工作原理与操作类似,只是它仅用于读取数据。
转到src/server
并创建一个名为 的新文件queries.ts
。
接下来,在您的main.wasp
文件中添加以下代码:
//...
query getAllMemes {
fn: import { getAllMemes } from "@server/queries.js",
entities: [Meme]
}
然后在您的queries.ts
文件中添加以下getAllMemes
函数:
import HttpError from '@wasp/core/HttpError.js';
import type { Meme } from '@wasp/entities';
import type { GetAllMemes } from '@wasp/queries/types';
export const getAllMemes: GetAllMemes<void, Meme[]> = async (_args, context) => {
const memeIdeas = await context.entities.Meme.findMany({
orderBy: { createdAt: 'desc' },
include: { template: true },
});
return memeIdeas;
};
客户端代码
现在我们已经有了服务器端实现的createMeme
代码getAllMemes
,让我们将其连接到我们的客户端。
Wasp 使得我们能够非常轻松地导入刚刚创建的操作并在前端调用它们。
您可以通过转到src/client/pages/Home.tsx
文件顶部并添加以下代码来实现:
//...other imports...
import { useQuery } from '@wasp/queries';
import createMeme from '@wasp/actions/createMeme';
import getAllMemes from '@wasp/queries/getAllMemes';
import useAuth from '@wasp/auth/useAuth';
export function HomePage() {
const [topics, setTopics] = useState(['']);
const [audience, setAudience] = useState('');
const [isMemeGenerating, setIsMemeGenerating] = useState(false);
// 😎 😎 😎
const { data: user } = useAuth();
const { data: memes, isLoading, error } = useQuery(getAllMemes);
const handleGenerateMeme: FormEventHandler<HTMLFormElement> = async (e) => {
e.preventDefault();
if (!user) {
history.push('/login');
return;
}
if (topics.join('').trim().length === 0 || audience.length === 0) {
alert('Please provide topic and audience');
return;
}
try {
setIsMemeGenerating(true);
await createMeme({ topics, audience }); // <--- 😎 😎 😎
} catch (error: any) {
alert('Error generating meme: ' + error.message);
} finally {
setIsMemeGenerating(false);
}
};
//...
如您所见,我们已经导入createMeme
和getAllMemes
(😎)。
对于getAllMemes
,我们将其包装在useQuery
钩子中,以便我们可以获取并缓存数据。另一方面,我们的createMeme
Action 会被调用,handleGenerateMeme
我们将在提交表单时调用它。
无需逐个添加代码Home.tsx
,以下是生成和显示表情包的所有代码。请Home.tsx
用以下代码替换所有代码,我将在下面更详细地解释:
import { useState, FormEventHandler } from 'react';
import { useQuery } from '@wasp/queries';
import createMeme from '@wasp/actions/createMeme';
import getAllMemes from '@wasp/queries/getAllMemes';
import useAuth from '@wasp/auth/useAuth';
import { useHistory } from 'react-router-dom';
import {
AiOutlinePlusCircle,
AiOutlineMinusCircle,
AiOutlineRobot,
} from 'react-icons/ai';
export function HomePage() {
const [topics, setTopics] = useState(['']);
const [audience, setAudience] = useState('');
const [isMemeGenerating, setIsMemeGenerating] = useState(false);
const history = useHistory();
const { data: user } = useAuth();
const { data: memes, isLoading, error } = useQuery(getAllMemes);
const handleGenerateMeme: FormEventHandler<HTMLFormElement> = async (e) => {
e.preventDefault();
if (!user) {
history.push('/login');
return;
}
if (topics.join('').trim().length === 0 || audience.length === 0) {
alert('Please provide topic and audience');
return;
}
try {
setIsMemeGenerating(true);
await createMeme({ topics, audience });
} catch (error: any) {
alert('Error generating meme: ' + error.message);
} finally {
setIsMemeGenerating(false);
}
};
const handleDeleteMeme = async (id: string) => {
//...
};
if (isLoading) return 'Loading...';
if (error) return 'Error: ' + error;
return (
<div className='p-4'>
<h1 className='text-3xl font-bold mb-4'>Welcome to Memerator!</h1>
<p className='mb-4'>Start generating meme ideas by providing topics and intended audience.</p>
<form onSubmit={handleGenerateMeme}>
<div className='mb-4 max-w-[500px]'>
<label htmlFor='topics' className='block font-bold mb-2'>
Topics:
</label>
{topics.map((topic, index) => (
<input
key={index}
type='text'
id='topics'
value={topic}
onChange={(e) => {
const updatedTopics = [...topics];
updatedTopics[index] = e.target.value;
setTopics(updatedTopics);
}}
className='p-1 mr-1 mb-1 border rounded text-lg focus:outline-none focus:ring-2 focus:ring-primary-600 focus:border-transparent'
/>
))}
<div className='flex items-center my-2 gap-1'>
<button
type='button'
onClick={() => topics.length < 3 && setTopics([...topics, ''])}
className='flex items-center gap-1 bg-primary-200 hover:bg-primary-300 border-2 text-black text-xs py-1 px-2 rounded'
>
<AiOutlinePlusCircle /> Add Topic
</button>
{topics.length > 1 && (
<button
onClick={() => setTopics(topics.slice(0, -1))}
className='flex items-center gap-1 bg-red-500 hover:bg-red-700 border-2 text-white text-xs py-1 px-2 rounded'
>
<AiOutlineMinusCircle /> Remove Topic
</button>
)}
</div>
</div>
<div className='mb-4'>
<label htmlFor='audience' className='block font-bold mb-2'>
Intended Audience:
</label>
<input
type='text'
id='audience'
value={audience}
onChange={(e) => setAudience(e.target.value)}
className='p-1 border rounded text-lg focus:outline-none focus:ring-2 focus:ring-primary-600 focus:border-transparent'
/>
</div>
<button
type='submit'
className={`flex items-center gap-1 bg-primary-200 hover:bg-primary-300 border-2 text-black text-sm font-bold py-1 px-2 rounded ${
isMemeGenerating ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
} $}`}
>
<AiOutlineRobot />
{!isMemeGenerating ? 'Generate Meme' : 'Generating...'}
</button>
</form>
{!!memes && memes.length > 0 ? (
memes.map((memeIdea) => (
<div key={memeIdea.id} className='mt-4 p-4 bg-gray-100 rounded-lg'>
<img src={memeIdea.url} width='500px' />
<div className='flex flex-col items-start mt-2'>
<div>
<span className='text-sm text-gray-700'>Topics: </span>
<span className='text-sm italic text-gray-500'>{memeIdea.topics}</span>
</div>
<div>
<span className='text-sm text-gray-700'>Audience: </span>
<span className='text-sm italic text-gray-500'>{memeIdea.audience}</span>
</div>
</div>
{/* TODO: implement edit and delete meme features */}
</div>
))
) : (
<div className='flex justify-center mt-5'> :( no memes found</div>
)}
</div>
);
}
关于这段代码,我想指出两点:
- 组件挂载时,钩子
useQuery
会调用我们的getAllMemes
查询。它还会为我们缓存结果,并且每当我们通过 向数据库添加新的 Meme 时,都会自动重新获取结果createMeme
。这意味着每当生成新的 Meme 时,我们的页面都会自动重新加载。 - 这个
useAuth
钩子允许我们获取已登录用户的信息。如果用户尚未登录,我们会强制他们登录后才能生成表情包。
这些真的很酷的 Wasp 功能,让你作为开发人员的生活变得更加轻松 🙂
那么,现在就尝试生成一个表情包吧。这是我刚刚生成的:
哈哈。不错!
现在,如果我们可以编辑和删除表情包,那不是很酷吗?如果我们可以扩展表情包生成器可用的模板集,那又如何?那不是也很酷吗?
待续…
在本教程的第 2 部分中,我们将处理应用程序的其余部分,例如添加重复的 cron 作业以获取更多 meme 模板,以及编辑、删除和共享 meme 的功能。
如果您觉得这篇文章对您有帮助,请在 GitHub 上给我们一个 star来表示支持 !这将帮助我们继续制作更多类似的东西。
文章来源:https://dev.to/wasp/build-your-own-ai-meme-generator-learn-how-to-use-openais-function-calls-1p21