Skip to content

Instantly share code, notes, and snippets.

@retro
Created November 27, 2024 07:09
Show Gist options
  • Save retro/729221317c0d493d4d197836a4157e3d to your computer and use it in GitHub Desktop.
Save retro/729221317c0d493d4d197836a4157e3d to your computer and use it in GitHub Desktop.
Durable components demo code
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>;
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