feat: agents

This commit is contained in:
Nevo David 2024-12-25 17:52:10 +07:00
parent e0d4661e87
commit a0054ac2de
25 changed files with 2414 additions and 915 deletions

View File

@ -7,6 +7,7 @@ import {
Post,
Put,
Query,
Res,
} from '@nestjs/common';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
@ -23,6 +24,8 @@ import { ApiTags } from '@nestjs/swagger';
import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service';
import { GeneratorDto } from '@gitroom/nestjs-libraries/dtos/generator/generator.dto';
import { CreateGeneratedPostsDto } from '@gitroom/nestjs-libraries/dtos/generator/create.generated.posts.dto';
import { AgentGraphService } from '@gitroom/nestjs-libraries/agent/agent.graph.service';
import { Response } from 'express';
@ApiTags('Posts')
@Controller('/posts')
@ -30,7 +33,8 @@ export class PostsController {
constructor(
private _postsService: PostsService,
private _starsService: StarsService,
private _messagesService: MessagesService
private _messagesService: MessagesService,
private _agentGraphService: AgentGraphService
) {}
@Get('/marketplace/:id?')
@ -100,11 +104,20 @@ export class PostsController {
@Post('/generator')
@CheckPolicies([AuthorizationActions.Create, Sections.POSTS_PER_MONTH])
generatePosts(
async generatePosts(
@GetOrgFromRequest() org: Organization,
@Body() body: GeneratorDto
@Body() body: GeneratorDto,
@Res({ passthrough: false }) res: Response
) {
return this._postsService.generatePosts(org.id, body);
res.setHeader('Content-Type', 'application/json; charset=utf-8');
for await (const event of this._agentGraphService.start(
org.id,
body,
)) {
res.write(JSON.stringify(event) + '\n');
}
res.end();
}
@Delete('/:group')

View File

@ -10,14 +10,29 @@ import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.req
import { User } from '@prisma/client';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management';
import { AgentGraphService } from '@gitroom/nestjs-libraries/agent/agent.graph.service';
import { AgentGraphInsertService } from '@gitroom/nestjs-libraries/agent/agent.graph.insert.service';
@ApiTags('Public')
@Controller('/public')
export class PublicController {
constructor(
private _agenciesService: AgenciesService,
private _trackService: TrackService
private _trackService: TrackService,
private _agentGraphInsertService: AgentGraphInsertService
) {}
@Post('/agent')
async createAgent(@Body() body: { text: string; apiKey: string }) {
if (
!body.apiKey ||
!process.env.AGENT_API_KEY ||
body.apiKey !== process.env.AGENT_API_KEY
) {
return;
}
return this._agentGraphInsertService.newPost(body.text);
}
@Get('/agencies-list')
async getAgencyByUser() {
return this._agenciesService.getAllAgencies();

View File

@ -9,6 +9,7 @@ import { PluginModule } from '@gitroom/plugins/plugin.module';
import { PublicApiModule } from '@gitroom/backend/public-api/public.api.module';
import { ThrottlerBehindProxyGuard } from '@gitroom/nestjs-libraries/throttler/throttler.provider';
import { ThrottlerModule } from '@nestjs/throttler';
import { AgentModule } from '@gitroom/nestjs-libraries/agent/agent.module';
@Global()
@Module({
@ -18,6 +19,7 @@ import { ThrottlerModule } from '@nestjs/throttler';
ApiModule,
PluginModule,
PublicApiModule,
AgentModule,
ThrottlerModule.forRoot([
{
ttl: 3600000,

View File

@ -25,17 +25,7 @@
"executor": "nx:run-commands",
"defaultConfiguration": "development",
"options": {
"buildTarget": "commands:build",
"inspect": false,
"command": "cd dist/apps/commands && node main.js"
},
"configurations": {
"development": {
"buildTarget": "commands:build:development"
},
"production": {
"buildTarget": "commands:build:production"
}
}
},
"lint": {

View File

@ -5,11 +5,18 @@ import { DatabaseModule } from '@gitroom/nestjs-libraries/database/prisma/databa
import { RefreshTokens } from './tasks/refresh.tokens';
import { BullMqModule } from '@gitroom/nestjs-libraries/bull-mq-transport-new/bull.mq.module';
import { ConfigurationTask } from './tasks/configuration';
import { AgentRun } from './tasks/agent.run';
import { AgentModule } from '@gitroom/nestjs-libraries/agent/agent.module';
@Module({
imports: [ExternalCommandModule, DatabaseModule, BullMqModule],
imports: [
ExternalCommandModule,
DatabaseModule,
BullMqModule,
AgentModule,
],
controllers: [],
providers: [CheckStars, RefreshTokens, ConfigurationTask],
providers: [CheckStars, RefreshTokens, ConfigurationTask, AgentRun],
get exports() {
return [...this.imports, ...this.providers];
},

View File

@ -0,0 +1,15 @@
import { Command } from 'nestjs-command';
import { Injectable } from '@nestjs/common';
import { AgentGraphService } from '@gitroom/nestjs-libraries/agent/agent.graph.service';
@Injectable()
export class AgentRun {
constructor(private _agentGraphService: AgentGraphService) {}
@Command({
command: 'run:agent',
describe: 'Run the agent',
})
async agentRun() {
console.log(await this._agentGraphService.createGraph('hello', true));
}
}

View File

@ -394,4 +394,25 @@ div div .set-font-family {
transform: translate(-50%, -50%);
white-space: nowrap;
opacity: 30%;
}
.loading-shimmer {
position: relative;
color: rgba(255, 255, 255, .5);
}
.loading-shimmer:before {
content: attr(data-text);
position: absolute;
overflow: hidden;
max-width: 100%;
white-space: nowrap;
color: white;
animation: loading 4s linear 0s infinite;
filter: blur(0.4px);
}
@keyframes loading {
0% {
max-width: 0;
}
}

View File

@ -66,8 +66,13 @@ export const AddEditModal: FC<{
integrations: Integrations[];
reopenModal: () => void;
mutate: () => void;
onlyValues?: Array<{
content: string;
id?: string;
image?: Array<{ id: string; path: string }>;
}>;
}> = (props) => {
const { date, integrations: ints, reopenModal, mutate } = props;
const { date, integrations: ints, reopenModal, mutate, onlyValues } = props;
const [customer, setCustomer] = useState('');
// selected integrations to allow edit
@ -104,7 +109,7 @@ export const AddEditModal: FC<{
id?: string;
image?: Array<{ id: string; path: string }>;
}>
>([{ content: '' }]);
>(onlyValues ? onlyValues : [{ content: '' }]);
const fetch = useFetch();

View File

@ -22,7 +22,7 @@ export const Editor = forwardRef<
const user = useUser();
useCopilotReadable({
description: 'Content of the post number ' + (props.order + 1),
value: props.content,
value: props.value,
});
useCopilotAction({

View File

@ -1,193 +1,30 @@
import React, {
FC,
useCallback,
useMemo,
useState,
} from 'react';
import React, { FC, useCallback, useMemo, useState } from 'react';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
import { useRouter } from 'next/navigation';
import { deleteDialog } from '@gitroom/react/helpers/delete.dialog';
import {
Step,
StepSpace,
} from '@gitroom/frontend/components/onboarding/onboarding';
import { useModals } from '@mantine/modals';
import { Select } from '@gitroom/react/form/select';
import { Input } from '@gitroom/react/form/input';
import dayjs from 'dayjs';
import { FormProvider, SubmitHandler, useForm } from 'react-hook-form';
import { classValidatorResolver } from '@hookform/resolvers/class-validator';
import { GeneratorDto } from '@gitroom/nestjs-libraries/dtos/generator/generator.dto';
import { Button } from '@gitroom/react/form/button';
import { PostSelector } from '@gitroom/frontend/components/post-url-selector/post.url.selector';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { Textarea } from '@gitroom/react/form/textarea';
import { Checkbox } from '@gitroom/react/form/checkbox';
import clsx from 'clsx';
import {
CalendarWeekProvider,
useCalendar,
} from '@gitroom/frontend/components/launches/calendar.context';
import { AddEditModal } from '@gitroom/frontend/components/launches/add.edit.model';
import dayjs from 'dayjs';
import { Select } from '@gitroom/react/form/select';
const ThirdStep: FC<{ week: number; year: number }> = (props) => {
const { week, year } = props;
const gotToPosts = useCallback(() => {
window.location.href = `/launches?week=${week}&year=${year}`;
}, [week, year]);
return (
<div>
<div className="text-[20px] mb-[20px] flex flex-col items-center justify-center text-center mt-[20px] gap-[20px]">
<img src="/success.svg" alt="success" />
Your posts have been scheduled as drafts.
<br />
<Button onClick={gotToPosts}>Click here to see them</Button>
</div>
</div>
);
};
const SecondStep: FC<{
posts: Array<Array<{ post: string }>>;
url: string;
postId?: string;
nextStep: (params: { week: number; year: number }) => void;
}> = (props) => {
const { posts, nextStep, url, postId } = props;
const fetch = useFetch();
const [selected, setSelected] = useState<Array<string>>([]);
const [loading, setLoading] = useState(false);
const form = useForm({
values: {
date: dayjs().week() + '_' + dayjs().year(),
},
});
const addPost = useCallback(
(index: string) => () => {
if (selected.includes(index)) {
setSelected(selected.filter((i) => i !== index));
return;
}
setSelected([...selected, index]);
},
[selected]
);
const list = useMemo(() => {
const currentDate = dayjs();
return [...new Array(52)].map((_, i) => {
const week = currentDate.add(i, 'week');
return {
value: week.week() + '_' + week.year(),
label: `Week #${week.week()} (${week
.startOf('isoWeek')
.format('YYYY-MM-DD')} - ${week
.endOf('isoWeek')
.format('YYYY-MM-DD')})`,
};
});
}, []);
const createPosts: SubmitHandler<{
date: any;
}> = useCallback(
async (values) => {
setLoading(true);
await fetch('/posts/generator/draft', {
method: 'POST',
body: JSON.stringify({
posts: posts
.filter((_, index) => selected.includes(String(index)))
.map((po) => ({ list: po })),
url,
postId: postId ? `(post:${postId})` : undefined,
year: values.date.year,
week: values.date.week,
}),
});
setLoading(false);
nextStep({
week: values.date.week,
year: values.date.year,
});
},
[selected, postId, url]
);
return (
<form onSubmit={form.handleSubmit(createPosts)}>
<FormProvider {...form}>
<div className={loading ? 'opacity-75' : ''}>
<Select
label="Select a week"
name="date"
extraForm={{
setValueAs: (value) => {
const [week, year] = value.split('_');
return { week: +week, year: +year };
},
}}
>
{list.map((item) => (
<option value={item.value} key={item.value}>
{item.label}
</option>
))}
</Select>
<div className="text-[20px] mb-[20px]">
Click on the posts you would like to schedule.
<br />
They will be saved as drafts and you can edit them later.
</div>
<div className="grid grid-cols-3 gap-[25px] select-none cursor-pointer">
{posts.map((post, index) => (
<div
onClick={addPost(String(index))}
className={clsx(
'flex flex-col h-[200px] border rounded-[4px] group hover:border-white relative',
selected.includes(String(index))
? 'border-white'
: 'border-fifth'
)}
key={post[0].post}
>
{post.length > 1 && (
<div className="bg-forth absolute -left-[15px] -top-[15px] z-[100] p-[3px] rounded-[10px]">
a thread
</div>
)}
<div
className={clsx(
'flex-1 relative h-full w-full group-hover:bg-black',
selected.includes(String(index)) && 'bg-black'
)}
>
<div className="absolute left-0 top-0 w-full h-full p-[16px]">
<div className="w-full h-full overflow-hidden text-ellipsis group-hover:bg-black resize-none outline-none">
{post[0].post.split('\n\n')[0]}
</div>
</div>
</div>
</div>
))}
</div>
<div className="mt-[20px] flex justify-end">
<Button type="submit" disabled={!selected.length} loading={loading}>
Create posts
</Button>
</div>
</div>
</FormProvider>
</form>
);
};
const FirstStep: FC<{
nextStep: (
posts: Array<Array<{ post: string }>>,
url: string,
postId?: string
) => void;
}> = (props) => {
const { nextStep } = props;
const FirstStep: FC = (props) => {
const { integrations, reloadCalendarView } = useCalendar();
const modal = useModals();
const fetch = useFetch();
const [loading, setLoading] = useState(false);
const [showStep, setShowStep] = useState('');
const resolver = useMemo(() => {
return classValidatorResolver(GeneratorDto);
}, []);
@ -196,52 +33,130 @@ const FirstStep: FC<{
mode: 'all',
resolver,
values: {
url: '',
post: undefined as undefined | string,
research: '',
isPicture: false,
format: 'one_short',
tone: 'personal',
},
});
const [url, post] = form.watch(['url', 'post']);
const [research] = form.watch(['research']);
const makeSelect = useCallback(
(post?: string) => {
form.setValue('post', post?.split?.(':')[1]?.split(')')?.[0]);
const generateStep = useCallback(
async (reader: ReadableStreamDefaultReader) => {
const decoder = new TextDecoder('utf-8');
if (!post && !url) {
form.setError('url', {
message: 'You need to select a post or a URL',
});
return;
let lastResponse = {} as any;
// eslint-disable-next-line no-constant-condition
while (true) {
const { done, value } = await reader.read();
if (done) return lastResponse.data.output;
// Convert chunked binary data to string
const chunkStr = decoder.decode(value, { stream: true });
for (const chunk of chunkStr
.split('\n')
.filter((f) => f && f.indexOf('{') > -1)) {
try {
const data = JSON.parse(chunk);
switch (data.name) {
case 'agent':
setShowStep('Agent starting');
break;
case 'research':
setShowStep('Researching your content...');
break;
case 'find-category':
setShowStep('Understanding the category...');
break;
case 'find-topic':
setShowStep('Finding the topic...');
break;
case 'find-popular-posts':
setShowStep('Finding popular posts to match with...');
break;
case 'generate-hook':
setShowStep('Generating hook...');
break;
case 'generate-content':
setShowStep('Generating content...');
break;
case 'generate-picture':
setShowStep('Generating pictures...');
break;
case 'upload-pictures':
setShowStep('Uploading pictures...');
break;
case 'post-time':
setShowStep('Finding time to post...');
break;
}
lastResponse = data;
} catch (e) {
/** don't do anything **/
}
}
}
if (post && url) {
form.setError('url', {
message: 'You can only have a URL or a post',
});
return;
}
form.setError('url', {
message: '',
});
},
[post, url]
[]
);
const onSubmit: SubmitHandler<{
url: string;
post: string | undefined;
}> = useCallback(async (value) => {
setLoading(true);
const data = await (
await fetch('/posts/generator', {
research: string;
}> = useCallback(
async (value) => {
setLoading(true);
const response = await fetch('/posts/generator', {
method: 'POST',
body: JSON.stringify(value),
})
).json();
nextStep(data.list, value.url, value.post);
setLoading(false);
}, []);
});
if (!response.body) {
return;
}
const reader = response.body.getReader();
const load = await generateStep(reader);
const messages = load.content.map((p: any, index: number) => {
if (index === 0) {
return {
content: load.hook + '\n' + p.content,
...(p?.image?.path ? { image: [p.image] } : {}),
};
}
return {
content: p.content,
...(p?.image?.path ? { image: [p.image] } : {}),
};
});
setShowStep('');
modal.openModal({
closeOnClickOutside: false,
closeOnEscape: false,
withCloseButton: false,
classNames: {
modal: 'w-[100%] max-w-[1400px] bg-transparent text-textColor',
},
children: (
<AddEditModal
integrations={integrations.slice(0).map((p) => ({ ...p }))}
mutate={reloadCalendarView}
date={dayjs.utc(load.date).local()}
reopenModal={() => ({})}
onlyValues={messages}
/>
),
size: '80%',
});
setLoading(false);
},
[integrations, reloadCalendarView]
);
return (
<form
@ -250,31 +165,61 @@ const FirstStep: FC<{
>
<FormProvider {...form}>
<div className="flex flex-col">
<div className="p-[20px] border border-fifth rounded-[4px]">
<div className="pb-[10px] rounded-[4px]">
<div className="flex">
<div className="flex-1">
<Input label="URL" {...form.register('url')} />
</div>
</div>
<div className="flex flex-col-reverse">
<div className="p-[16px] bg-input border-fifth border rounded-[4px] min-h-[500px] empty:hidden">
<PostSelector
noModal={true}
onClose={() => null}
onSelect={makeSelect}
date={dayjs().add(1, 'year')}
only="article"
{!showStep ? (
<div className="loading-shimmer pb-[10px]">&nbsp;</div>
) : (
<div
className="loading-shimmer pb-[10px]"
data-text={showStep}
>
{showStep}
</div>
)}
<Textarea
label="Write anything"
disabled={loading}
placeholder="You can write anything you want, and also add links, we will do the research for you..."
{...form.register('research')}
/>
</div>
<div className="pb-[10px] existing-empty">
Or select from exising posts
<Select label="Output format" {...form.register('format')}>
<option value="one_short">Short post</option>
<option value="one_long">Long post</option>
<option value="thread_short">
A thread with short posts
</option>
<option value="thread_long">A thread with long posts</option>
</Select>
<Select label="Output format" {...form.register('tone')}>
<option value="personal">
Personal voice ({'"'}I am happy to announce{'"'})
</option>
<option value="company">
Company voice ({'"'}We are happy to announce{'"'})
</option>
</Select>
<div
className={clsx('flex items-center', loading && 'opacity-50')}
>
<Checkbox
disabled={loading}
{...form.register('isPicture')}
label="Add pictures?"
/>
</div>
</div>
</div>
</div>
</div>
<div className="mt-[20px] flex justify-end">
<Button type="submit" disabled={!!(url && post)} loading={loading}>
{url && post ? "You can't have both URL and a POST" : 'Next'}
<Button
type="submit"
disabled={research.length < 10}
loading={loading}
>
Generate
</Button>
</div>
</FormProvider>
@ -282,28 +227,14 @@ const FirstStep: FC<{
);
};
export const GeneratorPopup = () => {
const [step, setStep] = useState(1);
const modals = useModals();
const [posts, setPosts] = useState<
| {
posts: Array<Array<{ post: string }>>;
url: string;
postId?: string;
}
| undefined
>(undefined);
const [yearAndWeek, setYearAndWeek] = useState<{
year: number;
week: number;
} | null>(null);
const closeAll = useCallback(() => {
modals.closeAll();
}, []);
return (
<div className="bg-sixth p-[32px] w-full max-w-[920px] mx-auto flex flex-col gap-[24px] rounded-[4px] border border-customColor6 relative">
<div className="bg-sixth p-[32px] w-full max-w-[920px] mx-auto flex flex-col rounded-[4px] border border-customColor6 relative">
<button
onClick={closeAll}
className="outline-none absolute right-[20px] top-[15px] mantine-UnstyledButton-root mantine-ActionIcon-root hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
@ -325,37 +256,7 @@ export const GeneratorPopup = () => {
</svg>
</button>
<h1 className="text-[24px]">Generate Posts</h1>
<div className="flex">
<Step title="Generate posts" step={1} currentStep={step} lastStep={3} />
<StepSpace />
<Step title="Confirm posts" step={2} currentStep={step} lastStep={3} />
<StepSpace />
<Step title="Done" step={3} currentStep={step} lastStep={3} />
</div>
{step === 1 && (
<FirstStep
nextStep={(posts, url: string, postId?: string) => {
setPosts({
posts,
url,
postId,
});
setStep(2);
}}
/>
)}
{step === 2 && (
<SecondStep
{...posts!}
nextStep={(e) => {
setYearAndWeek(e);
setStep(3);
}}
/>
)}
{step === 3 && (
<ThirdStep week={yearAndWeek?.week!} year={yearAndWeek?.year!} />
)}
<FirstStep />
</div>
);
};
@ -363,6 +264,7 @@ export const GeneratorComponent = () => {
const user = useUser();
const router = useRouter();
const modal = useModals();
const all = useCalendar();
const generate = useCallback(async () => {
if (!user?.tier?.ai) {
@ -385,9 +287,13 @@ export const GeneratorComponent = () => {
modal: 'bg-transparent text-textColor',
},
size: '100%',
children: <GeneratorPopup />,
children: (
<CalendarWeekProvider {...all}>
<GeneratorPopup />
</CalendarWeekProvider>
),
});
}, [user]);
}, [user, all]);
return (
<button

View File

@ -20,6 +20,8 @@ import { useFireEvents } from '@gitroom/helpers/utils/use.fire.events';
import { Calendar } from './calendar';
import { useDrag, useDrop } from 'react-dnd';
import { DNDProvider } from '@gitroom/frontend/components/launches/helpers/dnd.provider';
import { GeneratorComponent } from './generator/generator';
import { useVariables } from '@gitroom/react/helpers/variable.context';
interface MenuComponentInterface {
refreshChannel: (
@ -217,6 +219,8 @@ export const MenuComponent: FC<
};
export const LaunchesComponent = () => {
const fetch = useFetch();
const user = useUser();
const {billingEnabled} = useVariables();
const router = useRouter();
const search = useSearchParams();
const toast = useToaster();
@ -333,11 +337,14 @@ export const LaunchesComponent = () => {
}
if (search.get('msg')) {
toast.show(search.get('msg')!, 'warning');
window?.opener?.postMessage({msg: search.get('msg')!, success: false}, '*');
window?.opener?.postMessage(
{ msg: search.get('msg')!, success: false },
'*'
);
}
if (search.get('added')) {
fireEvents('channel_added');
window?.opener?.postMessage({msg: 'Channel added', success: true}, '*');
window?.opener?.postMessage({ msg: 'Channel added', success: true }, '*');
}
if (window.opener) {
window.close();
@ -375,7 +382,7 @@ export const LaunchesComponent = () => {
))}
</div>
<AddProviderButton update={() => update(true)} />
{/*{sortedIntegrations?.length > 0 && user?.tier?.ai && <GeneratorComponent />}*/}
{sortedIntegrations?.length > 0 && user?.tier?.ai && billingEnabled && <GeneratorComponent />}
</div>
<div className="flex-1 flex flex-col gap-[14px]">
<Filters />

View File

@ -0,0 +1,32 @@
export const agentCategories = [
'Educational',
'Inspirational',
'Promotional',
'Entertaining',
'Interactive',
'Behind The Scenes',
'Testimonial',
'Informative',
'Humorous',
'Seasonal',
'News',
'Challenge',
'Contest',
'Tips',
'Tutorial',
'Poll',
'Survey',
'Quote',
'Event',
'FAQ',
'Story',
'Meme',
'Review',
'Announcement',
'Highlight',
'Celebration',
'Reminder',
'Debate',
'Update',
'Trend',
];

View File

@ -0,0 +1,138 @@
import { Injectable } from '@nestjs/common';
import { BaseMessage, HumanMessage } from '@langchain/core/messages';
import { END, START, StateGraph } from '@langchain/langgraph';
import { ChatOpenAI } from '@langchain/openai';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { agentCategories } from '@gitroom/nestjs-libraries/agent/agent.categories';
import { z } from 'zod';
import { agentTopics } from '@gitroom/nestjs-libraries/agent/agent.topics';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
const model = new ChatOpenAI({
apiKey: process.env.OPENAI_API_KEY,
model: 'gpt-4o-2024-08-06',
temperature: 0,
});
interface WorkflowChannelsState {
messages: BaseMessage[];
topic?: string;
category: string;
hook?: string;
content?: string;
}
const category = z.object({
category: z.string().describe('The category for the post'),
});
const topic = z.object({
topic: z.string().describe('The topic of the post'),
});
const hook = z.object({
hook: z.string().describe('The hook of the post'),
});
@Injectable()
export class AgentGraphInsertService {
constructor(
private _postsService: PostsService,
) {
}
static state = () =>
new StateGraph<WorkflowChannelsState>({
channels: {
messages: {
reducer: (currentState, updateValue) =>
currentState.concat(updateValue),
default: () => [],
},
topic: null,
category: null,
hook: null,
content: null,
},
});
async findCategory(state: WorkflowChannelsState) {
const { messages } = state;
const structuredOutput = model.withStructuredOutput(category);
return ChatPromptTemplate.fromTemplate(
`
You are an assistant that get a social media post and categorize it into to one from the following categories:
{categories}
Here is the post:
{post}
`
)
.pipe(structuredOutput)
.invoke({
post: messages[0].content,
categories: agentCategories.join(', '),
});
}
findTopic(state: WorkflowChannelsState) {
const { messages } = state;
const structuredOutput = model.withStructuredOutput(topic);
return ChatPromptTemplate.fromTemplate(
`
You are an assistant that get a social media post and categorize it into one of the following topics:
{topics}
Here is the post:
{post}
`
)
.pipe(structuredOutput)
.invoke({
post: messages[0].content,
topics: agentTopics.join(', '),
});
}
findHook(state: WorkflowChannelsState) {
const { messages } = state;
const structuredOutput = model.withStructuredOutput(hook);
return ChatPromptTemplate.fromTemplate(
`
You are an assistant that get a social media post and extract the hook, the hook is usually the first or second of both sentence of the post, but can be in a different place, make sure you don't change the wording of the post use the exact text:
{post}
`
)
.pipe(structuredOutput)
.invoke({
post: messages[0].content,
});
}
async savePost(state: WorkflowChannelsState) {
await this._postsService.createPopularPosts({
category: state.category,
topic: state.topic!,
hook: state.hook!,
content: state.messages[0].content! as string
});
return {};
}
newPost(post: string) {
const state = AgentGraphInsertService.state();
const workflow = state
.addNode('find-category', this.findCategory)
.addNode('find-topic', this.findTopic)
.addNode('find-hook', this.findHook)
.addNode('save-post', this.savePost.bind(this))
.addEdge(START, 'find-category')
.addEdge('find-category', 'find-topic')
.addEdge('find-topic', 'find-hook')
.addEdge('find-hook', 'save-post')
.addEdge('save-post', END);
const app = workflow.compile();
return app.invoke({
messages: [new HumanMessage(post)],
});
}
}

View File

@ -0,0 +1,419 @@
import { Injectable } from '@nestjs/common';
import {
BaseMessage,
HumanMessage,
ToolMessage,
} from '@langchain/core/messages';
import { END, START, StateGraph } from '@langchain/langgraph';
import { ChatOpenAI, DallEAPIWrapper } from '@langchain/openai';
import { TavilySearchResults } from '@langchain/community/tools/tavily_search';
import { ToolNode } from '@langchain/langgraph/prebuilt';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import dayjs from 'dayjs';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
import { z } from 'zod';
import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service';
import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory';
import { GeneratorDto } from '@gitroom/nestjs-libraries/dtos/generator/generator.dto';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
const tools = [new TavilySearchResults({ maxResults: 3 })];
const toolNode = new ToolNode(tools);
const model = new ChatOpenAI({
apiKey: process.env.OPENAI_API_KEY,
model: 'gpt-4o-2024-08-06',
temperature: 0.7,
});
const dalle = new DallEAPIWrapper({
apiKey: process.env.OPENAI_API_KEY,
model: 'dall-e-3',
});
interface WorkflowChannelsState {
messages: BaseMessage[];
orgId: string;
question: string;
hook?: string;
fresearch?: string;
category?: string;
topic?: string;
date?: string;
format: 'one_short' | 'one_long' | 'thread_short' | 'thread_long';
tone: 'personal' | 'company';
content?: {
content: string;
website?: string;
prompt?: string;
image?: string;
}[];
isPicture?: boolean;
popularPosts?: { content: string; hook: string }[];
}
const category = z.object({
category: z.string().describe('The category for the post'),
});
const topic = z.object({
topic: z.string().describe('The topic for the post'),
});
const hook = z.object({
hook: z
.string()
.describe(
'Hook for the new post, don\'t take it from "the request of the user"'
),
});
const contentZod = (
isPicture: boolean,
format: 'one_short' | 'one_long' | 'thread_short' | 'thread_long'
) => {
const content = z.object({
content: z.string().describe('Content for the new post'),
website: z
.string()
.optional()
.describe(
"Website for the new post if exists, If one of the post present a brand, website link must be to the root domain of the brand or don't include it, website url should contain the brand name"
),
...(isPicture
? {
prompt: z
.string()
.describe(
"Prompt to generate a picture for this post later, make sure it doesn't contain brand names and make it very descriptive in terms of style"
),
}
: {}),
});
return z.object({
content:
format === 'one_short' || format === 'one_long'
? content
: z.array(content).min(2).describe(`Content for the new post`),
});
};
@Injectable()
export class AgentGraphService {
private storage = UploadFactory.createStorage();
constructor(
private _postsService: PostsService,
private _mediaService: MediaService
) {}
static state = () =>
new StateGraph<WorkflowChannelsState>({
channels: {
messages: {
reducer: (currentState, updateValue) =>
currentState.concat(updateValue),
default: () => [],
},
fresearch: null,
format: null,
tone: null,
question: null,
orgId: null,
hook: null,
content: null,
date: null,
category: null,
popularPosts: null,
topic: null,
isPicture: null,
},
});
async startCall(state: WorkflowChannelsState) {
const runTools = model.bindTools(tools);
const response = await ChatPromptTemplate.fromTemplate(
`
Today is ${dayjs().format()}, You are an assistant that gets a social media post or requests for a social media post.
You research should be on the most possible recent data.
You concat the text of the request together with an internet research based on the text.
{text}
`
)
.pipe(runTools)
.invoke({
text: state.messages[state.messages.length - 1].content,
});
return { messages: [response] };
}
async saveResearch(state: WorkflowChannelsState) {
const content = state.messages.filter((f) => f instanceof ToolMessage);
return { fresearch: content };
}
async findCategories(state: WorkflowChannelsState) {
const allCategories = await this._postsService.findAllExistingCategories();
const structuredOutput = model.withStructuredOutput(category);
const { category: outputCategory } = await ChatPromptTemplate.fromTemplate(
`
You are an assistant that gets a text that will be later summarized into a social media post
and classify it to one of the following categories: {categories}
text: {text}
`
)
.pipe(structuredOutput)
.invoke({
categories: allCategories.map((p) => p.category).join(', '),
text: state.fresearch,
});
return {
category: outputCategory,
};
}
async findTopic(state: WorkflowChannelsState) {
const allTopics = await this._postsService.findAllExistingTopicsOfCategory(
state?.category!
);
if (allTopics.length === 0) {
return { topic: null };
}
const structuredOutput = model.withStructuredOutput(topic);
const { topic: outputTopic } = await ChatPromptTemplate.fromTemplate(
`
You are an assistant that gets a text that will be later summarized into a social media post
and classify it to one of the following topics: {topics}
text: {text}
`
)
.pipe(structuredOutput)
.invoke({
topics: allTopics.map((p) => p.topic).join(', '),
text: state.fresearch,
});
return {
topic: outputTopic,
};
}
async findPopularPosts(state: WorkflowChannelsState) {
const popularPosts = await this._postsService.findPopularPosts(
state.category!,
state.topic
);
return { popularPosts };
}
async generateHook(state: WorkflowChannelsState) {
const structuredOutput = model.withStructuredOutput(hook);
const { hook: outputHook } = await ChatPromptTemplate.fromTemplate(
`
You are an assistant that gets content for a social media post, and generate only the hook.
The hook is the 1-2 sentences of the post that will be used to grab the attention of the reader.
You will be provided existing hooks you should use as inspiration.
- Avoid weird hook that starts with "Discover the secret...", "The best...", "The most...", "The top..."
- Make sure it sounds ${state.tone}
- Use ${state.tone === 'personal' ? '1st' : '3rd'} person mode
- Make sure it's engaging
- Don't be cringy
- Use simple english
- Make sure you add "\n" between the lines
- Don't take the hook from "request of the user"
<!-- BEGIN request of the user -->
{request}
<!-- END request of the user -->
<!-- BEGIN existing hooks -->
{hooks}
<!-- END existing hooks -->
<!-- BEGIN current content -->
{text}
<!-- END current content -->
`
)
.pipe(structuredOutput)
.invoke({
request: state.messages[0].content,
hooks: state.popularPosts!.map((p) => p.hook).join('\n'),
text: state.fresearch,
});
return {
hook: outputHook,
};
}
async generateContent(state: WorkflowChannelsState) {
const structuredOutput = model.withStructuredOutput(
contentZod(!!state.isPicture, state.format)
);
const { content: outputContent } = await ChatPromptTemplate.fromTemplate(
`
You are an assistant that gets existing hook of a social media, content and generate only the content.
- Don't add any hashtags
- Make sure it sounds ${state.tone}
- Use ${state.tone === 'personal' ? '1st' : '3rd'} person mode
- ${
state.format === 'one_short' || state.format === 'thread_short'
? 'Post should be maximum 200 chars to fit twitter'
: 'Post should be long'
}
- ${
state.format === 'one_short' || state.format === 'one_long'
? 'Post should have only 1 item'
: 'Post should have minimum 2 items'
}
- Use the hook as inspiration
- Make sure it's engaging
- Don't be cringy
- Use simple english
- The Content should not contain the hook
- Try to put some call to action at the end of the post
- Make sure you add "\n" between the lines
- Add "\n" after every "."
Hook:
{hook}
User request:
{request}
current content information:
{information}
`
)
.pipe(structuredOutput)
.invoke({
hook: state.hook,
request: state.messages[0].content,
information: state.fresearch,
});
return {
content: outputContent,
};
}
async fixArray(state: WorkflowChannelsState) {
if (state.format === 'one_short' || state.format === 'one_long') {
return {
content: [state.content],
};
}
return {};
}
async generatePictures(state: WorkflowChannelsState) {
if (!state.isPicture) {
return {};
}
const newContent = await Promise.all(
(state.content || []).map(async (p) => {
const image = await dalle.invoke(p.prompt!);
return {
...p,
image,
};
})
);
return {
content: newContent,
};
}
async uploadPictures(state: WorkflowChannelsState) {
const all = await Promise.all(
(state.content || []).map(async (p) => {
if (p.image) {
const upload = await this.storage.uploadSimple(p.image);
const name = upload.split('/').pop()!;
const uploadWithId = await this._mediaService.saveFile(
state.orgId,
name,
upload
);
return {
...p,
image: uploadWithId,
};
}
return p;
})
);
return { content: all };
}
async isGeneratePicture(state: WorkflowChannelsState) {
if (state.isPicture) {
return 'generate-picture';
}
return 'post-time';
}
async postDateTime(state: WorkflowChannelsState) {
return { date: await this._postsService.findFreeDateTime(state.orgId) };
}
start(orgId: string, body: GeneratorDto) {
const state = AgentGraphService.state();
const workflow = state
.addNode('agent', this.startCall.bind(this))
.addNode('research', toolNode)
.addNode('save-research', this.saveResearch.bind(this))
.addNode('find-category', this.findCategories.bind(this))
.addNode('find-topic', this.findTopic.bind(this))
.addNode('find-popular-posts', this.findPopularPosts.bind(this))
.addNode('generate-hook', this.generateHook.bind(this))
.addNode('generate-content', this.generateContent.bind(this))
.addNode('generate-content-fix', this.fixArray.bind(this))
.addNode('generate-picture', this.generatePictures.bind(this))
.addNode('upload-pictures', this.uploadPictures.bind(this))
.addNode('post-time', this.postDateTime.bind(this))
.addEdge(START, 'agent')
.addEdge('agent', 'research')
.addEdge('research', 'save-research')
.addEdge('save-research', 'find-category')
.addEdge('find-category', 'find-topic')
.addEdge('find-topic', 'find-popular-posts')
.addEdge('find-popular-posts', 'generate-hook')
.addEdge('generate-hook', 'generate-content')
.addEdge('generate-content', 'generate-content-fix')
.addConditionalEdges(
'generate-content-fix',
this.isGeneratePicture.bind(this)
)
.addEdge('generate-picture', 'upload-pictures')
.addEdge('upload-pictures', 'post-time')
.addEdge('post-time', END);
const app = workflow.compile();
return app.streamEvents(
{
messages: [new HumanMessage(body.research)],
isPicture: body.isPicture,
format: body.format,
tone: body.tone,
orgId,
},
{
streamMode: 'values',
version: 'v2',
}
);
}
}

View File

@ -0,0 +1,12 @@
import { Global, Module } from '@nestjs/common';
import { AgentGraphService } from '@gitroom/nestjs-libraries/agent/agent.graph.service';
import { AgentGraphInsertService } from '@gitroom/nestjs-libraries/agent/agent.graph.insert.service';
@Global()
@Module({
providers: [AgentGraphService, AgentGraphInsertService],
get exports() {
return this.providers;
},
})
export class AgentModule {}

View File

@ -0,0 +1,82 @@
export const agentTopics = [
'Business',
'Marketing',
'Finance',
'Startups',
'Networking',
'Leadership',
'Strategy',
'Branding',
'Analytics',
'Growth',
'Drawing',
'Painting',
'Design',
'Photography',
'Writing',
'Sculpting',
'Animation',
'Sketching',
'Crafting',
'Calligraphy',
'Mindset',
'Productivity',
'Motivation',
'Education',
'Learning',
'Skills',
'Success',
'Wellness',
'Goals',
'Inspiration',
'Fashion',
'Travel',
'Food',
'Fitness',
'Health',
'Beauty',
'Home',
'Decor',
'Hobbies',
'Parenting',
'Tech',
'Gadgets',
'AI',
'Coding',
'Software',
'Innovation',
'Apps',
'Gaming',
'Robotics',
'Security',
'Music',
'Movies',
'Sports',
'Books',
'Theater',
'Comedy',
'Dance',
'Celebrities',
'Culture',
'Gaming',
'Environment',
'Equality',
'Activism',
'Justice',
'Diversity',
'Sustainability',
'Inclusion',
'Awareness',
'Charity',
'Peace',
'Holidays',
'Festivities',
'Seasons',
'Trends',
'Celebrations',
'Anniversaries',
'Milestones',
'Memories',
'Promotions',
'Updates',
];

View File

@ -39,8 +39,8 @@ export class IntegrationRepository {
id: plugId,
},
include: {
integration: true
}
integration: true,
},
});
}
@ -486,4 +486,17 @@ export class IntegrationRepository {
})),
});
}
async getPostingTimes(orgId: string) {
return this._integration.model.integration.findMany({
where: {
organizationId: orgId,
disabled: false,
deletedAt: null,
},
select: {
postingTimes: true,
},
})
}
}

View File

@ -19,7 +19,9 @@ import { IntegrationTimeDto } from '@gitroom/nestjs-libraries/dtos/integrations/
import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory';
import { PlugDto } from '@gitroom/nestjs-libraries/dtos/plugs/plug.dto';
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client';
import { difference } from 'lodash';
import { difference, uniq } from 'lodash';
import utc from 'dayjs/plugin/utc';
dayjs.extend(utc);
@Injectable()
export class IntegrationService {
@ -515,4 +517,16 @@ export class IntegrationService {
const loadOnlyIds = exisingData.map((p) => p.value);
return difference(id, loadOnlyIds);
}
async findFreeDateTime(orgId: string): Promise<number[]> {
const findTimes = await this._integrationRepository.getPostingTimes(orgId);
return uniq(
findTimes.reduce((all: any, current: any) => {
return [
...all,
...JSON.parse(current.postingTimes).map((p: { time: number }) => p.time),
];
}, [] as number[])
);
}
}

View File

@ -13,7 +13,10 @@ dayjs.extend(weekOfYear);
@Injectable()
export class PostsRepository {
constructor(private _post: PrismaRepository<'post'>) {}
constructor(
private _post: PrismaRepository<'post'>,
private _popularPosts: PrismaRepository<'popularPosts'>
) {}
getOldPosts(orgId: string, date: string) {
return this._post.model.post.findMany({
@ -387,4 +390,84 @@ export class PostsRepository {
},
});
}
findAllExistingCategories() {
return this._popularPosts.model.popularPosts.findMany({
select: {
category: true,
},
distinct: ['category'],
});
}
findAllExistingTopicsOfCategory(category: string) {
return this._popularPosts.model.popularPosts.findMany({
where: {
category,
},
select: {
topic: true,
},
distinct: ['topic'],
});
}
findPopularPosts(category: string, topic?: string) {
return this._popularPosts.model.popularPosts.findMany({
where: {
category,
...(topic ? { topic } : {}),
},
select: {
content: true,
hook: true,
},
});
}
createPopularPosts(post: {
category: string;
topic: string;
content: string;
hook: string;
}) {
return this._popularPosts.model.popularPosts.create({
data: {
category: 'category',
topic: 'topic',
content: 'content',
hook: 'hook',
},
});
}
async getPostsCountsByDates(
orgId: string,
times: number[],
date: dayjs.Dayjs
) {
const dates = await this._post.model.post.findMany({
where: {
deletedAt: null,
organizationId: orgId,
publishDate: {
in: times.map((time) => {
return date.clone().add(time, 'minutes').toDate();
}),
},
},
});
return times.filter(
(time) =>
date.clone().add(time, 'minutes').isAfter(dayjs.utc()) &&
!dates.find((dateFind) => {
return (
dayjs
.utc(dateFind.publishDate)
.diff(date.clone().startOf('day'), 'minutes') == time
);
})
);
}
}

View File

@ -6,12 +6,9 @@ import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integ
import { Integration, Post, Media, From } from '@prisma/client';
import { GetPostsDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.dto';
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
import { capitalize, shuffle } from 'lodash';
import { capitalize, shuffle, uniq } from 'lodash';
import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service';
import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service';
import { GeneratorDto } from '@gitroom/nestjs-libraries/dtos/generator/generator.dto';
import { ExtractContentService } from '@gitroom/nestjs-libraries/openai/extract.content.service';
import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service';
import { CreateGeneratedPostsDto } from '@gitroom/nestjs-libraries/dtos/generator/create.generated.posts.dto';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
@ -22,6 +19,8 @@ import {
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client';
import { timer } from '@gitroom/helpers/utils/timer';
import { AuthTokenDetails } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import utc from 'dayjs/plugin/utc';
dayjs.extend(utc);
type PostWithConditionals = Post & {
integration?: Integration;
@ -37,8 +36,6 @@ export class PostsService {
private _notificationService: NotificationService,
private _messagesService: MessagesService,
private _stripeService: StripeService,
private _extractContentService: ExtractContentService,
private _openAiService: OpenaiService,
private _integrationService: IntegrationService
) {}
@ -606,26 +603,6 @@ export class PostsService {
}
}
async loadPostContent(postId: string) {
const post = await this._postRepository.getPostById(postId);
if (!post) {
return '';
}
return post.content;
}
async generatePosts(orgId: string, body: GeneratorDto) {
const content = body.url
? await this._extractContentService.extractContent(body.url)
: await this.loadPostContent(body.post);
const value = body.url
? await this._openAiService.extractWebsiteText(content!)
: await this._openAiService.generatePosts(content!);
return { list: value };
}
async generatePostsDraft(orgId: string, body: CreateGeneratedPostsDto) {
const getAllIntegrations = (
await this._integrationService.getIntegrationsList(orgId)
@ -702,4 +679,59 @@ export class PostsService {
}
}
}
findAllExistingCategories() {
return this._postRepository.findAllExistingCategories();
}
findAllExistingTopicsOfCategory(category: string) {
return this._postRepository.findAllExistingTopicsOfCategory(category);
}
findPopularPosts(category: string, topic?: string) {
return this._postRepository.findPopularPosts(category, topic);
}
async findFreeDateTime(orgId: string) {
const findTimes = await this._integrationService.findFreeDateTime(orgId);
return this.findFreeDateTimeRecursive(
orgId,
findTimes,
dayjs.utc().startOf('day')
);
}
async createPopularPosts(post: {
category: string;
topic: string;
content: string;
hook: string;
}) {
return this._postRepository.createPopularPosts(post);
}
private async findFreeDateTimeRecursive(
orgId: string,
times: number[],
date: dayjs.Dayjs
): Promise<string> {
const list = await this._postRepository.getPostsCountsByDates(
orgId,
times,
date
);
if (!list.length) {
return this.findFreeDateTimeRecursive(orgId, times, date.add(1, 'day'));
}
const num = list.reduce<null | number>((prev, curr) => {
if (prev === null || prev > curr) {
return curr;
}
return prev;
}, null) as number;
return date.clone().add(num, 'minutes').format('YYYY-MM-DDTHH:mm:00');
}
}

View File

@ -486,6 +486,16 @@ model ExisingPlugData {
@@unique([integrationId, methodName, value])
}
model PopularPosts {
id String @id @default(uuid())
category String
topic String
content String
hook String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum OrderStatus {
PENDING
ACCEPTED

View File

@ -1,24 +1,20 @@
import {
IsDefined,
IsInt,
IsString,
IsUrl,
ValidateIf,
ValidateNested,
IsBoolean, IsIn, IsString, MinLength
} from 'class-validator';
export class GeneratorDto {
@IsString()
@ValidateIf((o) => !o.post)
@IsUrl(
{},
{
message: 'Invalid URL',
}
)
url: string;
@MinLength(10)
research: string;
@IsBoolean()
isPicture: boolean;
@ValidateIf((o) => !o.url)
@IsString()
post: string;
@IsIn(['one_short', 'one_long', 'thread_short', 'thread_long'])
format: 'one_short' | 'one_long' | 'thread_short' | 'thread_long';
@IsString()
@IsIn(['personal', 'company'])
tone: 'personal' | 'company';
}

View File

@ -16,7 +16,7 @@ export const Textarea: FC<DetailedHTMLProps<InputHTMLAttributes<HTMLTextAreaElem
}, [form?.formState?.errors?.[props?.name!]?.message, error]);
return (
<div className="flex flex-col gap-[6px]">
<div className={clsx("flex flex-col gap-[6px]", props.disabled && 'opacity-50')}>
<div className={`${interClass} text-[14px]`}>{label}</div>
<textarea {...disableForm ? {} : form.register(props.name)} className={clsx("bg-input min-h-[150px] p-[16px] outline-none border-fifth border rounded-[4px] text-inputText placeholder-inputText", className)} {...rest} />
<div className="text-red-400 text-[12px]">{err || <>&nbsp;</>}</div>

1815
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,7 @@
"dev:cron": "npx nx run cron:serve:development",
"dev:docker": "docker compose -f ./docker-compose.dev.yaml up -d",
"start:prod": "node dist/apps/backend/main.js",
"commands:build:development": "npx nx run commands:build:development",
"start:prod:frontend": "nx run frontend:serve:production",
"start:prod:workers": "node dist/apps/workers/main.js",
"start:prod:cron": "node dist/apps/cron/main.js",
@ -41,6 +42,10 @@
"@copilotkit/react-ui": "^1.4.7",
"@copilotkit/runtime": "^1.4.7",
"@hookform/resolvers": "^3.3.4",
"@langchain/community": "^0.3.19",
"@langchain/core": "^0.3.26",
"@langchain/langgraph": "^0.2.34",
"@langchain/openai": "^0.3.16",
"@mantine/core": "^5.10.5",
"@mantine/dates": "^5.10.5",
"@mantine/hooks": "^5.10.5",
@ -91,6 +96,8 @@
"@uppy/react": "^4.0.2",
"@uppy/status-bar": "^4.0.3",
"@uppy/xhr-upload": "^4.1.0",
"ai": "^4.0.22",
"algoliasearch": "^5.18.0",
"array-move": "^4.0.0",
"axios": "^1.7.7",
"bcrypt": "^5.1.1",
@ -157,7 +164,8 @@
"utf-8-validate": "^5.0.10",
"uuid": "^10.0.0",
"yargs": "^17.7.2",
"yup": "^1.4.0"
"yup": "^1.4.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@nestjs/schematics": "^10.0.1",