diff --git a/.github/workflows/build.yaml b/.github/workflows/build similarity index 100% rename from .github/workflows/build.yaml rename to .github/workflows/build diff --git a/.github/workflows/build-pr-temp.yml b/.github/workflows/build-pr similarity index 94% rename from .github/workflows/build-pr-temp.yml rename to .github/workflows/build-pr index e5c45d59..513fe803 100644 --- a/.github/workflows/build-pr-temp.yml +++ b/.github/workflows/build-pr @@ -13,6 +13,9 @@ jobs: node-version: ['20.17.0'] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + environment: + name: build-pr + steps: - uses: actions/checkout@v4 with: @@ -60,9 +63,6 @@ jobs: - name: SonarQube Analysis (Pull Request) uses: SonarSource/sonarqube-scan-action@v6 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} with: args: > -Dsonar.projectVersion=${{ steps.get_version.outputs.tag }} diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml deleted file mode 100644 index 4cbfe2b2..00000000 --- a/.github/workflows/build-pr.yml +++ /dev/null @@ -1,75 +0,0 @@ ---- -name: Build - -on: - pull_request_target: - -jobs: - build: - runs-on: ubuntu-latest - - environment: - name: build-pr - - strategy: - matrix: - node-version: ['20.17.0'] - # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha }} - - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - - - name: Install pnpm - uses: pnpm/action-setup@v2 - with: - version: 8 - run_install: false - - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - - name: Setup pnpm cache - uses: actions/cache@v4 - with: - path: | - ${{ env.STORE_PATH }} - ${{ github.workspace }}/.next/cache - key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }} - restore-keys: | - ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}- - - - name: Install dependencies - run: pnpm install - - - name: Build - run: pnpm run build - - - name: Get Commit SHA (short) - id: get_version - run: | - # Get the short 8-character commit SHA - VERSION=$(git rev-parse --short=8 HEAD) - echo "Commit SHA is $VERSION" - echo "tag=$VERSION" >> $GITHUB_OUTPUT - - - name: SonarQube Analysis (Pull Request) - uses: SonarSource/sonarqube-scan-action@v6 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} - with: - args: > - -Dsonar.projectVersion=${{ steps.get_version.outputs.tag }} - -Dsonar.pullrequest.key=${{ github.event.pull_request.number }} - -Dsonar.pullrequest.branch=${{ github.event.pull_request.head.ref }} - -Dsonar.pullrequest.base=${{ github.event.pull_request.base.ref }} diff --git a/Dockerfile.dev b/Dockerfile.dev index 72bb3654..80d439dc 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM node:20-alpine3.19 +FROM node:22.20-alpine ARG NEXT_PUBLIC_VERSION ENV NEXT_PUBLIC_VERSION=$NEXT_PUBLIC_VERSION RUN apk add --no-cache g++ make py3-pip bash nginx diff --git a/Jenkins/Build.Jenkinsfile b/Jenkins/Build.Jenkinsfile new file mode 100644 index 00000000..886ade0a --- /dev/null +++ b/Jenkins/Build.Jenkinsfile @@ -0,0 +1,96 @@ +// Declarative Pipeline for building Node.js application and running SonarQube analysis triggered by a push event. +pipeline { + // Defines the execution environment. Using 'agent any' to ensure an agent is available. + agent any + + // Global environment block removed to prevent Groovy scoping issues with manual path calculation. + + stages { + // Stage 1: Checkout the code (Relies on the initial SCM checkout done by Jenkins) + stage('Source Checkout') { + steps { + echo "Workspace already populated by the initial SCM checkout. Proceeding." + } + } + + // Stage 2: Setup Node.js v20 and install pnpm + stage('Setup Environment and Tools') { + steps { + sh ''' + echo "Ensuring required utilities and Node.js are installed..." + sudo apt-get update + sudo apt-get install -y curl unzip nodejs + + # 1. Install Node.js v20 + curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - + sudo apt-get install -y nodejs + echo "Node.js version: \$(node -v)" + + # 2. Install pnpm globally (version 8) + npm install -g pnpm@8 + echo "pnpm version: \$(pnpm -v)" + ''' + } + } + + // Stage 3: Install dependencies and build the application + stage('Install and Build') { + steps { + sh 'pnpm install' + sh 'pnpm run build' + } + } + + // Stage 4: Run SonarQube analysis: Install scanner, get version, and execute. + stage('SonarQube Analysis') { + steps { + script { + // 1. Get the short 8-character commit SHA for project versioning + def commitShaShort = sh(returnStdout: true, script: 'git rev-parse --short=8 HEAD').trim() + echo "Commit SHA (short) is: ${commitShaShort}" + + // --- 2. MANUALLY INSTALL THE SONAR SCANNER CLI LOCALLY IN THIS STAGE --- + sh """ + echo "Manually downloading and installing Sonar Scanner CLI..." + + # Download the stable scanner CLI package + curl -sS -o sonar-scanner.zip \ + "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-4.7.0.2747.zip" + + # Added -o flag to force overwrite and prevent interactive prompt failure + unzip -o -q sonar-scanner.zip -d . + """ + + // 3. Find the extracted directory name and capture the full absolute bin path in Groovy + // This is defined locally and used directly, avoiding environment variable issues. + def scannerBinPath = sh( + returnStdout: true, + script: ''' + SCANNER_DIR=$(find . -maxdepth 1 -type d -name "sonar-scanner*" | head -n 1) + # Get the full absolute path to the executable file + echo \$(pwd)/\${SCANNER_DIR}/bin/sonar-scanner + ''' + ).trim() + + echo "Scanner executable path captured: ${scannerBinPath}" + + // 4. Use withSonarQubeEnv to set up the secure variables (HOST and TOKEN) + withSonarQubeEnv(installationName: 'SonarQube-Server') { + // 5. Execute the scanner using the Groovy variable directly. + sh """ + echo "Starting SonarQube Analysis for project version: ${commitShaShort}" + + # Execute the full, absolute path captured in the Groovy variable. + '${scannerBinPath}' \\ + -Dsonar.projectVersion=${commitShaShort} \\ + -Dsonar.sources=. + + # SONAR_HOST_URL and SONAR_TOKEN are automatically passed as environment variables + # by the withSonarQubeEnv block. + """ + } + } + } + } + } +} diff --git a/Jenkins/BuildPR.Jenkinsfile b/Jenkins/BuildPR.Jenkinsfile new file mode 100644 index 00000000..6e279b0e --- /dev/null +++ b/Jenkins/BuildPR.Jenkinsfile @@ -0,0 +1,100 @@ +// Declarative Pipeline for building Node.js application and running SonarQube analysis for a Pull Request. +pipeline { + // Defines the execution environment. Using 'agent any' to ensure an agent is available. + agent any + + // Environment variables that hold PR details, provided by Jenkins Multibranch setup. + environment { + // FIX: Environment variables must be quoted or wrapped in a function call. + // We quote the 'env.CHANGE_ID' reference to fix the compilation error. + PR_KEY = "${env.CHANGE_ID}" + PR_BRANCH = "${env.CHANGE_BRANCH}" + PR_BASE = "${env.CHANGE_TARGET}" + } + + stages { + // Stage 1: Checkout the code (Relies on the initial SCM checkout done by Jenkins) + stage('Source Checkout') { + steps { + echo "Workspace already populated by the initial SCM checkout. Proceeding." + } + } + + // Stage 2: Setup Node.js v20, install pnpm, and install required tools (curl, unzip) + stage('Setup Environment and Tools') { + steps { + sh ''' + echo "Ensuring required utilities and Node.js are installed..." + sudo apt-get update + sudo apt-get install -y curl unzip nodejs + + # 1. Install Node.js v20 (closest matching the specified version '20.17.0') + curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - + sudo apt-get install -y nodejs + echo "Node.js version: \$(node -v)" + + # 2. Install pnpm globally (version 8) + npm install -g pnpm@8 + echo "pnpm version: \$(pnpm -v)" + ''' + } + } + + // Stage 3: Install dependencies and build the application + stage('Install and Build') { + steps { + sh 'pnpm install' + sh 'pnpm run build' + } + } + + // Stage 4: Run SonarQube PR analysis: Install scanner locally, get version, and execute. + stage('SonarQube Pull Request Analysis') { + steps { + script { + // 1. Get the short 8-character commit SHA for project versioning + def commitShaShort = sh(returnStdout: true, script: 'git rev-parse --short=8 HEAD').trim() + echo "Commit SHA (short) is: ${commitShaShort}" + + // --- 2. MANUALLY INSTALL THE SONAR SCANNER CLI LOCALLY --- + sh """ + echo "Manually downloading and installing Sonar Scanner CLI..." + curl -sS -o sonar-scanner.zip \ + "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-4.7.0.2747.zip" + unzip -o -q sonar-scanner.zip -d . + """ + + // 3. Find the extracted directory name and capture the full absolute executable path. + def scannerBinPath = sh( + returnStdout: true, + script: ''' + SCANNER_DIR=$(find . -maxdepth 1 -type d -name "sonar-scanner*" | head -n 1) + # Get the full absolute path to the executable file + echo \$(pwd)/\${SCANNER_DIR}/bin/sonar-scanner + ''' + ).trim() + + echo "Scanner executable path captured: ${scannerBinPath}" + + // 4. Use withSonarQubeEnv to set up the secure variables (HOST and TOKEN) + withSonarQubeEnv(installationName: 'SonarQube-Server') { + // 5. Execute the scanner using the Groovy variable directly with PR parameters. + sh """ + echo "Starting SonarQube Pull Request Analysis for PR #${PR_KEY}" + + '${scannerBinPath}' \\ + -Dsonar.projectVersion=${commitShaShort} \\ + -Dsonar.sources=. \\ + -Dsonar.pullrequest.key=${PR_KEY} \\ + -Dsonar.pullrequest.branch=${PR_BRANCH} \\ + -Dsonar.pullrequest.base=${PR_BASE} + + # SONAR_HOST_URL and SONAR_TOKEN are automatically passed as environment variables + # by the withSonarQubeEnv block. + """ + } + } + } + } + } +} diff --git a/Jenkinsfile b/Jenkinsfile deleted file mode 100644 index e7a614d0..00000000 --- a/Jenkinsfile +++ /dev/null @@ -1,70 +0,0 @@ -pipeline { - agent any - - environment { - NODE_VERSION = '20.17.0' - PR_NUMBER = "${env.CHANGE_ID}" // PR number comes from webhook payload - IMAGE_TAG="ghcr.io/gitroomhq/postiz-app-pr:${env.CHANGE_ID}" - } - - stages { - stage('Checkout Repository') { - steps { - checkout scm - } - } - - stage('Check Node.js and npm') { - steps { - script { - sh "node -v" - sh "npm -v" - } - } - } - - stage('Install Dependencies') { - steps { - sh 'npm ci' - } - } - - stage('Build Project') { - steps { - sh 'npm run build' - } - } - - stage('Build and Push Docker Image') { - when { - expression { return env.CHANGE_ID != null } // Only run if it's a PR - } - steps { - withCredentials([string(credentialsId: 'gh-pat', variable: 'GITHUB_PASS')]) { - // Docker login step - sh ''' - echo "$GITHUB_PASS" | docker login ghcr.io -u "egelhaus" --password-stdin - ''' - // Build Docker image - sh ''' - docker build -f Dockerfile.dev -t $IMAGE_TAG . - ''' - // Push Docker image to GitHub Container Registry - sh ''' - docker push $IMAGE_TAG - ''' - } - } - } - } - post { - success { - echo 'Build completed successfully!' - - } - failure { - echo 'Build failed!' - - } - } -} diff --git a/README.md b/README.md index d59b6be0..91350317 100644 --- a/README.md +++ b/README.md @@ -19,21 +19,6 @@

