feat: separate posts with ai

This commit is contained in:
Nevo David 2025-06-10 19:46:21 +07:00
parent fd9bfecb65
commit 3a31da4b41
6 changed files with 172 additions and 8 deletions

View File

@ -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);
}
}

View File

@ -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 <T extends object>(
// 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 <T extends object>(
},
[InPlaceValue]
);
const merge = useCallback(() => {
setInPlaceValue(
InPlaceValue.reduce(
@ -222,6 +226,20 @@ export const withProvider = function <T extends object>(
)
);
}, [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 <T extends object>(
</div>
</Fragment>
))}
{InPlaceValue.length > 1 && (
<div className="flex gap-[4px]">
{InPlaceValue.length > 1 && (
<div>
<MergePost merge={merge} />
</div>
)}
<div>
<MergePost merge={merge} />
<SeparatePost
changeLoading={setUploading}
posts={InPlaceValue.map((p) => p.content)}
len={
typeof maximumCharacters === 'number'
? maximumCharacters
: 10000
}
merge={separatePosts}
/>
</div>
)}
</div>
</div>
</EditorWrapper>,
document.querySelector('#renderEditor')!

View File

@ -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 (
<Button className="!h-[30px] !text-sm !bg-red-800" onClick={notReversible}>
{t('separate_post', 'Separate post to multiple posts')}
</Button>
);
};

View File

@ -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<any> {
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<any[]> {
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 (

View File

@ -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;
})
),
};
}
}

View File

@ -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"
}