diff --git a/apps/backend/src/api/api.module.ts b/apps/backend/src/api/api.module.ts index a56b8fd9..586dcd2f 100644 --- a/apps/backend/src/api/api.module.ts +++ b/apps/backend/src/api/api.module.ts @@ -31,8 +31,6 @@ import { Nowpayments } from '@gitroom/nestjs-libraries/crypto/nowpayments'; import { WebhookController } from '@gitroom/backend/api/routes/webhooks.controller'; import { SignatureController } from '@gitroom/backend/api/routes/signature.controller'; import { AutopostController } from '@gitroom/backend/api/routes/autopost.controller'; -import { McpService } from '@gitroom/nestjs-libraries/mcp/mcp.service'; -import { McpController } from '@gitroom/backend/api/routes/mcp.controller'; import { SetsController } from '@gitroom/backend/api/routes/sets.controller'; import { ThirdPartyController } from '@gitroom/backend/api/routes/third-party.controller'; import { MonitorController } from '@gitroom/backend/api/routes/monitor.controller'; @@ -63,7 +61,6 @@ const authenticatedController = [ StripeController, AuthController, PublicController, - McpController, MonitorController, ...authenticatedController, ], @@ -80,7 +77,6 @@ const authenticatedController = [ TrackService, ShortLinkService, Nowpayments, - McpService, ], get exports() { return [...this.imports, ...this.providers]; diff --git a/apps/backend/src/api/routes/copilot.controller.ts b/apps/backend/src/api/routes/copilot.controller.ts index 581343fb..6654a405 100644 --- a/apps/backend/src/api/routes/copilot.controller.ts +++ b/apps/backend/src/api/routes/copilot.controller.ts @@ -1,16 +1,41 @@ -import { Logger, Controller, Get, Post, Req, Res, Query } from '@nestjs/common'; +import { + Logger, + Controller, + Get, + Post, + Req, + Res, + Query, + Param, +} from '@nestjs/common'; import { CopilotRuntime, OpenAIAdapter, - copilotRuntimeNestEndpoint, + copilotRuntimeNodeHttpEndpoint, + copilotRuntimeNextJSAppRouterEndpoint, } from '@copilotkit/runtime'; import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request'; import { Organization } from '@prisma/client'; import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service'; +import { MastraAgent } from '@ag-ui/mastra'; +import { MastraService } from '@gitroom/nestjs-libraries/chat/mastra.service'; +import { Request, Response } from 'express'; +import { RuntimeContext } from '@mastra/core/di'; +import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability'; +import { AuthorizationActions, Sections } from '@gitroom/backend/services/auth/permissions/permission.exception.class'; + +export type ChannelsContext = { + integrations: string; + organization: string; + ui: string; +}; @Controller('/copilot') export class CopilotController { - constructor(private _subscriptionService: SubscriptionService) {} + constructor( + private _subscriptionService: SubscriptionService, + private _mastraService: MastraService + ) {} @Post('/chat') chat(@Req() req: Request, @Res() res: Response) { if ( @@ -21,28 +46,112 @@ export class CopilotController { return; } - const copilotRuntimeHandler = copilotRuntimeNestEndpoint({ + const copilotRuntimeHandler = copilotRuntimeNodeHttpEndpoint({ endpoint: '/copilot/chat', runtime: new CopilotRuntime(), serviceAdapter: new OpenAIAdapter({ - model: - // @ts-ignore - req?.body?.variables?.data?.metadata?.requestType === - 'TextareaCompletion' - ? 'gpt-4o-mini' - : 'gpt-4.1', + model: 'gpt-4.1', }), }); - // @ts-ignore return copilotRuntimeHandler(req, res); } + @Post('/agent') + @CheckPolicies([AuthorizationActions.Create, Sections.AI]) + async agent( + @Req() req: Request, + @Res() res: Response, + @GetOrgFromRequest() organization: Organization + ) { + if ( + process.env.OPENAI_API_KEY === undefined || + process.env.OPENAI_API_KEY === '' + ) { + Logger.warn('OpenAI API key not set, chat functionality will not work'); + return; + } + const mastra = await this._mastraService.mastra(); + const runtimeContext = new RuntimeContext(); + runtimeContext.set( + 'integrations', + req?.body?.variables?.properties?.integrations || [] + ); + + runtimeContext.set('organization', JSON.stringify(organization)); + runtimeContext.set('ui', 'true'); + + const agents = MastraAgent.getLocalAgents({ + resourceId: organization.id, + mastra, + // @ts-ignore + runtimeContext, + }); + + const runtime = new CopilotRuntime({ + agents, + }); + + const copilotRuntimeHandler = copilotRuntimeNextJSAppRouterEndpoint({ + endpoint: '/copilot/agent', + runtime, + // properties: req.body.variables.properties, + serviceAdapter: new OpenAIAdapter({ + model: 'gpt-4.1', + }), + }); + + return copilotRuntimeHandler.handleRequest(req, res); + } + @Get('/credits') calculateCredits( @GetOrgFromRequest() organization: Organization, - @Query('type') type: 'ai_images' | 'ai_videos', + @Query('type') type: 'ai_images' | 'ai_videos' ) { - return this._subscriptionService.checkCredits(organization, type || 'ai_images'); + return this._subscriptionService.checkCredits( + organization, + type || 'ai_images' + ); + } + + @Get('/:thread/list') + @CheckPolicies([AuthorizationActions.Create, Sections.AI]) + async getMessagesList( + @GetOrgFromRequest() organization: Organization, + @Param('thread') threadId: string + ): Promise { + const mastra = await this._mastraService.mastra(); + const memory = await mastra.getAgent('postiz').getMemory(); + try { + return await memory.query({ + resourceId: organization.id, + threadId, + }); + } catch (err) { + return { messages: [] }; + } + } + + @Get('/list') + @CheckPolicies([AuthorizationActions.Create, Sections.AI]) + async getList(@GetOrgFromRequest() organization: Organization) { + const mastra = await this._mastraService.mastra(); + // @ts-ignore + const memory = await mastra.getAgent('postiz').getMemory(); + const list = await memory.getThreadsByResourceIdPaginated({ + resourceId: organization.id, + perPage: 100000, + page: 0, + orderBy: 'createdAt', + sortDirection: 'DESC', + }); + + return { + threads: list.threads.map((p) => ({ + id: p.id, + title: p.title, + })), + }; } } diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts index 7ae2136d..c4b1ff50 100644 --- a/apps/backend/src/api/routes/integrations.controller.ts +++ b/apps/backend/src/api/routes/integrations.controller.ts @@ -16,7 +16,6 @@ import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integ import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service'; import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request'; import { Organization, User } from '@prisma/client'; -import { ApiKeyDto } from '@gitroom/nestjs-libraries/dtos/integrations/api.key.dto'; import { IntegrationFunctionDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.function.dto'; import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability'; import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing'; diff --git a/apps/backend/src/api/routes/mcp.controller.ts b/apps/backend/src/api/routes/mcp.controller.ts deleted file mode 100644 index 6e466b11..00000000 --- a/apps/backend/src/api/routes/mcp.controller.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { - Body, - Controller, - HttpException, - Param, - Post, - Sse, -} from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; -import { McpService } from '@gitroom/nestjs-libraries/mcp/mcp.service'; -import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service'; - -@ApiTags('Mcp') -@Controller('/mcp') -export class McpController { - constructor( - private _mcpService: McpService, - private _organizationService: OrganizationService - ) {} - - @Sse('/:api/sse') - async sse(@Param('api') api: string) { - const apiModel = await this._organizationService.getOrgByApiKey(api); - if (!apiModel) { - throw new HttpException('Invalid url', 400); - } - - return await this._mcpService.runServer(api, apiModel.id); - } - - @Post('/:api/messages') - async post(@Param('api') api: string, @Body() body: any) { - const apiModel = await this._organizationService.getOrgByApiKey(api); - if (!apiModel) { - throw new HttpException('Invalid url', 400); - } - - return this._mcpService.processPostBody(apiModel.id, body); - } -} diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index c5bbf8ae..fb6d7bd4 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -8,11 +8,11 @@ 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'; -import { McpModule } from '@gitroom/backend/mcp/mcp.module'; import { ThirdPartyModule } from '@gitroom/nestjs-libraries/3rdparties/thirdparty.module'; import { VideoModule } from '@gitroom/nestjs-libraries/videos/video.module'; -import { SentryModule } from "@sentry/nestjs/setup"; +import { SentryModule } from '@sentry/nestjs/setup'; import { FILTER } from '@gitroom/nestjs-libraries/sentry/sentry.exception'; +import { ChatModule } from '@gitroom/nestjs-libraries/chat/chat.module'; @Global() @Module({ @@ -23,9 +23,9 @@ import { FILTER } from '@gitroom/nestjs-libraries/sentry/sentry.exception'; ApiModule, PublicApiModule, AgentModule, - McpModule, ThirdPartyModule, VideoModule, + ChatModule, ThrottlerModule.forRoot([ { ttl: 3600000, @@ -43,7 +43,7 @@ import { FILTER } from '@gitroom/nestjs-libraries/sentry/sentry.exception'; { provide: APP_GUARD, useClass: PoliciesGuard, - } + }, ], exports: [ BullMqModule, @@ -51,8 +51,8 @@ import { FILTER } from '@gitroom/nestjs-libraries/sentry/sentry.exception'; ApiModule, PublicApiModule, AgentModule, - McpModule, ThrottlerModule, + ChatModule, ], }) export class AppModule {} diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 6b2a9acd..776ef144 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -1,4 +1,5 @@ import { loadSwagger } from '@gitroom/helpers/swagger/load.swagger'; +import { json } from 'express'; process.env.TZ = 'UTC'; @@ -13,12 +14,14 @@ initializeSentry('backend', true); import { SubscriptionExceptionFilter } from '@gitroom/backend/services/auth/permissions/subscription.exception'; import { HttpExceptionFilter } from '@gitroom/nestjs-libraries/services/exception.filter'; import { ConfigurationChecker } from '@gitroom/helpers/configuration/configuration.checker'; +import { startMcp } from '@gitroom/nestjs-libraries/chat/start.mcp'; async function bootstrap() { const app = await NestFactory.create(AppModule, { rawBody: true, cors: { ...(!process.env.NOT_SECURED ? { credentials: true } : {}), + allowedHeaders: ['Content-Type', 'Authorization'], exposedHeaders: [ 'reload', 'onboarding', @@ -27,17 +30,24 @@ async function bootstrap() { ], origin: [ process.env.FRONTEND_URL, + 'http://localhost:6274', ...(process.env.MAIN_URL ? [process.env.MAIN_URL] : []), ], }, }); + await startMcp(app); + app.useGlobalPipes( new ValidationPipe({ transform: true, }) ); + app.use('/copilot', (req: any, res: any, next: any) => { + json({ limit: '50mb' })(req, res, next); + }); + app.use(cookieParser()); app.useGlobalFilters(new SubscriptionExceptionFilter()); app.useGlobalFilters(new HttpExceptionFilter()); diff --git a/apps/backend/src/mcp/main.mcp.ts b/apps/backend/src/mcp/main.mcp.ts deleted file mode 100644 index 83a05ade..00000000 --- a/apps/backend/src/mcp/main.mcp.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { McpTool } from '@gitroom/nestjs-libraries/mcp/mcp.tool'; -import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service'; -import { string, array, enum as eenum, object, boolean } from 'zod'; -import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; -import dayjs from 'dayjs'; -import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service'; - -@Injectable() -export class MainMcp { - constructor( - private _integrationService: IntegrationService, - private _postsService: PostsService, - private _openAiService: OpenaiService - ) {} - - @McpTool({ toolName: 'POSTIZ_GET_CONFIG_ID' }) - async preRun() { - return [ - { - type: 'text', - text: `id: ${makeId(10)} Today date is ${dayjs.utc().format()}`, - }, - ]; - } - - @McpTool({ toolName: 'POSTIZ_PROVIDERS_LIST' }) - async listOfProviders(organization: string) { - const list = ( - await this._integrationService.getIntegrationsList(organization) - ).map((org) => ({ - id: org.id, - name: org.name, - identifier: org.providerIdentifier, - picture: org.picture, - disabled: org.disabled, - profile: org.profile, - customer: org.customer - ? { - id: org.customer.id, - name: org.customer.name, - } - : undefined, - })); - - return [{ type: 'text', text: JSON.stringify(list) }]; - } - - @McpTool({ - toolName: 'POSTIZ_SCHEDULE_POST', - zod: { - type: eenum(['draft', 'schedule']), - configId: string(), - generatePictures: boolean(), - date: string().describe('UTC TIME'), - providerId: string().describe('Use POSTIZ_PROVIDERS_LIST to get the id'), - posts: array(object({ text: string(), images: array(string()) })), - }, - }) - async schedulePost( - organization: string, - obj: { - type: 'draft' | 'schedule'; - generatePictures: boolean; - date: string; - providerId: string; - posts: { text: string }[]; - } - ) { - const create = await this._postsService.createPost(organization, { - date: obj.date, - type: obj.type, - tags: [], - shortLink: false, - posts: [ - { - group: makeId(10), - value: await Promise.all( - obj.posts.map(async (post) => ({ - content: post.text, - id: makeId(10), - image: !obj.generatePictures - ? [] - : [ - { - id: makeId(10), - path: await this._openAiService.generateImage( - post.text, - true - ), - }, - ], - })) - ), - settings: { - __type: 'any' as any, - }, - integration: { - id: obj.providerId, - }, - }, - ], - }); - - return [ - { - type: 'text', - text: `Post created successfully, check it here: ${process.env.FRONTEND_URL}/p/${create[0].postId}`, - }, - ]; - } -} diff --git a/apps/backend/src/mcp/mcp.module.ts b/apps/backend/src/mcp/mcp.module.ts deleted file mode 100644 index 81c16e8f..00000000 --- a/apps/backend/src/mcp/mcp.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Global, Module } from '@nestjs/common'; -import { MainMcp } from '@gitroom/backend/mcp/main.mcp'; - -@Global() -@Module({ - imports: [], - controllers: [], - providers: [MainMcp], - get exports() { - return [...this.providers]; - }, -}) -export class McpModule {} diff --git a/apps/backend/src/services/auth/auth.middleware.ts b/apps/backend/src/services/auth/auth.middleware.ts index 0ef8377a..3bb3fea5 100644 --- a/apps/backend/src/services/auth/auth.middleware.ts +++ b/apps/backend/src/services/auth/auth.middleware.ts @@ -6,6 +6,7 @@ import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/o import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service'; import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management'; import { HttpForbiddenException } from '@gitroom/nestjs-libraries/services/exception.filter'; +import { MastraService } from '@gitroom/nestjs-libraries/chat/mastra.service'; export const removeAuth = (res: Response) => { res.cookie('auth', '', { @@ -20,7 +21,6 @@ export const removeAuth = (res: Response) => { expires: new Date(0), maxAge: -1, }); - res.header('logout', 'true'); }; diff --git a/apps/frontend/src/app/(app)/(site)/agents/[id]/page.tsx b/apps/frontend/src/app/(app)/(site)/agents/[id]/page.tsx new file mode 100644 index 00000000..dad5a9dc --- /dev/null +++ b/apps/frontend/src/app/(app)/(site)/agents/[id]/page.tsx @@ -0,0 +1,12 @@ +import { Metadata } from 'next'; +import { Agent } from '@gitroom/frontend/components/agents/agent'; +import { AgentChat } from '@gitroom/frontend/components/agents/agent.chat'; +export const metadata: Metadata = { + title: 'Postiz - Agent', + description: '', +}; +export default async function Page() { + return ( + + ); +} diff --git a/apps/frontend/src/app/(app)/(site)/agents/layout.tsx b/apps/frontend/src/app/(app)/(site)/agents/layout.tsx new file mode 100644 index 00000000..cc3dac8f --- /dev/null +++ b/apps/frontend/src/app/(app)/(site)/agents/layout.tsx @@ -0,0 +1,13 @@ +import { Metadata } from 'next'; +import { Agent } from '@gitroom/frontend/components/agents/agent'; +export const metadata: Metadata = { + title: 'Postiz - Agent', + description: '', +}; +export default async function Layout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/apps/frontend/src/app/(app)/(site)/agents/page.tsx b/apps/frontend/src/app/(app)/(site)/agents/page.tsx new file mode 100644 index 00000000..bc1005ea --- /dev/null +++ b/apps/frontend/src/app/(app)/(site)/agents/page.tsx @@ -0,0 +1,11 @@ +import { Metadata } from 'next'; +import { redirect } from 'next/navigation'; + +export const metadata: Metadata = { + title: 'Postiz - Agent', + description: '', +}; + +export default async function Page() { + return redirect('/agents/new'); +} diff --git a/apps/frontend/src/app/global.scss b/apps/frontend/src/app/global.scss index 3b446329..75993ff0 100644 --- a/apps/frontend/src/app/global.scss +++ b/apps/frontend/src/app/global.scss @@ -678,4 +678,29 @@ html[dir='rtl'] [dir='ltr'] { } .blur-xs { filter: blur(4px); +} + +.agent { + .copilotKitInputContainer { + padding: 0 24px !important; + } + + .copilotKitInput { + width: 100% !important; + } +} +.rm-bg .b2 { + padding-top: 0 !important; +} +.rm-bg .b1 { + background: transparent !important; + gap: 0 !important; +} + +.copilotKitMessage img { + width: 200px; +} + +.copilotKitMessage a { + color: var(--new-btn-text) !important; } \ No newline at end of file diff --git a/apps/frontend/src/components/agents/agent.chat.tsx b/apps/frontend/src/components/agents/agent.chat.tsx new file mode 100644 index 00000000..2f8aa0a6 --- /dev/null +++ b/apps/frontend/src/components/agents/agent.chat.tsx @@ -0,0 +1,359 @@ +'use client'; + +import React, { + FC, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { CopilotChat, CopilotKitCSSProperties } from '@copilotkit/react-ui'; +import { + InputProps, + UserMessageProps, +} from '@copilotkit/react-ui/dist/components/chat/props'; +import { Input } from '@gitroom/frontend/components/agents/agent.input'; +import { useModals } from '@gitroom/frontend/components/layout/new-modal'; +import { + CopilotKit, + useCopilotAction, + useCopilotMessagesContext, +} from '@copilotkit/react-core'; +import { + MediaPortal, + PropertiesContext, +} from '@gitroom/frontend/components/agents/agent'; +import { useVariables } from '@gitroom/react/helpers/variable.context'; +import { useParams } from 'next/navigation'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import { TextMessage } from '@copilotkit/runtime-client-gql'; +import { AddEditModal } from '@gitroom/frontend/components/new-launch/add.edit.modal'; +import dayjs from 'dayjs'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { ExistingDataContextProvider } from '@gitroom/frontend/components/launches/helpers/use.existing.data'; + +export const AgentChat: FC = () => { + const { backendUrl } = useVariables(); + const params = useParams<{ id: string }>(); + const { properties } = useContext(PropertiesContext); + + return ( + + + +
+
+ > Public API +`, + }} + UserMessage={Message} + Input={NewInput} + /> +
+
+
+ ); +}; + +const LoadMessages: FC<{ id: string }> = ({ id }) => { + const { setMessages } = useCopilotMessagesContext(); + const fetch = useFetch(); + + const loadMessages = useCallback(async (idToSet: string) => { + const data = await (await fetch(`/copilot/${idToSet}/list`)).json(); + setMessages( + data.uiMessages.map((p: any) => { + return new TextMessage({ + content: p.content, + role: p.role, + }); + }) + ); + }, []); + + useEffect(() => { + if (id === 'new') { + setMessages([]); + return; + } + loadMessages(id); + }, [id]); + + return null; +}; + +const Message: FC = (props) => { + const convertContentToImagesAndVideo = useMemo(() => { + return (props.message?.content || '') + .replace(/Video: (http.*mp4\n)/g, (match, p1) => { + return ``; + }) + .replace(/Image: (http.*\n)/g, (match, p1) => { + return ``; + }) + .replace(/\[\-\-Media\-\-\](.*)\[\-\-Media\-\-\]/g, (match, p1) => { + return `
${p1}
`; + }) + .replace( + /(\[--integrations--\][\s\S]*?\[--integrations--\])/g, + (match, p1) => { + return ``; + } + ); + }, [props.message?.content]); + return ( +
+ ); +}; +const NewInput: FC = (props) => { + const [media, setMedia] = useState([] as { path: string; id: string }[]); + const [value, setValue] = useState(''); + const { properties } = useContext(PropertiesContext); + return ( + <> + setMedia(e.target.value)} + /> + { + const send = props.onSend( + text + + (media.length > 0 + ? '\n[--Media--]' + + media + .map((m) => + m.path.indexOf('mp4') > -1 + ? `Video: ${m.path}` + : `Image: ${m.path}` + ) + .join('\n') + + '\n[--Media--]' + : '') + + ` +${ + properties.length + ? `[--integrations--] +Use the following social media platforms: ${JSON.stringify( + properties.map((p) => ({ + id: p.id, + platform: p.identifier, + profilePicture: p.picture, + additionalSettings: p.additionalSettings, + })) + )} +[--integrations--]` + : `` +}` + ); + setValue(''); + setMedia([]); + return send; + }} + /> + + ); +}; + +export const Hooks: FC = () => { + const modals = useModals(); + + useCopilotAction({ + name: 'manualPosting', + description: + 'This tool should be triggered when the user wants to manually add the generated post', + parameters: [ + { + name: 'list', + type: 'object[]', + description: + 'list of posts to schedule to different social media (integration ids)', + attributes: [ + { + name: 'integrationId', + type: 'string', + description: 'The integration id', + }, + { + name: 'date', + type: 'string', + description: 'UTC date of the scheduled post', + }, + { + name: 'settings', + type: 'object', + description: 'Settings for the integration [input:settings]', + }, + { + name: 'posts', + type: 'object[]', + description: 'list of posts / comments (one under another)', + attributes: [ + { + name: 'content', + type: 'string', + description: 'the content of the post', + }, + { + name: 'attachments', + type: 'object[]', + description: 'list of attachments', + attributes: [ + { + name: 'id', + type: 'string', + description: 'id of the attachment', + }, + { + name: 'path', + type: 'string', + description: 'url of the attachment', + }, + ], + }, + ], + }, + ], + }, + ], + renderAndWaitForResponse: ({ args, status, respond }) => { + if (status === 'executing') { + return ; + } + + return null; + }, + }); + return null; +}; + +const OpenModal: FC<{ + respond: (value: any) => void; + args: { + list: { + integrationId: string; + date: string; + settings?: Record; + posts: { content: string; attachments: { id: string; path: string }[] }[]; + }[]; + }; +}> = ({ args, respond }) => { + const modals = useModals(); + const { properties } = useContext(PropertiesContext); + const startModal = useCallback(async () => { + for (const integration of args.list) { + await new Promise((res) => { + const group = makeId(10); + modals.openModal({ + id: 'add-edit-modal', + closeOnClickOutside: false, + removeLayout: true, + closeOnEscape: false, + withCloseButton: false, + askClose: true, + size: '80%', + title: ``, + classNames: { + modal: 'w-[100%] max-w-[1400px] text-textColor', + }, + children: ( + p.id === integration.integrationId) + .picture || '', + settings: integration.settings || {}, + posts: integration.posts.map((p) => ({ + approvedSubmitForOrder: 'NO', + content: p.content, + createdAt: new Date().toISOString(), + state: 'DRAFT', + id: makeId(10), + settings: JSON.stringify(integration.settings || {}), + group, + integrationId: integration.integrationId, + integration: properties.find( + (p) => p.id === integration.integrationId + ), + publishDate: dayjs.utc(integration.date).toISOString(), + image: p.attachments.map((a) => ({ + id: a.id, + path: a.path, + })), + })), + }} + > + p.id === integration.integrationId + )} + onlyValues={integration.posts.map((p) => ({ + content: p.content, + id: makeId(10), + settings: integration.settings || {}, + image: p.attachments.map((a) => ({ + id: a.id, + path: a.path, + })), + }))} + reopenModal={() => {}} + mutate={() => res(true)} + /> + + ), + }); + }); + } + + respond('User scheduled all the posts'); + }, [args, respond, properties]); + + useEffect(() => { + startModal(); + }, []); + return ( +
respond('continue')}> + Opening manually ${JSON.stringify(args)} +
+ ); +}; diff --git a/apps/frontend/src/components/agents/agent.input.tsx b/apps/frontend/src/components/agents/agent.input.tsx new file mode 100644 index 00000000..85d22b9b --- /dev/null +++ b/apps/frontend/src/components/agents/agent.input.tsx @@ -0,0 +1,120 @@ +import React, { useMemo, useRef, useState } from 'react'; +import { useCopilotContext, useCopilotReadable } from '@copilotkit/react-core'; +import AutoResizingTextarea from '@gitroom/frontend/components/agents/agent.textarea'; +import { useChatContext } from '@copilotkit/react-ui'; +import { InputProps } from '@copilotkit/react-ui/dist/components/chat/props'; +const MAX_NEWLINES = 6; + +export const Input = ({ + inProgress, + onSend, + isVisible = false, + onStop, + onUpload, + hideStopButton = false, + onChange, +}: InputProps & { onChange: (value: string) => void }) => { + const context = useChatContext(); + const copilotContext = useCopilotContext(); + const showPoweredBy = !copilotContext.copilotApiConfig?.publicApiKey; + + const textareaRef = useRef(null); + const [isComposing, setIsComposing] = useState(false); + + const handleDivClick = (event: React.MouseEvent) => { + const target = event.target as HTMLElement; + + // If the user clicked a button or inside a button, don't focus the textarea + if (target.closest('button')) return; + + // If the user clicked the textarea, do nothing (it's already focused) + if (target.tagName === 'TEXTAREA') return; + + // Otherwise, focus the textarea + textareaRef.current?.focus(); + }; + + const [text, setText] = useState(''); + const send = () => { + if (inProgress) return; + onSend(text); + setText(''); + + textareaRef.current?.focus(); + }; + + const isInProgress = inProgress; + const buttonIcon = + isInProgress && !hideStopButton + ? context.icons.stopIcon + : context.icons.sendIcon; + + const canSend = useMemo(() => { + const interruptEvent = copilotContext.langGraphInterruptAction?.event; + const interruptInProgress = + interruptEvent?.name === 'LangGraphInterruptEvent' && + !interruptEvent?.response; + + return !isInProgress && text.trim().length > 0 && !interruptInProgress; + }, [copilotContext.langGraphInterruptAction?.event, isInProgress, text]); + + const canStop = useMemo(() => { + return isInProgress && !hideStopButton; + }, [isInProgress, hideStopButton]); + + const sendDisabled = !canSend && !canStop; + + return ( +
+
+ { + onChange(event.target.value); + setText(event.target.value); + }} + onCompositionStart={() => setIsComposing(true)} + onCompositionEnd={() => setIsComposing(false)} + onKeyDown={(event) => { + if (event.key === 'Enter' && !event.shiftKey && !isComposing) { + event.preventDefault(); + if (canSend) { + send(); + } + } + }} + /> +
+ {onUpload && ( + + )} + +
+ +
+
+
+ ); +}; diff --git a/apps/frontend/src/components/agents/agent.textarea.tsx b/apps/frontend/src/components/agents/agent.textarea.tsx new file mode 100644 index 00000000..ad1f9e1e --- /dev/null +++ b/apps/frontend/src/components/agents/agent.textarea.tsx @@ -0,0 +1,77 @@ +import React, { useState, useRef, useEffect, forwardRef, useImperativeHandle } from "react"; + +interface AutoResizingTextareaProps { + maxRows?: number; + placeholder?: string; + value: string; + onChange: (event: React.ChangeEvent) => void; + onKeyDown?: (event: React.KeyboardEvent) => void; + onCompositionStart?: () => void; + onCompositionEnd?: () => void; + autoFocus?: boolean; +} + +const AutoResizingTextarea = forwardRef( + ( + { + maxRows = 1, + placeholder, + value, + onChange, + onKeyDown, + onCompositionStart, + onCompositionEnd, + autoFocus, + }, + ref, + ) => { + const internalTextareaRef = useRef(null); + const [maxHeight, setMaxHeight] = useState(0); + + useImperativeHandle(ref, () => internalTextareaRef.current as HTMLTextAreaElement); + + useEffect(() => { + const calculateMaxHeight = () => { + const textarea = internalTextareaRef.current; + if (textarea) { + textarea.style.height = "auto"; + const singleRowHeight = textarea.scrollHeight; + setMaxHeight(singleRowHeight * maxRows); + if (autoFocus) { + textarea.focus(); + } + } + }; + + calculateMaxHeight(); + }, [maxRows]); + + useEffect(() => { + const textarea = internalTextareaRef.current; + if (textarea) { + textarea.style.height = "auto"; + textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`; + } + }, [value, maxHeight]); + + return ( +