From 3a31da4b417faf786df7ad48c75d27f95f0b6182 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Tue, 10 Jun 2025 19:46:21 +0700 Subject: [PATCH] feat: separate posts with ai --- .../src/api/routes/posts.controller.ts | 8 +++ .../providers/high.order.provider.tsx | 40 +++++++++-- .../src/components/launches/separate.post.tsx | 45 ++++++++++++ .../database/prisma/posts/posts.service.ts | 12 +++- .../src/openai/openai.service.ts | 72 +++++++++++++++++++ .../translation/locales/en/translation.json | 3 +- 6 files changed, 172 insertions(+), 8 deletions(-) create mode 100644 apps/frontend/src/components/launches/separate.post.tsx diff --git a/apps/backend/src/api/routes/posts.controller.ts b/apps/backend/src/api/routes/posts.controller.ts index 32d1ff52..7e91f4fe 100644 --- a/apps/backend/src/api/routes/posts.controller.ts +++ b/apps/backend/src/api/routes/posts.controller.ts @@ -179,4 +179,12 @@ export class PostsController { ) { return this._postsService.changeDate(org.id, id, date); } + + @Post('/separate-posts') + async separatePosts( + @GetOrgFromRequest() org: Organization, + @Body() body: { content: string, len: number } + ) { + return this._postsService.separatePosts(body.content, body.len); + } } diff --git a/apps/frontend/src/components/launches/providers/high.order.provider.tsx b/apps/frontend/src/components/launches/providers/high.order.provider.tsx index d87f5d3b..a22c2ed9 100644 --- a/apps/frontend/src/components/launches/providers/high.order.provider.tsx +++ b/apps/frontend/src/components/launches/providers/high.order.provider.tsx @@ -46,6 +46,8 @@ import { InternalChannels } from '@gitroom/frontend/components/launches/internal import { MergePost } from '@gitroom/frontend/components/launches/merge.post'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; import { useSet } from '@gitroom/frontend/components/launches/set.context'; +import { SeparatePost } from '@gitroom/frontend/components/launches/separate.post'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; // Simple component to change back to settings on after changing tab export const SetTab: FC<{ @@ -177,7 +179,8 @@ export const withProvider = function ( // this is a smart function, it updates the global value without updating the states (too heavy) and set the settings validation const form = useValues( set?.set - ? set?.set?.posts?.find((p) => p?.integration?.id === props?.id)?.settings + ? set?.set?.posts?.find((p) => p?.integration?.id === props?.id) + ?.settings : existingData.settings, props.id, props.identifier, @@ -201,6 +204,7 @@ export const withProvider = function ( }, [InPlaceValue] ); + const merge = useCallback(() => { setInPlaceValue( InPlaceValue.reduce( @@ -222,6 +226,20 @@ export const withProvider = function ( ) ); }, [InPlaceValue]); + + const separatePosts = useCallback( + (posts: string[]) => { + setInPlaceValue( + posts.map((p, i) => ({ + content: p, + id: InPlaceValue?.[i]?.id || makeId(10), + image: InPlaceValue?.[i]?.image || [], + })) + ); + }, + [InPlaceValue] + ); + const changeImage = useCallback( (index: number) => (newValue: { @@ -602,11 +620,25 @@ export const withProvider = function ( ))} - {InPlaceValue.length > 1 && ( +
+ {InPlaceValue.length > 1 && ( +
+ +
+ )}
- + p.content)} + len={ + typeof maximumCharacters === 'number' + ? maximumCharacters + : 10000 + } + merge={separatePosts} + />
- )} +
, document.querySelector('#renderEditor')! diff --git a/apps/frontend/src/components/launches/separate.post.tsx b/apps/frontend/src/components/launches/separate.post.tsx new file mode 100644 index 00000000..b83c9bf5 --- /dev/null +++ b/apps/frontend/src/components/launches/separate.post.tsx @@ -0,0 +1,45 @@ +import { Button } from '@gitroom/react/form/button'; +import { deleteDialog } from '@gitroom/react/helpers/delete.dialog'; +import { FC, useCallback } from 'react'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +export const SeparatePost: FC<{ + posts: string[]; + len: number; + merge: (posts: string[]) => void; + changeLoading: (loading: boolean) => void; +}> = (props) => { + const { len, posts } = props; + const t = useT(); + const fetch = useFetch(); + + const notReversible = useCallback(async () => { + if ( + await deleteDialog( + 'Are you sure you want to separate all posts? This action is not reversible.', + 'Yes' + ) + ) { + props.changeLoading(true); + const merge = props.posts.join('\n'); + const { posts } = await ( + await fetch('/posts/separate-posts', { + method: 'POST', + body: JSON.stringify({ + content: merge, + len: props.len, + }), + }) + ).json(); + + props.merge(posts); + props.changeLoading(false); + } + }, [len, posts]); + + return ( + + ); +}; diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts index 167daaac..7455b84c 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts @@ -28,6 +28,7 @@ import axios from 'axios'; import sharp from 'sharp'; import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory'; import { Readable } from 'stream'; +import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service'; dayjs.extend(utc); type PostWithConditionals = Post & { @@ -48,7 +49,8 @@ export class PostsService { private _integrationService: IntegrationService, private _mediaService: MediaService, private _shortLinkService: ShortLinkService, - private _webhookService: WebhooksService + private _webhookService: WebhooksService, + private openaiService: OpenaiService, ) {} async getStatistics(orgId: string, id: string) { @@ -570,7 +572,7 @@ export class PostsService { } } - private async postArticle(integration: Integration, posts: Post[]) { + private async postArticle(integration: Integration, posts: Post[]): Promise { const getIntegration = this._integrationManager.getArticlesIntegration( integration.providerIdentifier ); @@ -652,7 +654,7 @@ export class PostsService { return messageModel; } - async createPost(orgId: string, body: CreatePostDto) { + async createPost(orgId: string, body: CreatePostDto): Promise { const postList = []; for (const post of body.posts) { const messages = post.value.map((p) => p.content); @@ -727,6 +729,10 @@ export class PostsService { return postList; } + async separatePosts(content: string, len: number) { + return this.openaiService.separatePosts(content, len); + } + async changeDate(orgId: string, id: string, date: string) { const getPostById = await this._postRepository.getPostById(id, orgId); if ( diff --git a/libraries/nestjs-libraries/src/openai/openai.service.ts b/libraries/nestjs-libraries/src/openai/openai.service.ts index 876f5102..1ccdbfd8 100644 --- a/libraries/nestjs-libraries/src/openai/openai.service.ts +++ b/libraries/nestjs-libraries/src/openai/openai.service.ts @@ -125,4 +125,76 @@ export class OpenaiService { return this.generatePosts(articleContent!); } + + async separatePosts(content: string, len: number) { + const SeparatePostsPrompt = z.object({ + posts: z.array(z.string()), + }); + + const SeparatePostPrompt = z.object({ + post: z.string().max(len), + }); + + const posts = + ( + await openai.beta.chat.completions.parse({ + model: 'gpt-4.1', + messages: [ + { + role: 'system', + content: `You are an assistant that take a social media post and break it to a thread, each post must be minimum ${len - 10} and maximum ${len} characters, keeping the exact wording and break lines, however make sure you split posts based on context`, + }, + { + role: 'user', + content: content, + }, + ], + response_format: zodResponseFormat( + SeparatePostsPrompt, + 'separatePosts' + ), + }) + ).choices[0].message.parsed?.posts || []; + + return { + posts: await Promise.all( + posts.map(async (post) => { + if (post.length <= len) { + return post; + } + + let retries = 4; + while (retries) { + try { + return ( + ( + await openai.beta.chat.completions.parse({ + model: 'gpt-4.1', + messages: [ + { + role: 'system', + content: `You are an assistant that take a social media post and shrink it to be maximum ${len} characters, keeping the exact wording and break lines`, + }, + { + role: 'user', + content: post, + }, + ], + response_format: zodResponseFormat( + SeparatePostPrompt, + 'separatePost' + ), + }) + ).choices[0].message.parsed?.post || '' + ); + } catch (e) { + retries--; + } + } + + return post; + }) + ), + }; + } } diff --git a/libraries/react-shared-libraries/src/translation/locales/en/translation.json b/libraries/react-shared-libraries/src/translation/locales/en/translation.json index aa1019b3..33da524e 100644 --- a/libraries/react-shared-libraries/src/translation/locales/en/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/en/translation.json @@ -484,5 +484,6 @@ "change_language": "Change Language", "that_a_wrap": "That's a wrap!\n\nIf you enjoyed this thread:\n\n1. Follow me @{{username}} for more of these\n2. RT the tweet below to share this thread with your audience\n", "post_as_images_carousel": "Post as images carousel", - "save_set": "Save Set" + "save_set": "Save Set", + "separate_post": "Separate post to multiple posts" }