Created
November 27, 2024 07:09
-
-
Save retro/729221317c0d493d4d197836a4157e3d to your computer and use it in GitHub Desktop.
Durable components demo code
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Effect, pipe, Schema, Fiber, Array } from "effect"; | |
import { Component, State, Endpoint, Api, type GetAppType } from "@repo/core"; | |
import OpenAI from "openai"; | |
import { zodResponseFormat } from "openai/helpers/zod.mjs"; | |
import { z } from "zod"; | |
const openai = new OpenAI({ | |
apiKey: process.env.OPENAI_API_KEY, | |
organization: process.env.OPENAI_ORGANIZATION, | |
}); | |
const Post = Component.setup("Post", { | |
endpoints: { | |
message: Endpoint.make( | |
Schema.Struct({ | |
kind: Schema.Literal("message"), | |
content: Schema.String, | |
}) | |
), | |
approval: Endpoint.make( | |
Schema.Struct({ kind: Schema.Literal("approval") }) | |
), | |
}, | |
state: { | |
post: State.make<string | null>(() => null), | |
isLoading: State.make<boolean>(() => false), | |
}, | |
components: {}, | |
}).build( | |
( | |
{ state, endpoints }, | |
payload: { site: "instagram" | "facebook" | "twitter"; keyPoints: string[] } | |
) => | |
Effect.gen(function* () { | |
// Define the messages to send to the OpenAI API. We don't need to store them | |
// in the DB, IO is durable, and messages can be recomputed as needed | |
const messages: OpenAI.ChatCompletionMessageParam[] = [ | |
{ | |
role: "system", | |
content: `You are a helpful social media post generator. Your task is to generate a social media post for the given key points. The post should be compelling and engaging, and should follow the platform's guidelines and best practices. | |
You will be writing the post for ${payload.site}`, | |
}, | |
{ | |
role: "user", | |
content: `\ | |
Write a ${payload.site} post for the following key points: | |
${payload.keyPoints.map((k) => `- ${k}`).join("\n")} | |
`, | |
}, | |
]; | |
let idx = 0; | |
// Chat loop | |
while (true) { | |
// Show loading indicator, state is streamed to the frontend | |
yield* State.update(state.isLoading, true); | |
// Call the OpenAI API with the current messages and get the new post | |
const completion = yield* Api.io( | |
`post-${idx}`, | |
pipe( | |
getPost(messages), | |
Effect.catchTag("UnknownException", () => Effect.succeed(null)) | |
) | |
); | |
yield* State.update(state.isLoading, false); | |
const value = completion?.choices[0]?.message?.parsed; | |
if (!value) { | |
break; | |
} | |
messages.push({ | |
role: "assistant", | |
content: value.content, | |
}); | |
yield* State.update(state.post, value.content); | |
// Open two endpoints - one for the user to send a message, and one for | |
// the user approval | |
const message = yield* endpoints.message(`message-${idx}`); | |
const approval = yield* endpoints.approval(`approval-${idx}`); | |
// Wait for either message or approval to complete | |
const result = yield* Effect.race(message.value, approval.value); | |
// If the user approved the post, return the post | |
if (result.kind === "approval") { | |
return { site: payload.site, post: value.content }; | |
} | |
// Push the user's message to the messages array and continue | |
// the chat loop | |
messages.push({ role: "user", content: result.content }); | |
idx++; | |
} | |
}) | |
); | |
function getPost(messages: OpenAI.ChatCompletionMessageParam[]) { | |
return Effect.tryPromise(async () => { | |
return await openai.beta.chat.completions.parse({ | |
model: "gpt-4o-2024-08-06", | |
messages, | |
temperature: 0.5, | |
response_format: zodResponseFormat( | |
z.object({ | |
reasoning: z | |
.string() | |
.describe("Explain the reasoning behind the post"), | |
content: z.string().describe("The content of the post"), | |
}), | |
"post" | |
), | |
}); | |
}); | |
} | |
// Generates key points from an article | |
const KeyPoints = Component.setup("KeyPoints", { | |
endpoints: {}, | |
state: {}, | |
components: {}, | |
}).build((_api, payload: { article: string }) => | |
Effect.gen(function* () { | |
let run = 0; | |
// Retry 5 times | |
while (run < 5) { | |
// Call the OpenAI API with the article and get the key points | |
const completion = yield* Api.io( | |
"key-points", | |
pipe( | |
getKeyPoints(payload.article), | |
Effect.catchTag("UnknownException", () => Effect.succeed(null)) | |
) | |
); | |
const keyPoints = completion?.choices[0]?.message?.parsed; | |
if (keyPoints) { | |
return keyPoints; | |
} | |
run++; | |
} | |
}) | |
); | |
function getKeyPoints(article: string) { | |
return Effect.tryPromise(async () => { | |
return await openai.beta.chat.completions.parse({ | |
model: "gpt-4o-2024-08-06", | |
messages: [ | |
{ | |
role: "system", | |
content: | |
"You are a helpful assistant that generates key points from an article.", | |
}, | |
{ | |
role: "user", | |
content: `\ | |
Write a list of key points from the following article | |
> ${article} | |
`, | |
}, | |
], | |
response_format: zodResponseFormat( | |
z.object({ keyPoints: z.array(z.string()) }), | |
"keyPoints" | |
), | |
}); | |
}); | |
} | |
/* Root component | |
- Gets the article from the user | |
- Generates key points from the article | |
- Spawns a component for each social media platform | |
*/ | |
const SocialMediaGeneratorSetup = Component.setup("SocialMediaGenerator", { | |
// Endpoints allow us to communicate with the outside world | |
endpoints: { | |
initialPayload: Endpoint.make( | |
Schema.Struct({ | |
article: Schema.String, | |
twitter: Schema.Boolean, | |
facebook: Schema.Boolean, | |
instagram: Schema.Boolean, | |
}) | |
), | |
}, | |
// State is streamed to the frontend | |
state: { | |
keyPoints: State.make<string[]>(() => []), | |
article: State.make<string | null>(() => null), | |
}, | |
components: { | |
KeyPoints, | |
InstagramPost: Post, | |
FacebookPost: Post, | |
TwitterPost: Post, | |
}, | |
}); | |
export const SocialMediaGenerator = SocialMediaGeneratorSetup.build( | |
({ components, state, endpoints }, _payload: {}) => | |
Effect.gen(function* () { | |
// Get initial user input (article and selected social media platforms) | |
const initialPayload = yield* endpoints | |
.initialPayload("initialPayload") | |
.pipe(Effect.andThen(({ value }) => value)); | |
yield* State.update(state.article, initialPayload.article); | |
// Generate key points from the article | |
const keyPointsResponse = yield* components | |
.KeyPoints({ | |
article: initialPayload.article, | |
}) | |
.pipe(Effect.andThen(Fiber.join)); | |
if (!keyPointsResponse) { | |
return; | |
} | |
yield* State.update(state.keyPoints, () => keyPointsResponse.keyPoints); | |
// Spawn components for each selected social media platform | |
const postComponents = pipe( | |
[ | |
initialPayload.twitter | |
? yield* components.TwitterPost({ | |
site: "twitter", | |
keyPoints: keyPointsResponse.keyPoints, | |
}) | |
: null, | |
initialPayload.facebook | |
? yield* components.FacebookPost({ | |
site: "facebook", | |
keyPoints: keyPointsResponse.keyPoints, | |
}) | |
: null, | |
initialPayload.instagram | |
? yield* components.InstagramPost({ | |
site: "instagram", | |
keyPoints: keyPointsResponse.keyPoints, | |
}) | |
: null, | |
], | |
Array.filter((component) => component !== null) | |
); | |
// Wait for all components to complete - workflow is done | |
// You could call an API here to send the posts to your backend | |
const posts = yield* Fiber.joinAll(postComponents); | |
}) | |
); | |
export type App = GetAppType<typeof SocialMediaGenerator>; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { Route } from "./+types/home"; | |
import { Welcome } from "../welcome/welcome"; | |
import { useSynxio, Synxio } from "~/lib/synxio"; | |
import { z } from "zod"; | |
import { zodResolver } from "@hookform/resolvers/zod"; | |
import { useForm } from "react-hook-form"; | |
import { useEffect } from "react"; | |
import { Textarea } from "~/components/ui/textarea"; | |
import { Button } from "~/components/ui/button"; | |
import { Checkbox } from "~/components/ui/checkbox"; | |
import { | |
Form, | |
FormControl, | |
FormDescription, | |
FormField, | |
FormItem, | |
FormLabel, | |
} from "~/components/ui/form"; | |
import { | |
Dialog, | |
DialogContent, | |
DialogDescription, | |
DialogFooter, | |
DialogHeader, | |
DialogTitle, | |
DialogTrigger, | |
} from "~/components/ui/dialog"; | |
export function meta({}: Route.MetaArgs) { | |
return [ | |
{ title: "New React Router App" }, | |
{ name: "description", content: "Welcome to React Router!" }, | |
]; | |
} | |
const PostFeedbackSchema = z.object({ | |
content: z.string().min(1), | |
}); | |
type PostFeedbackSchema = z.infer<typeof PostFeedbackSchema>; | |
function PostFeedback({ | |
messageUrl, | |
approvalUrl, | |
}: { | |
messageUrl: string; | |
approvalUrl: string; | |
}) { | |
const form = useForm<PostFeedbackSchema>({ | |
resolver: zodResolver(PostFeedbackSchema), | |
}); | |
const onSubmit = (data: PostFeedbackSchema) => { | |
fetch(`http://localhost:3000/${messageUrl}`, { | |
method: "POST", | |
headers: { | |
Accept: "application/json", | |
"Content-Type": "application/json", | |
}, | |
body: JSON.stringify({ kind: "message", content: data.content }), | |
}); | |
}; | |
return ( | |
<div className="space-y-4"> | |
<Form {...form}> | |
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> | |
<FormField | |
control={form.control} | |
name="content" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Feedback</FormLabel> | |
<FormControl> | |
<Textarea {...field} placeholder="Make it better" /> | |
</FormControl> | |
</FormItem> | |
)} | |
></FormField> | |
<div className="flex justify-end gap-4"> | |
<Button | |
type="button" | |
variant="outline" | |
onClick={() => { | |
fetch(`http://localhost:3000/${approvalUrl}`, { | |
method: "POST", | |
headers: { | |
Accept: "application/json", | |
"Content-Type": "application/json", | |
}, | |
body: JSON.stringify({ | |
kind: "approval", | |
}), | |
}); | |
}} | |
> | |
Approve | |
</Button> | |
<Button type="submit">Submit Feedback</Button> | |
</div> | |
</form> | |
</Form> | |
</div> | |
); | |
} | |
function Post({ id, site }: { id: string; site: string }) { | |
return ( | |
<Synxio.Component | |
name="Post" | |
id={id} | |
whenRunning={(component) => { | |
return ( | |
<div className="border rounded p-4 space-y-2"> | |
<h2 className="text-md font-bold">{site} Post</h2> | |
<div> | |
{component.state.isLoading ? ( | |
<Spinner /> | |
) : ( | |
<div className="space-y-4"> | |
<div>{component.state.post}</div> | |
<hr /> | |
<PostFeedback | |
messageUrl={component.endpoints.message} | |
approvalUrl={component.endpoints.approval} | |
/> | |
</div> | |
)} | |
</div> | |
</div> | |
); | |
}} | |
whenCompleted={(component) => { | |
return ( | |
<div className="border rounded p-4 space-y-2"> | |
<h2 className="text-md font-bold">{site} Post</h2> | |
<div> | |
<div className="space-y-4"> | |
<div>{component.state.post}</div> | |
<hr /> | |
<div>✅ Approved</div> | |
</div> | |
</div> | |
</div> | |
); | |
}} | |
/> | |
); | |
} | |
function Posts() { | |
const component = useSynxio("SocialMediaGenerator"); | |
if (!component) { | |
return null; | |
} | |
const { components } = component; | |
const potentialComponents = [ | |
components.InstagramPost, | |
components.FacebookPost, | |
components.TwitterPost, | |
].filter(Boolean); | |
if (!potentialComponents.length) { | |
return null; | |
} | |
console.log(components); | |
return ( | |
<div className="space-y-2"> | |
<h2 className="text-lg font-bold">Posts</h2> | |
<div className="space-y-4"> | |
{components.TwitterPost ? ( | |
<Post id={components.TwitterPost} site="Twitter" /> | |
) : null} | |
{components.FacebookPost ? ( | |
<Post id={components.FacebookPost} site="Facebook" /> | |
) : null} | |
{components.InstagramPost ? ( | |
<Post id={components.InstagramPost} site="Instagram" /> | |
) : null} | |
</div> | |
</div> | |
); | |
} | |
function Spinner() { | |
return ( | |
<div | |
className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-solid border-current border-e-transparent align-[-0.125em] text-surface motion-reduce:animate-[spin_1.5s_linear_infinite] dark:text-red-700" | |
role="status" | |
> | |
<span className="!absolute !-m-px !h-px !w-px !overflow-hidden !whitespace-nowrap !border-0 !p-0 ![clip:rect(0,0,0,0)]"> | |
Loading... | |
</span> | |
</div> | |
); | |
} | |
function GeneratingKeyPoints() { | |
return Synxio.Component({ | |
name: "KeyPoints", | |
whenRunning: () => ( | |
<div className="flex gap-2 items-center bg-white rounded px-2 py-1 text-zinc-800"> | |
<Spinner /> | |
Generating key points... | |
</div> | |
), | |
}); | |
} | |
function KeyPoints({ keyPoints }: { keyPoints: string[] }) { | |
if (!keyPoints.length) { | |
return null; | |
} | |
return ( | |
<div> | |
<h2 className="text-lg font-bold">Key Points</h2> | |
<ul className="list-disc ml-4"> | |
{keyPoints.map((k, idx) => { | |
return <li key={idx}>{k}</li>; | |
})} | |
</ul> | |
</div> | |
); | |
} | |
const ArticleSchema = z.object({ | |
article: z.string().min(1), | |
instagram: z.boolean().default(false), | |
facebook: z.boolean().default(false), | |
twitter: z.boolean().default(false), | |
}); | |
type ArticleSchema = z.infer<typeof ArticleSchema>; | |
function InitialPayload({ url }: { url: string }) { | |
const form = useForm<ArticleSchema>({ | |
resolver: zodResolver(ArticleSchema), | |
defaultValues: { | |
instagram: false, | |
facebook: false, | |
twitter: false, | |
}, | |
}); | |
const onSubmit = (data: ArticleSchema) => { | |
fetch(`http://localhost:3000/${url}`, { | |
method: "POST", | |
headers: { | |
Accept: "application/json", | |
"Content-Type": "application/json", | |
}, | |
body: JSON.stringify(data), | |
}); | |
}; | |
return ( | |
<div className="space-y-4"> | |
<Form {...form}> | |
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> | |
<FormField | |
control={form.control} | |
name="article" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Article</FormLabel> | |
<FormControl> | |
<Textarea {...field} placeholder="Article" /> | |
</FormControl> | |
<FormDescription> | |
Write an article, and we'll generate social media posts for | |
you! | |
</FormDescription> | |
</FormItem> | |
)} | |
></FormField> | |
<FormField | |
control={form.control} | |
name="twitter" | |
render={({ field }) => ( | |
<FormItem className="flex flex-row items-center space-x-2 space-y-0"> | |
<FormControl> | |
<Checkbox | |
checked={field.value} | |
onCheckedChange={field.onChange} | |
/> | |
</FormControl> | |
<FormLabel className="font-normal"> | |
Generate Twitter post | |
</FormLabel> | |
</FormItem> | |
)} | |
></FormField> | |
<FormField | |
control={form.control} | |
name="facebook" | |
render={({ field }) => ( | |
<FormItem className="flex flex-row items-center space-x-2 space-y-0"> | |
<FormControl> | |
<Checkbox | |
checked={field.value} | |
onCheckedChange={field.onChange} | |
/> | |
</FormControl> | |
<FormLabel className="font-normal"> | |
Generate Facebook post | |
</FormLabel> | |
</FormItem> | |
)} | |
></FormField> | |
<FormField | |
control={form.control} | |
name="instagram" | |
render={({ field }) => ( | |
<FormItem className="flex flex-row items-center space-x-2 space-y-0"> | |
<FormControl> | |
<Checkbox | |
checked={field.value} | |
onCheckedChange={field.onChange} | |
/> | |
</FormControl> | |
<FormLabel className="font-normal"> | |
Generate Instagram post | |
</FormLabel> | |
</FormItem> | |
)} | |
></FormField> | |
<Button type="submit">Submit</Button> | |
</form> | |
</Form> | |
</div> | |
); | |
} | |
export default function Home() { | |
const component = useSynxio("SocialMediaGenerator"); | |
if (!component) { | |
return null; | |
} | |
const initialPayloadUrl = component.endpoints.initialPayload; | |
return ( | |
<div className="max-w-3xl mx-auto text-sm flex flex-col gap-4 p-4"> | |
<h1 className="text-xl font-bold">Social Media Generator</h1> | |
{!component.state.article ? ( | |
<InitialPayload url={initialPayloadUrl} /> | |
) : null} | |
<GeneratingKeyPoints /> | |
<KeyPoints keyPoints={component.state.keyPoints} /> | |
<Posts /> | |
{component.status === "completed" ? "🎉 Done!" : null} | |
</div> | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment