🚀使用 NextJS、Trigger.dev 和 GPT4 创建简历生成器🔥✨
TL;DR
在本文中,您将学习如何使用 NextJS、Trigger.dev、Resend 和 OpenAI 创建简历生成器。😲
- 添加基本详细信息,例如名字、姓氏和最后工作地点。
- 生成个人资料摘要、工作经历和工作职责等详细信息。
- 创建包含所有信息的 PDF。
- 将所有内容发送到您的电子邮件
您的后台工作平台🔌
Trigger.dev 是一个开源库,可让您使用 NextJS、Remix、Astro 等为您的应用程序创建和监控长时间运行的作业!
请帮我们点个星🥹。
这有助于我们创作更多类似的文章💖
让我们开始吧🔥
使用 NextJS 设置新项目
npx create-next-app@latest
我们将创建一个包含基本信息的简单表格,例如:
- 名
- 姓
- 电子邮件
- 您的个人资料图片
- 以及你至今为止的经历!
我们将使用 NextJS 的新应用路由器。
打开layout.tsx
并添加以下代码
import { GeistSans } from "geist/font";
import "./globals.css";
const defaultUrl = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: "http://localhost:3000";
export const metadata = {
metadataBase: new URL(defaultUrl),
title: "Resume Builder with GPT4",
description: "The fastest way to build a resume with GPT4",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={GeistSans.className}>
<body className="bg-background text-foreground">
<main className="min-h-screen flex flex-col items-center">
{children}
</main>
</body>
</html>
);
}
我们基本上设置了所有页面的布局(即使我们只有一个页面)。
我们设置了基本的页面元数据,背景和全局 CSS 元素。
接下来,我们打开page.tsx
并添加以下代码:
<div className="flex-1 w-full flex flex-col items-center">
<nav className="w-full flex justify-center border-b border-b-foreground/10 h-16">
<div className="w-full max-w-6xl flex justify-between items-center p-3 text-sm">
<span className="font-bold select-none">resumeGPT.</span>
</div>
</nav>
<div className="animate-in flex-1 flex flex-col opacity-0 max-w-6xl px-3">
<Home />
</div>
</div>
这设置了我们的 resumeGPT 的标题和主要主页组件。
构建表单的最简单方法
保存表单信息和验证字段的最简单方法是使用 react-hook-form。
我们要上传一张头像。
为此,我们不能使用基于 JSON 的请求。
我们需要将 JSON 转换为有效的表单数据。
因此让我们将它们全部安装起来!
npm install react-hook-form object-to-formdata axios --save
创建一个名为 components 的新文件夹,添加一个名为 的新文件Home.tsx
,并添加以下代码:
"use client";
import React, { useState } from "react";
import {FormProvider, useForm} from "react-hook-form";
import Companies from "@/components/Companies";
import axios from "axios";
import {serialize} from "object-to-formdata";
export type TUserDetails = {
firstName: string;
lastName: string;
photo: string;
email: string;
companies: TCompany[];
};
export type TCompany = {
companyName: string;
position: string;
workedYears: string;
technologies: string;
};
const Home = () => {
const [finished, setFinished] = useState<boolean>(false);
const methods = useForm<TUserDetails>()
const {
register,
handleSubmit,
formState: { errors },
} = methods;
const handleFormSubmit = async (values: TUserDetails) => {
axios.post('/api/create', serialize(values));
setFinished(true);
};
if (finished) {
return (
<div className="mt-10">Sent to the queue! Check your email</div>
)
}
return (
<div className="flex flex-col items-center justify-center p-7">
<div className="w-full py-3 bg-slate-500 items-center justify-center flex flex-col rounded-t-lg text-white">
<h1 className="font-bold text-white text-3xl">Resume Builder</h1>
<p className="text-gray-300">
Generate a resume with GPT in seconds 🚀
</p>
</div>
<FormProvider {...methods}>
<form
onSubmit={handleSubmit(handleFormSubmit)}
className="p-4 w-full flex flex-col"
>
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex flex-col w-full">
<label htmlFor="firstName">First name</label>
<input
type="text"
required
id="firstName"
placeholder="e.g. John"
className="p-3 rounded-md outline-none border border-gray-500 text-white bg-transparent"
{...register('firstName')}
/>
</div>
<div className="flex flex-col w-full">
<label htmlFor="lastName">Last name</label>
<input
type="text"
required
id="lastName"
placeholder="e.g. Doe"
className="p-3 rounded-md outline-none border border-gray-500 text-white bg-transparent"
{...register('lastName')}
/>
</div>
</div>
<hr className="w-full h-1 mt-3" />
<label htmlFor="email">Email Address</label>
<input
type="email"
required
id="email"
placeholder="e.g. john.doe@gmail.com"
className="p-3 rounded-md outline-none border border-gray-500 text-white bg-transparent"
{...register('email', {required: true, pattern: /^\S+@\S+$/i})}
/>
<hr className="w-full h-1 mt-3" />
<label htmlFor="photo">Upload your image 😎</label>
<input
type="file"
id="photo"
accept="image/x-png"
className="p-3 rounded-md outline-none border border-gray-500 mb-3"
{...register('photo', {required: true})}
/>
<Companies />
<button className="p-4 pointer outline-none bg-blue-500 border-none text-white text-base font-semibold rounded-lg">
CREATE RESUME
</button>
</form>
</FormProvider>
</div>
);
};
export default Home;
您可以看到,我们首先"use client"
告诉我们的组件它应该仅在客户端上运行。
为什么我们只需要客户端?
React 状态(输入更改)仅在客户端可用。
我们设置了两个接口,TUserDetails
和TCompany
。它们代表我们正在处理的数据的结构。
我们使用useForm
with react-hook-form
。它为我们的输入创建本地状态管理,并允许我们轻松更新和验证我们的字段。您可以看到,在每个 中input
,都有一个简单的register
函数,指定输入名称和验证并将其注册到托管状态。
这很酷,因为我们不需要玩类似onChange
您还可以看到我们使用,这很重要,因为我们希望在子组件中FormProvider
拥有的上下文。react-hook-form
我们还有一个名为 的方法handleFormSubmit
。该方法会在我们提交表单后被调用。您可以看到,我们使用该serialize
函数将 JavaScript 对象转换为 FormData,并向服务器发送请求以启动作业axios
。
你还可以看到另一个名为 的组件Companies
。该组件可以让我们指定我们工作过的所有公司。
那么让我们开始吧。
创建一个名为 的新文件Companies.tsx
,并添加以下代码:
import React, {useCallback, useEffect} from "react";
import { TCompany } from "./Home";
import {useFieldArray, useFormContext} from "react-hook-form";
const Companies = () => {
const {control, register} = We();
const {fields: companies, append} = useFieldArray({
control,
name: "companies",
});
const addCompany = useCallback(() => {
append({
companyName: '',
position: '',
workedYears: '',
technologies: ''
})
}, [companies]);
useEffect(() => {
addCompany();
}, []);
return (
<div className="mb-4">
{companies.length > 1 ? (
<h3 className="font-bold text-white text-3xl my-3">
Your list of Companies:
</h3>
) : null}
{companies.length > 1 &&
companies.slice(1).map((company, index) => (
<div
key={index}
className="mb-4 p-4 border bg-gray-800 rounded-lg shadow-md"
>
<div className="mb-2">
<label htmlFor={`companyName-${index}`} className="text-white">
Company Name
</label>
<input
type="text"
id={`companyName-${index}`}
className="p-2 border border-gray-300 rounded-md w-full bg-transparent"
{...register(`companies.${index}.companyName`, {required: true})}
/>
</div>
<div className="mb-2">
<label htmlFor={`position-${index}`} className="text-white">
Position
</label>
<input
type="text"
id={`position-${index}`}
className="p-2 border border-gray-300 rounded-md w-full bg-transparent"
{...register(`companies.${index}.position`, {required: true})}
/>
</div>
<div className="mb-2">
<label htmlFor={`workedYears-${index}`} className="text-white">
Worked Years
</label>
<input
type="number"
id={`workedYears-${index}`}
className="p-2 border border-gray-300 rounded-md w-full bg-transparent"
{...register(`companies.${index}.workedYears`, {required: true})}
/>
</div>
<div className="mb-2">
<label htmlFor={`workedYears-${index}`} className="text-white">
Technologies
</label>
<input
type="text"
id={`technologies-${index}`}
className="p-2 border border-gray-300 rounded-md w-full bg-transparent"
{...register(`companies.${index}.technologies`, {required: true})}
/>
</div>
</div>
))}
<button type="button" onClick={addCompany} className="mb-4 p-2 pointer outline-none bg-blue-900 w-full border-none text-white text-base font-semibold rounded-lg">
Add Company
</button>
</div>
);
};
export default Companies;
我们从开始useFormContext
,它允许我们获取父组件的上下文。
接下来,我们useFieldArray
创建一个名为“companies”的新状态。它是一个包含我们所有公司的数组。
在中useEffect
,我们添加数组的第一个项来对其进行迭代。
当单击时addCompany
,它会将另一个元素推送到数组中。
我们和客户的事情已经结束了🥳
解析 HTTP 请求
还记得我们发送了一个POST
请求吗/api/create
?
让我们进入 app/api 文件夹,并create
在该文件夹内创建一个名为 的新文件夹,创建一个名为 的新文件route.tsx
,并粘贴以下代码:
import {NextRequest, NextResponse} from "next/server";
import {client} from "@/trigger";
export async function POST(req: NextRequest) {
const data = await req.formData();
const allArr = {
name: data.getAll('companies[][companyName]'),
position: data.getAll('companies[][position]'),
workedYears: data.getAll('companies[][workedYears]'),
technologies: data.getAll('companies[][technologies]'),
};
const payload = {
firstName: data.get('firstName'),
lastName: data.get('lastName'),
photo: Buffer.from((await (data.get('photo[0]') as File).arrayBuffer())).toString('base64'),
email: data.get('email'),
companies: allArr.name.map((name, index) => ({
companyName: allArr.name[index],
position: allArr.position[index],
workedYears: allArr.workedYears[index],
technologies: allArr.technologies[index],
})).filter((company) => company.companyName && company.position && company.workedYears && company.technologies)
}
await client.sendEvent({
name: 'create.resume',
payload
});
return NextResponse.json({ })
}
此代码仅适用于 NodeJS 20 及以上版本。如果您的版本较低,则无法解析 FormData。
该代码非常简单。
- 我们使用以下方式将请求解析为 FormData
req.formData
- 我们将基于 FormData 的请求转换为 JSON 文件。
- 我们提取图像并将其转换为
base64
- 我们将所有内容发送给 TriggerDev
制作简历并发送到你的邮箱📨
建立简历是一项长期任务,我们需要
- 使用 ChatGPT 生成内容。
- 创建 PDF
- 发送至您的邮箱
由于一些原因,我们不想通过长时间运行的 HTTP 请求来完成所有这些操作。
- 部署到 Vercel 时,无服务器函数有 10 秒的限制。我们永远无法按时完成。
- 我们希望避免用户长时间停留。这会带来糟糕的用户体验。如果用户关闭窗口,整个流程就会失败。
隆重推出 Trigger.dev!
使用 Trigger.dev,您可以在 NextJS 应用内运行后台进程!无需创建新的服务器。
它们还知道如何将长时间运行的作业无缝拆分成多个短任务来处理。
注册一个Trigger.dev 账户。注册后,创建一个组织并为你的工作选择一个项目名称。
选择 Next.js 作为您的框架,并按照将 Trigger.dev 添加到现有 Next.js 项目的过程进行操作。
否则,请单击 Environments & API Keys
项目仪表板的侧边栏菜单。
复制您的 DEV 服务器 API 密钥,并运行以下代码片段来安装 Trigger.dev。请仔细按照说明操作。
npx @trigger.dev/cli@latest init
在另一个终端中,运行以下代码片段以在 Trigger.dev 和 Next.js 项目之间建立隧道。
npx @trigger.dev/cli@latest dev
让我们创建 TriggerDev 作业!
前往新创建的文件夹 jobs,并创建一个名为 的新文件create.resume.ts
。
添加以下代码:
client.defineJob({
id: "create-resume",
name: "Create Resume",
version: "0.0.1",
trigger: eventTrigger({
name: "create.resume",
schema: z.object({
firstName: z.string(),
lastName: z.string(),
photo: z.string(),
email: z.string().email(),
companies: z.array(z.object({
companyName: z.string(),
position: z.string(),
workedYears: z.string(),
technologies: z.string()
}))
}),
}),
run: async (payload, io, ctx) => {
}
});
这将为我们创建一个名为 的新作业create-resume
。
如您所见,我们之前从 发送的请求有一个模式验证route.tsx
。这将为我们提供验证 和auto-completion
。
我们将在这里运行三个作业
- ChatGPT
- PDF创建
- 电子邮件发送
让我们从 ChatGPT 开始。
创建一个 OpenAI 帐户 并生成一个 API 密钥。
单击View API key
下拉菜单即可创建 API 密钥。
接下来,通过运行下面的代码片段来安装 OpenAI 包。
npm install @trigger.dev/openai
将您的 OpenAI API 密钥添加到.env.local
文件中。
OPENAI_API_KEY=<your_api_key>
在根目录中创建一个名为 的新文件夹utils
。
在该目录中,创建一个名为 的新文件,openai.ts
添加以下代码:
import { OpenAI } from "openai";
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY!,
});
export async function generateResumeText(prompt: string) {
const response = await openai.completions.create({
model: "text-davinci-003",
prompt,
max_tokens: 250,
temperature: 0.7,
top_p: 1,
frequency_penalty: 1,
presence_penalty: 1,
});
return response.choices[0].text.trim();
}
export const prompts = {
profileSummary: (fullName: string, currentPosition: string, workingExperience: string, knownTechnologies: string) => `I am writing a resume, my details are \n name: ${fullName} \n role: ${currentPosition} (${workingExperience} years). \n I write in the technologies: ${knownTechnologies}. Can you write a 100 words description for the top of the resume(first person writing)?`,
jobResponsibilities: (fullName: string, currentPosition: string, workingExperience: string, knownTechnologies: string) => `I am writing a resume, my details are \n name: ${fullName} \n role: ${currentPosition} (${workingExperience} years). \n I write in the technolegies: ${knownTechnologies}. Can you write 3 points for a resume on what I am good at?`,
workHistory: (fullName: string, currentPosition: string, workingExperience: string, details: TCompany[]) => `I am writing a resume, my details are \n name: ${fullName} \n role: ${currentPosition} (${workingExperience} years). ${companyDetails(details)} \n Can you write me 50 words for each company seperated in numbers of my succession in the company (in first person)?`,
};
这段代码基本上创建了使用 ChatGPT 的基础架构,以及 3 个函数profileSummary
, 、workingExperience
和workHistory
。我们将使用它们来创建各个部分的内容。
返回我们的create.resume.ts
并添加新工作:
import { client } from "@/trigger";
import { eventTrigger } from "@trigger.dev/sdk";
import { z } from "zod";
import { prompts } from "@/utils/openai";
import { TCompany, TUserDetails } from "@/components/Home";
const companyDetails = (companies: TCompany[]) => {
let stringText = "";
for (let i = 1; i < companies.length; i++) {
stringText += ` ${companies[i].companyName} as a ${companies[i].position} on technologies ${companies[i].technologies} for ${companies[i].workedYears} years.`;
}
return stringText;
};
client.defineJob({
id: "create-resume",
name: "Create Resume",
version: "0.0.1",
integrations: {
resend
},
trigger: eventTrigger({
name: "create.resume",
schema: z.object({
firstName: z.string(),
lastName: z.string(),
photo: z.string(),
email: z.string().email(),
companies: z.array(z.object({
companyName: z.string(),
position: z.string(),
workedYears: z.string(),
technologies: z.string()
}))
}),
}),
run: async (payload, io, ctx) => {
const texts = await io.runTask("openai-task", async () => {
return Promise.all([
await generateResumeText(prompts.profileSummary(payload.firstName, payload.companies[0].position, payload.companies[0].workedYears, payload.companies[0].technologies)),
await generateResumeText(prompts.jobResponsibilities(payload.firstName, payload.companies[0].position, payload.companies[0].workedYears, payload.companies[0].technologies)),
await generateResumeText(prompts.workHistory(payload.firstName, payload.companies[0].position, payload.companies[0].workedYears, payload.companies))
]);
});
},
});
我们创建了一个名为 的新任务openai-task
。
在该任务中,我们同时使用 ChatGPT 运行三个提示,并返回它们。
创建 PDF
创建 PDF 的方法有很多种
- 您可以使用 HTML2CANVAS 之类的工具将 HTML 代码转换为图像,然后转换为 PDF。
- 您可以使用诸如
puppeteer
抓取网页并将其转换为 PDF 之类的工具。 - 您可以使用可以在后端创建 PDF 的不同库。
在我们的例子中,我们将使用一个名为jsPdf
“非常简单的库”的库来在后端创建 PDF。我鼓励您使用 Puppeteer 和更多 HTML 创建一些更强大的 PDF 文件。
那么让我们安装它
npm install jspdf @typs/jspdf --save
让我们返回utils
并创建一个名为 的新文件resume.ts
。该文件基本上会创建一个 PDF 文件,我们可以将其发送到用户的电子邮箱。
添加以下内容:
import {TUserDetails} from "@/components/Home";
import {jsPDF} from "jspdf";
type ResumeProps = {
userDetails: TUserDetails;
picture: string;
profileSummary: string;
workHistory: string;
jobResponsibilities: string;
};
export function createResume({ userDetails, picture, workHistory, jobResponsibilities, profileSummary }: ResumeProps) {
const doc = new jsPDF();
// Title block
doc.setFontSize(24);
doc.setFont('helvetica', 'bold');
doc.text(userDetails.firstName + ' ' + userDetails.lastName, 45, 27);
doc.setLineWidth(0.5);
doc.rect(20, 15, 170, 20); // x, y, width, height
doc.addImage({
imageData: picture,
x: 25,
y: 17,
width: 15,
height: 15
});
// Reset font for the rest
doc.setFontSize(12);
doc.setFont('helvetica', 'normal');
// Personal Information block
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text('Summary', 20, 50);
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
const splitText = doc.splitTextToSize(profileSummary, 170);
doc.text(splitText, 20, 60);
const newY = splitText.length * 5;
// Work history block
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text('Work History', 20, newY + 65);
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
const splitWork = doc.splitTextToSize(workHistory, 170);
doc.text(splitWork, 20, newY + 75);
const newNewY = splitWork.length * 5;
// Job Responsibilities block
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text('Job Responsibilities', 20, newY + newNewY + 75);
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
const splitJob = doc.splitTextToSize(jobResponsibilities, 170);
doc.text(splitJob, 20, newY + newNewY + 85);
return doc.output("datauristring");
}
该文件包含三个部分:Personal Information
、、Work history
和Job Responsibilities
块。
我们计算每个区块的位置和内容。
一切都已设置好absolute
。
值得注意的是,splitTextToSize
文本被分成多行,这样就不会超出屏幕。
现在,让我们创建下一个任务:resume.ts
再次打开并添加以下代码:
import { client } from "@/trigger";
import { eventTrigger } from "@trigger.dev/sdk";
import { z } from "zod";
import { prompts } from "@/utils/openai";
import { TCompany, TUserDetails } from "@/components/Home";
import { createResume } from "@/utils/resume";
const companyDetails = (companies: TCompany[]) => {
let stringText = "";
for (let i = 1; i < companies.length; i++) {
stringText += ` ${companies[i].companyName} as a ${companies[i].position} on technologies ${companies[i].technologies} for ${companies[i].workedYears} years.`;
}
return stringText;
};
client.defineJob({
id: "create-resume",
name: "Create Resume",
version: "0.0.1",
integrations: {
resend
},
trigger: eventTrigger({
name: "create.resume",
schema: z.object({
firstName: z.string(),
lastName: z.string(),
photo: z.string(),
email: z.string().email(),
companies: z.array(z.object({
companyName: z.string(),
position: z.string(),
workedYears: z.string(),
technologies: z.string()
}))
}),
}),
run: async (payload, io, ctx) => {
const texts = await io.runTask("openai-task", async () => {
return Promise.all([
await generateResumeText(prompts.profileSummary(payload.firstName, payload.companies[0].position, payload.companies[0].workedYears, payload.companies[0].technologies)),
await generateResumeText(prompts.jobResponsibilities(payload.firstName, payload.companies[0].position, payload.companies[0].workedYears, payload.companies[0].technologies)),
await generateResumeText(prompts.workHistory(payload.firstName, payload.companies[0].position, payload.companies[0].workedYears, payload.companies))
]);
});
console.log('passed chatgpt');
const pdf = await io.runTask('convert-to-html', async () => {
const resume = createResume({
userDetails: payload,
picture: payload.photo,
profileSummary: texts[0],
jobResponsibilities: texts[1],
workHistory: texts[2],
});
return {final: resume.split(',')[1]}
});
console.log('converted to pdf');
},
});
您可以看到我们添加了一个名为 的新任务convert-to-html
。这将为我们创建 PDF,将其转换为 base64 并返回。
让他们知道🎤
我们快要结束了!
剩下的就是把它分享给用户了。
你可以使用任何你喜欢的电子邮件服务。
我们将使用 Resend.com
访问 注册页面,创建一个帐户和一个 API 密钥,并将其保存到.env.local
文件中。
RESEND_API_KEY=<place_your_API_key>
将Trigger.dev Resend 集成包安装到您的 Next.js 项目中。
npm install @trigger.dev/resend
剩下要做的就是添加我们最后一项任务!
幸运的是,Trigger 直接与 Resend 集成,因此我们不需要创建新的“常规”任务。
最终代码如下:
import { client } from "@/trigger";
import { eventTrigger } from "@trigger.dev/sdk";
import { z } from "zod";
import { prompt } from "@/utils/openai";
import { TCompany, TUserDetails } from "@/components/Home";
import { createResume } from "@/utils/resume";
import { Resend } from "@trigger.dev/resend";
const resend = new Resend({
id: "resend",
apiKey: process.env.RESEND_API_KEY!,
});
const companyDetails = (companies: TCompany[]) => {
let stringText = "";
for (let i = 1; i < companies.length; i++) {
stringText += ` ${companies[i].companyName} as a ${companies[i].position} on technologies ${companies[i].technologies} for ${companies[i].workedYears} years.`;
}
return stringText;
};
client.defineJob({
id: "create-resume",
name: "Create Resume",
version: "0.0.1",
integrations: {
resend
},
trigger: eventTrigger({
name: "create.resume",
schema: z.object({
firstName: z.string(),
lastName: z.string(),
photo: z.string(),
email: z.string().email(),
companies: z.array(z.object({
companyName: z.string(),
position: z.string(),
workedYears: z.string(),
technologies: z.string()
}))
}),
}),
run: async (payload, io, ctx) => {
const texts = await io.runTask("openai-task", async () => {
return Promise.all([
await generateResumeText(prompts.profileSummary(payload.firstName, payload.companies[0].position, payload.companies[0].workedYears, payload.companies[0].technologies)),
await generateResumeText(prompts.jobResponsibilities(payload.firstName, payload.companies[0].position, payload.companies[0].workedYears, payload.companies[0].technologies)),
await generateResumeText(prompts.workHistory(payload.firstName, payload.companies[0].position, payload.companies[0].workedYears, payload.companies))
]);
});
console.log('passed chatgpt');
const pdf = await io.runTask('convert-to-html', async () => {
const resume = createResume({
userDetails: payload,
picture: payload.photo,
profileSummary: texts[0],
jobResponsibilities: texts[1],
workHistory: texts[2],
});
return {final: resume.split(',')[1]}
});
console.log('converted to pdf');
await io.resend.sendEmail('send-email', {
to: payload.email,
subject: 'Resume',
html: 'Your resume is attached!',
attachments: [
{
filename: 'resume.pdf',
content: Buffer.from(pdf.final, 'base64'),
contentType: 'application/pdf',
}
],
from: "Nevo David <nevo@gitup.dev>",
});
console.log('Sent email');
},
});
我们Resend
在文件顶部有一个实例,并从仪表板加载了我们的 API 密钥。
我们有
integrations: {
resend
},
我们将其添加到我们的作业中,以便稍后在 中使用io
。
最后,我们的作业是发送 PDFio.resend.sendEmail
值得注意的是里面的附件是我们在上一步生成的 PDF 文件。
大功告成🎉
您可以在此处检查并运行完整的源代码:
https://github.com/triggerdotdev/blog
让我们联系吧!🔌
作为开源开发者,我们诚邀您加入我们的 社区 ,贡献力量并与维护人员互动。欢迎访问我们的 GitHub 代码库 ,贡献代码并创建与 Trigger.dev 相关的问题。
本教程的源代码可以在这里找到:
https://github.com/triggerdotdev/blog/tree/main/blog-resume-builder
感谢您的阅读!
文章来源:https://dev.to/triggerdotdev/creating-a-resume-builder-with-nextjs-triggerdev-and-gpt4-4gmf