-
- - Quality Gate Status - - - Maintainability Rating - - - Reliability Rating - - - Security Rating - -
-

Your ultimate AI social media scheduling tool


diff --git a/apps/backend/package.json b/apps/backend/package.json index 3552b6f6..0b288478 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "dotenv -e ../../.env -- nest start --watch --entryFile=./apps/backend/src/main", "build": "cross-env NODE_ENV=production nest build", - "start": "dotenv -e ../../.env -- node ./dist/apps/backend/src/main.js", + "start": "dotenv -e ../../.env -- node --experimental-require-module ./dist/apps/backend/src/main.js", "pm2": "pm2 start pnpm --name backend -- start" }, "keywords": [], 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/agencies.controller.ts b/apps/backend/src/api/routes/agencies.controller.ts index e2849f96..5eb98c60 100644 --- a/apps/backend/src/api/routes/agencies.controller.ts +++ b/apps/backend/src/api/routes/agencies.controller.ts @@ -10,7 +10,7 @@ import { CreateAgencyDto } from '@gitroom/nestjs-libraries/dtos/agencies/create. export class AgenciesController { constructor(private _agenciesService: AgenciesService) {} @Get('/') - async getAgencyByUser(@GetUserFromRequest() user: User) { + async getAgencyByUsers(@GetUserFromRequest() user: User) { return (await this._agenciesService.getAgencyByUser(user)) || {}; } diff --git a/apps/backend/src/api/routes/auth.controller.ts b/apps/backend/src/api/routes/auth.controller.ts index f576f38f..338bf962 100644 --- a/apps/backend/src/api/routes/auth.controller.ts +++ b/apps/backend/src/api/routes/auth.controller.ts @@ -21,6 +21,7 @@ import { EmailService } from '@gitroom/nestjs-libraries/services/email.service'; import { RealIP } from 'nestjs-real-ip'; import { UserAgent } from '@gitroom/nestjs-libraries/user/user.agent'; import { Provider } from '@prisma/client'; +import * as Sentry from '@sentry/nestjs'; @ApiTags('Auth') @Controller('/auth') @@ -101,6 +102,7 @@ export class AuthController { } } + Sentry.metrics.count("new_user", 1); response.header('onboarding', 'true'); response.status(200).json({ register: true, diff --git a/apps/backend/src/api/routes/copilot.controller.ts b/apps/backend/src/api/routes/copilot.controller.ts index 581343fb..ffd61391 100644 --- a/apps/backend/src/api/routes/copilot.controller.ts +++ b/apps/backend/src/api/routes/copilot.controller.ts @@ -1,18 +1,43 @@ -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) { + chatAgent(@Req() req: Request, @Res() res: Response) { if ( process.env.OPENAI_API_KEY === undefined || process.env.OPENAI_API_KEY === '' @@ -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/api/routes/users.controller.ts b/apps/backend/src/api/routes/users.controller.ts index 601ba655..3d9dd968 100644 --- a/apps/backend/src/api/routes/users.controller.ts +++ b/apps/backend/src/api/routes/users.controller.ts @@ -199,6 +199,7 @@ export class UsersController { @Post('/logout') logout(@Res({ passthrough: true }) response: Response) { + response.header('logout', 'true'); response.cookie('auth', '', { domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!), ...(!process.env.NOT_SECURED 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..d1ee6fb7 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -1,4 +1,8 @@ +import { initializeSentry } from '@gitroom/nestjs-libraries/sentry/initialize.sentry'; +initializeSentry('backend', true); + import { loadSwagger } from '@gitroom/helpers/swagger/load.swagger'; +import { json } from 'express'; process.env.TZ = 'UTC'; @@ -7,37 +11,44 @@ import { Logger, ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; -import { initializeSentry } from '@gitroom/nestjs-libraries/sentry/initialize.sentry'; -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() { +async function start() { const app = await NestFactory.create(AppModule, { rawBody: true, cors: { ...(!process.env.NOT_SECURED ? { credentials: true } : {}), + allowedHeaders: ['Content-Type', 'Authorization', 'x-copilotkit-runtime-client-gql-version'], exposedHeaders: [ 'reload', 'onboarding', 'activate', + 'x-copilotkit-runtime-client-gql-version', ...(process.env.NOT_SECURED ? ['auth', 'showorg', 'impersonate'] : []), ], 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()); @@ -69,8 +80,8 @@ function checkConfiguration() { Logger.warn('Configuration issues found: ' + checker.getIssuesCount()); } else { - Logger.log('Configuration check completed without any issues.'); + Logger.log('Configuration check completed without any issues'); } } -bootstrap(); +start(); 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/public-api/routes/v1/public.integrations.controller.ts b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts index c70b7e91..2655f699 100644 --- a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts +++ b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts @@ -26,12 +26,17 @@ import { } from '@gitroom/backend/services/auth/permissions/permission.exception.class'; import { VideoDto } from '@gitroom/nestjs-libraries/dtos/videos/video.dto'; import { VideoFunctionDto } from '@gitroom/nestjs-libraries/dtos/videos/video.function.dto'; +import { UploadDto } from '@gitroom/nestjs-libraries/dtos/media/upload.dto'; +import axios from 'axios'; +import { Readable } from 'stream'; +import { lookup } from 'mime-types'; +import * as Sentry from '@sentry/nestjs'; @ApiTags('Public API') @Controller('/public/v1') export class PublicIntegrationsController { private storage = UploadFactory.createStorage(); - + constructor( private _integrationService: IntegrationService, private _postsService: PostsService, @@ -44,6 +49,7 @@ export class PublicIntegrationsController { @GetOrgFromRequest() org: Organization, @UploadedFile('file') file: Express.Multer.File ) { + Sentry.metrics.count("public_api-request", 1); if (!file) { throw new HttpException({ msg: 'No file provided' }, 400); } @@ -56,11 +62,44 @@ export class PublicIntegrationsController { ); } + @Post('/upload-from-url') + async uploadsFromUrl( + @GetOrgFromRequest() org: Organization, + @Body() body: UploadDto + ) { + Sentry.metrics.count("public_api-request", 1); + const response = await axios.get(body.url, { + responseType: 'arraybuffer', + }); + + const buffer = Buffer.from(response.data); + + const getFile = await this.storage.uploadFile({ + buffer, + mimetype: lookup(body?.url?.split?.('?')?.[0]) || 'image/jpeg', + size: buffer.length, + path: '', + fieldname: '', + destination: '', + stream: new Readable(), + filename: '', + originalname: '', + encoding: '', + }); + + return this._mediaService.saveFile( + org.id, + getFile.originalname, + getFile.path + ); + } + @Get('/find-slot/:id') async findSlotIntegration( @GetOrgFromRequest() org: Organization, @Param('id') id?: string ) { + Sentry.metrics.count("public_api-request", 1); return { date: await this._postsService.findFreeDateTime(org.id, id) }; } @@ -69,6 +108,7 @@ export class PublicIntegrationsController { @GetOrgFromRequest() org: Organization, @Query() query: GetPostsDto ) { + Sentry.metrics.count("public_api-request", 1); const posts = await this._postsService.getPosts(org.id, query); return { posts, @@ -82,6 +122,7 @@ export class PublicIntegrationsController { @GetOrgFromRequest() org: Organization, @Body() rawBody: any ) { + Sentry.metrics.count("public_api-request", 1); const body = await this._postsService.mapTypeToPost( rawBody, org.id, @@ -98,17 +139,20 @@ export class PublicIntegrationsController { @GetOrgFromRequest() org: Organization, @Param() body: { id: string } ) { + Sentry.metrics.count("public_api-request", 1); const getPostById = await this._postsService.getPost(org.id, body.id); return this._postsService.deletePost(org.id, getPostById.group); } @Get('/is-connected') async getActiveIntegrations(@GetOrgFromRequest() org: Organization) { + Sentry.metrics.count("public_api-request", 1); return { connected: true }; } @Get('/integrations') async listIntegration(@GetOrgFromRequest() org: Organization) { + Sentry.metrics.count("public_api-request", 1); return (await this._integrationService.getIntegrationsList(org.id)).map( (org) => ({ id: org.id, @@ -132,11 +176,13 @@ export class PublicIntegrationsController { @GetOrgFromRequest() org: Organization, @Body() body: VideoDto ) { + Sentry.metrics.count("public_api-request", 1); return this._mediaService.generateVideo(org, body); } @Post('/video/function') videoFunction(@Body() body: VideoFunctionDto) { + Sentry.metrics.count("public_api-request", 1); return this._mediaService.videoFunction( body.identifier, body.functionName, 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/backend/src/services/auth/providers/oauth.provider.ts b/apps/backend/src/services/auth/providers/oauth.provider.ts index 30175238..b8c49dad 100644 --- a/apps/backend/src/services/auth/providers/oauth.provider.ts +++ b/apps/backend/src/services/auth/providers/oauth.provider.ts @@ -56,7 +56,7 @@ export class OauthProvider implements ProvidersInterface { redirect_uri: `${this.frontendUrl}/settings`, }); - return `${this.authUrl}/?${params.toString()}`; + return `${this.authUrl}?${params.toString()}`; } async getToken(code: string): Promise { diff --git a/apps/cron/package.json b/apps/cron/package.json index 4226a686..6276445d 100644 --- a/apps/cron/package.json +++ b/apps/cron/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "dotenv -e ../../.env -- nest start --watch --entryFile=./apps/cron/src/main", "build": "cross-env NODE_ENV=production nest build", - "start": "dotenv -e ../../.env -- node ./dist/apps/cron/src/main.js", + "start": "dotenv -e ../../.env -- node --experimental-require-module ./dist/apps/cron/src/main.js", "pm2": "pm2 start pnpm --name cron -- start" }, "keywords": [], diff --git a/apps/cron/src/main.ts b/apps/cron/src/main.ts index c22bce19..3d3ece5b 100644 --- a/apps/cron/src/main.ts +++ b/apps/cron/src/main.ts @@ -1,12 +1,12 @@ -import { NestFactory } from '@nestjs/core'; -import { CronModule } from './cron.module'; - import { initializeSentry } from '@gitroom/nestjs-libraries/sentry/initialize.sentry'; initializeSentry('cron'); -async function bootstrap() { +import { NestFactory } from '@nestjs/core'; +import { CronModule } from './cron.module'; + +async function start() { // some comment again await NestFactory.createApplicationContext(CronModule); } -bootstrap(); +start(); 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 ( +