From 442f7ea183a93d84a110ac12b8d0202abc09706c Mon Sep 17 00:00:00 2001 From: Scott Quested Date: Fri, 26 Jan 2024 14:55:19 +0000 Subject: [PATCH] Add image gen with dall-e + update styles of home and explore pages --- convex/_generated/api.d.ts | 4 ++ convex/actions.ts | 0 convex/actionsNode.ts | 72 ++++++++++++++++++++++++++---- convex/mutations.ts | 25 ++--------- convex/mutationsInternal.ts | 22 +++++++++ convex/schema.ts | 1 + convex/utils.ts | 29 ++++++++++++ next.config.mjs | 10 ++++- src/app/explore/page.tsx | 14 ++++-- src/app/header.tsx | 22 ++++----- src/app/page.tsx | 4 +- src/components/JobCard/JobCard.tsx | 64 ++++++++++++++++---------- src/lib/utils.ts | 10 +++-- 13 files changed, 201 insertions(+), 76 deletions(-) create mode 100644 convex/actions.ts create mode 100644 convex/mutationsInternal.ts create mode 100644 convex/utils.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 6b7be23..46fd416 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -16,7 +16,9 @@ import type { } from "convex/server"; import type * as actionsNode from "../actionsNode.js"; import type * as mutations from "../mutations.js"; +import type * as mutationsInternal from "../mutationsInternal.js"; import type * as queries from "../queries.js"; +import type * as utils from "../utils.js"; /** * A utility for referencing Convex functions in your app's API. @@ -29,7 +31,9 @@ import type * as queries from "../queries.js"; declare const fullApi: ApiFromModules<{ actionsNode: typeof actionsNode; mutations: typeof mutations; + mutationsInternal: typeof mutationsInternal; queries: typeof queries; + utils: typeof utils; }>; export declare const api: FilterApi< typeof fullApi, diff --git a/convex/actions.ts b/convex/actions.ts new file mode 100644 index 0000000..e69de29 diff --git a/convex/actionsNode.ts b/convex/actionsNode.ts index 81bc522..139239e 100644 --- a/convex/actionsNode.ts +++ b/convex/actionsNode.ts @@ -2,12 +2,8 @@ import { v } from "convex/values"; import { internalAction } from "./_generated/server"; -import OpenAI from "openai"; import { internal } from "./_generated/api"; - -const openai = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY, -}); +import { openAiConfig } from "./utils"; const userPromoptText = `What is considered the top job someone could get with these skills. Don't include the question or skills in the response. @@ -17,12 +13,21 @@ const systemPromoptText = `You are a helpful assistant who will return JSON data for the top job someone could get with these skills. Use the keys 'jobTitle' and 'jobDescription' to return the data.`; -export const getOpenAiAnswer = internalAction({ +/** + * Internal action to generate a job from given skills from OpenAi. + * + * @param skills The skills to use to generate the job. + * @param id The id of the job to update. + * + */ +export const getJobBySkills = internalAction({ args: { skills: v.string(), id: v.id("jobs"), }, handler: async (ctx, { id, skills }) => { + const openai = await openAiConfig(skills); + try { const completion = await openai.chat.completions.create({ messages: [ @@ -43,16 +48,67 @@ export const getOpenAiAnswer = internalAction({ completion.choices[0].message.content || "{}" ); - await ctx.runMutation(internal.mutations.updateJob, { + const storageId = await ctx.runAction( + internal.actionsNode.getOpenAiImage, + { + prompt: transformResult.jobTitle, + id, + } + ); + + await ctx.runMutation(internal.mutationsInternal.updateJob, { id, result: JSON.stringify(transformResult), status: "completed", + imageId: storageId, }); } catch (error) { - await ctx.runMutation(internal.mutations.updateJob, { + await ctx.runMutation(internal.mutationsInternal.updateJob, { id, status: "failed", }); } }, }); + +/** + * Internal action to get an image from OpenAI. + * + * @param prompt The prompt to use to generate the image. + * @returns The storage id of the image. + * + */ +export const getOpenAiImage = internalAction({ + args: { + id: v.id("jobs"), + prompt: v.string(), + }, + handler: async (ctx, { id, prompt }) => { + const openai = await openAiConfig(prompt); + + console.log("prompt: ", prompt); + + try { + // Query OpenAI for the image. + const opanaiResponse = await openai.images.generate({ + prompt: `Image of ${prompt}. Make it a fun image`, + size: "512x512", + }); + const dallEImageUrl = opanaiResponse.data[0]["url"]!; + + // Download the image + const imageResponse = await fetch(dallEImageUrl); + if (!imageResponse.ok) { + throw new Error(`failed to download: ${imageResponse.statusText}`); + } + + // Store the image to Convex storage. + const image = await imageResponse.blob(); + const storageId = await ctx.storage.store(image as Blob); + + return storageId; + } catch (error) { + throw new Error("Failed to get image"); + } + }, +}); diff --git a/convex/mutations.ts b/convex/mutations.ts index 4dc3181..2540138 100644 --- a/convex/mutations.ts +++ b/convex/mutations.ts @@ -1,6 +1,6 @@ import { v } from "convex/values"; import { internal } from "./_generated/api"; -import { internalMutation, mutation } from "./_generated/server"; +import { mutation } from "./_generated/server"; export const saveJobs = mutation({ args: { skills: v.string(), userId: v.string() }, @@ -14,9 +14,10 @@ export const saveJobs = mutation({ userId, skills, status: "pending", + imageId: "", }); - await ctx.scheduler.runAfter(0, internal.actionsNode.getOpenAiAnswer, { + await ctx.scheduler.runAfter(0, internal.actionsNode.getJobBySkills, { id: jobs, skills, }); @@ -41,27 +42,9 @@ export const retryJob = mutation({ status: "pending", }); - await ctx.scheduler.runAfter(0, internal.actionsNode.getOpenAiAnswer, { + await ctx.scheduler.runAfter(0, internal.actionsNode.getJobBySkills, { id, skills, }); }, }); - -export const updateJob = internalMutation({ - args: { - id: v.id("jobs"), - result: v.optional(v.string()), - status: v.union( - v.literal("pending"), - v.literal("completed"), - v.literal("failed") - ), - }, - handler: async (ctx, { id, result, status }) => { - await ctx.db.patch(id, { - result, - status: status, - }); - }, -}); diff --git a/convex/mutationsInternal.ts b/convex/mutationsInternal.ts new file mode 100644 index 0000000..cc678fb --- /dev/null +++ b/convex/mutationsInternal.ts @@ -0,0 +1,22 @@ +import { v } from "convex/values"; +import { internalMutation } from "./_generated/server"; + +export const updateJob = internalMutation({ + args: { + id: v.id("jobs"), + result: v.optional(v.string()), + status: v.union( + v.literal("pending"), + v.literal("completed"), + v.literal("failed") + ), + imageId: v.optional(v.string()), + }, + handler: async (ctx, { id, result, status, imageId }) => { + await ctx.db.patch(id, { + result, + status: status, + imageId, + }); + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index ab4003b..4686811 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -11,5 +11,6 @@ export default defineSchema({ v.literal("completed"), v.literal("failed") ), + imageId: v.string(), }), }); diff --git a/convex/utils.ts b/convex/utils.ts new file mode 100644 index 0000000..8a8507d --- /dev/null +++ b/convex/utils.ts @@ -0,0 +1,29 @@ +import OpenAI from "openai"; + +export const openAiConfig = async (prompt: string) => { + const apiKey = process.env.OPENAI_API_KEY; + + if (!apiKey) { + throw new Error( + "Add your OPENAI_API_KEY as an env variable in the " + + "[dashboard](https://dasboard.convex.dev)" + ); + } + + const openai = new OpenAI({ apiKey }); + + // Check if the prompt is offensive. + const modResponse = await openai.moderations.create({ + input: prompt, + }); + const modResult = modResponse.results[0]; + if (modResult.flagged) { + throw new Error( + `Your prompt were flagged as offensive: ${JSON.stringify( + modResult.categories + )}` + ); + } + + return openai; +}; diff --git a/next.config.mjs b/next.config.mjs index 4678774..0f9923b 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,4 +1,12 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + images: { + remotePatterns: [ + { + hostname: process.env.NEXT_PUBLIC_CONVEX_DOMAIN, + }, + ], + }, +}; export default nextConfig; diff --git a/src/app/explore/page.tsx b/src/app/explore/page.tsx index fcffd24..2e021b2 100644 --- a/src/app/explore/page.tsx +++ b/src/app/explore/page.tsx @@ -3,9 +3,17 @@ import Jobs from "@/components/Jobs"; export default function Explore() { return (
-
-
- +
+
+
+

+ Find your perfect job with just a few skills +

+

+ Input your skills and we'll find the perfect job for you. +

+ +
diff --git a/src/app/header.tsx b/src/app/header.tsx index a55effb..1b9c5e5 100644 --- a/src/app/header.tsx +++ b/src/app/header.tsx @@ -14,27 +14,21 @@ export default function Header() {
What's my job?
- Explore - Create + + Explore + + + Create +
-
+
- {!path.includes("dashboard") && ( - - Dashboard - - )} diff --git a/src/app/page.tsx b/src/app/page.tsx index e62c5f7..92b0190 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,8 +5,8 @@ export default function Home() { return (
-
-
+
+

Find your perfect job with just a few skills

diff --git a/src/components/JobCard/JobCard.tsx b/src/components/JobCard/JobCard.tsx index dae9bfa..a907b0a 100644 --- a/src/components/JobCard/JobCard.tsx +++ b/src/components/JobCard/JobCard.tsx @@ -4,37 +4,53 @@ import JobListFailed from "../JobListFailed"; import { Card, CardHeader, CardContent } from "../ui/card"; import { JobCardProps } from "./JobCard.types"; import { Badge } from "../ui/badge"; +import Image from "next/image"; +import { getImageUrl } from "@/lib/utils"; export default function JobCard({ job, result }: JobCardProps) { return ( - + {job.status === "pending" ? ( -
- - Generating top job based on skills... +
+
+ + Generating top job based on skills... +
) : ( <> - -

{result?.jobTitle || ""}

-
- - {!!job.skills.length && ( -
- {job.skills.split(",").map((skill) => ( - - {skill} - - ))} -
- )} - {job.status === "failed" && } - {job.status === "completed" && ( -
-

{result?.jobDescription || ""}

-
- )} -
+ {`An +
+
+ +

+ {result?.jobTitle || ""} +

+
+ + {!!job.skills.length && ( +
+ {job.skills.split(",").map((skill) => ( + + {skill} + + ))} +
+ )} + {job.status === "failed" && } + {job.status === "completed" && ( +
+

{result?.jobDescription || ""}

+
+ )} +
+
)} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index d084cca..8fef6ca 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,10 @@ -import { type ClassValue, clsx } from "clsx" -import { twMerge } from "tailwind-merge" +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); +} + +export function getImageUrl(imageId: string) { + return `${process.env.NEXT_PUBLIC_CONVEX_URL}/api/storage/${imageId}`; }