diff --git a/.env.example b/.env.example index 9a017b3a..61d2a020 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,10 @@ GITHUB_CLIENT_ID="" GITHUB_CLIENT_SECRET="" BEEHIIVE_API_KEY="" BEEHIIVE_PUBLICATION_ID="" +LISTMONK_DOMAIN="" +LISTMONK_USER="" +LISTMONK_API_KEY="" +LISTMONK_LIST_ID="" THREADS_APP_ID="" THREADS_APP_SECRET="" FACEBOOK_APP_ID="" diff --git a/.github/workflows/build.yaml b/.github/workflows/build similarity index 65% rename from .github/workflows/build.yaml rename to .github/workflows/build index bc472bae..d40e7f14 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build @@ -3,11 +3,11 @@ name: Build on: push: - pull_request: jobs: build: runs-on: ubuntu-latest + strategy: matrix: @@ -16,6 +16,9 @@ jobs: steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: @@ -47,3 +50,20 @@ jobs: - 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 (Branch) + 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 b/.github/workflows/build-pr new file mode 100644 index 00000000..513fe803 --- /dev/null +++ b/.github/workflows/build-pr @@ -0,0 +1,71 @@ +--- +name: Build + +on: + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + 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: + fetch-depth: 0 + + - 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 + 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/.github/workflows/pr-docker-build.yml b/.github/workflows/pr-docker-build.yml index adfb8348..17eb5c04 100644 --- a/.github/workflows/pr-docker-build.yml +++ b/.github/workflows/pr-docker-build.yml @@ -9,10 +9,16 @@ permissions: write-all jobs: build-and-publish: runs-on: ubuntu-latest - + + environment: + name: build-pr + steps: - name: Checkout code uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} - name: Log in to GitHub Container Registry uses: docker/login-action@v3 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 5dd97d86..91350317 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

- - + + automate

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 d75697ea..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') @@ -41,7 +42,7 @@ export class AuthController { async register( @Req() req: Request, @Body() body: CreateOrgUserDto, - @Res({ passthrough: true }) response: Response, + @Res({ passthrough: false }) response: Response, @RealIP() ip: string, @UserAgent() userAgent: string ) { @@ -101,6 +102,7 @@ export class AuthController { } } + Sentry.metrics.count("new_user", 1); response.header('onboarding', 'true'); response.status(200).json({ register: true, @@ -114,7 +116,7 @@ export class AuthController { async login( @Req() req: Request, @Body() body: LoginUserDto, - @Res({ passthrough: true }) response: Response, + @Res({ passthrough: false }) response: Response, @RealIP() ip: string, @UserAgent() userAgent: string ) { @@ -204,11 +206,11 @@ export class AuthController { @Post('/activate') async activate( @Body('code') code: string, - @Res({ passthrough: true }) response: Response + @Res({ passthrough: false }) response: Response ) { const activate = await this._authService.activate(code); if (!activate) { - return response.status(200).send({ can: false }); + return response.status(200).json({ can: false }); } response.cookie('auth', activate, { @@ -228,16 +230,18 @@ export class AuthController { } response.header('onboarding', 'true'); - return response.status(200).send({ can: true }); + + return response.status(200).json({ can: true }); } @Post('/oauth/:provider/exists') async oauthExists( @Body('code') code: string, @Param('provider') provider: string, - @Res({ passthrough: true }) response: Response + @Res({ passthrough: false }) response: Response ) { const { jwt, token } = await this._authService.checkExists(provider, code); + if (token) { return response.json({ token }); } 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 cf9a6609..c4b1ff50 100644 --- a/apps/backend/src/api/routes/integrations.controller.ts +++ b/apps/backend/src/api/routes/integrations.controller.ts @@ -3,6 +3,7 @@ import { Controller, Delete, Get, + HttpException, Param, Post, Put, @@ -15,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'; @@ -37,6 +37,7 @@ import { AuthorizationActions, Sections, } from '@gitroom/backend/services/auth/permissions/permission.exception.class'; +import { uniqBy } from 'lodash'; @ApiTags('Integrations') @Controller('/integrations') @@ -246,11 +247,68 @@ export class IntegrationsController { ) { return this._integrationService.setTimes(org.id, id, body); } + + @Post('/mentions') + async mentions( + @GetOrgFromRequest() org: Organization, + @Body() body: IntegrationFunctionDto + ) { + const getIntegration = await this._integrationService.getIntegrationById( + org.id, + body.id + ); + if (!getIntegration) { + throw new Error('Invalid integration'); + } + + let newList: any[] | { none: true } = []; + try { + newList = (await this.functionIntegration(org, body)) || []; + } catch (err) { + console.log(err); + } + + if (!Array.isArray(newList) && newList?.none) { + return newList; + } + + const list = await this._integrationService.getMentions( + getIntegration.providerIdentifier, + body?.data?.query + ); + + if (Array.isArray(newList) && newList.length) { + await this._integrationService.insertMentions( + getIntegration.providerIdentifier, + newList + .map((p: any) => ({ + name: p.label || '', + username: p.id || '', + image: p.image || '', + doNotCache: p.doNotCache || false, + })) + .filter((f: any) => f.name && !f.doNotCache) + ); + } + + return uniqBy( + [ + ...list.map((p) => ({ + id: p.username, + image: p.image, + label: p.name, + })), + ...(newList as any[]), + ], + (p) => p.id + ).filter((f) => f.label && f.id); + } + @Post('/function') async functionIntegration( @GetOrgFromRequest() org: Organization, @Body() body: IntegrationFunctionDto - ) { + ): Promise { const getIntegration = await this._integrationService.getIntegrationById( org.id, body.id @@ -266,8 +324,10 @@ export class IntegrationsController { throw new Error('Invalid provider'); } + // @ts-ignore if (integrationProvider[body.name]) { try { + // @ts-ignore const load = await integrationProvider[body.name]( getIntegration.token, body.data, @@ -427,6 +487,18 @@ export class IntegrationsController { validName = `Channel_${String(id).slice(0, 8)}`; } } + + if ( + process.env.STRIPE_PUBLISHABLE_KEY && + org.isTrailing && + (await this._integrationService.checkPreviousConnections( + org.id, + String(id) + )) + ) { + throw new HttpException('', 412); + } + return this._integrationService.createOrUpdateIntegration( additionalSettings, !!integrationProvider.oneTimeToken, 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/public.controller.ts b/apps/backend/src/api/routes/public.controller.ts index 66238060..3137fc2e 100644 --- a/apps/backend/src/api/routes/public.controller.ts +++ b/apps/backend/src/api/routes/public.controller.ts @@ -1,4 +1,14 @@ -import { Body, Controller, Get, Param, Post, Req, Res } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + Post, + Query, + Req, + Res, + StreamableFile, +} from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AgenciesService } from '@gitroom/nestjs-libraries/database/prisma/agencies/agencies.service'; import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service'; @@ -11,6 +21,10 @@ import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management'; import { AgentGraphInsertService } from '@gitroom/nestjs-libraries/agent/agent.graph.insert.service'; import { Nowpayments } from '@gitroom/nestjs-libraries/crypto/nowpayments'; +import { Readable, pipeline } from 'stream'; +import { promisify } from 'util'; + +const pump = promisify(pipeline); @ApiTags('Public') @Controller('/public') @@ -136,4 +150,46 @@ export class PublicController { console.log('cryptoPost', body, path); return this._nowpayments.processPayment(path, body); } + + @Get('/stream') + async streamFile( + @Query('url') url: string, + @Res() res: Response, + @Req() req: Request + ) { + if (!url.endsWith('mp4')) { + return res.status(400).send('Invalid video URL'); + } + + const ac = new AbortController(); + const onClose = () => ac.abort(); + req.on('aborted', onClose); + res.on('close', onClose); + + const r = await fetch(url, { signal: ac.signal }); + + if (!r.ok && r.status !== 206) { + res.status(r.status); + throw new Error(`Upstream error: ${r.statusText}`); + } + + const type = r.headers.get('content-type') ?? 'application/octet-stream'; + res.setHeader('Content-Type', type); + + const contentRange = r.headers.get('content-range'); + if (contentRange) res.setHeader('Content-Range', contentRange); + + const len = r.headers.get('content-length'); + if (len) res.setHeader('Content-Length', len); + + const acceptRanges = r.headers.get('accept-ranges') ?? 'bytes'; + res.setHeader('Accept-Ranges', acceptRanges); + + if (r.status === 206) res.status(206); // Partial Content for range responses + + try { + await pump(Readable.fromWeb(r.body as any), res); + } catch (err) { + } + } } diff --git a/apps/backend/src/api/routes/signature.controller.ts b/apps/backend/src/api/routes/signature.controller.ts index 2162443b..e5998956 100644 --- a/apps/backend/src/api/routes/signature.controller.ts +++ b/apps/backend/src/api/routes/signature.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common'; import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request'; import { Organization } from '@prisma/client'; import { ApiTags } from '@nestjs/swagger'; @@ -28,6 +28,14 @@ export class SignatureController { return this._signatureService.createOrUpdateSignature(org.id, body); } + @Delete('/:id') + async deleteSignature( + @GetOrgFromRequest() org: Organization, + @Param('id') id: string + ) { + return this._signatureService.deleteSignature(org.id, id); + } + @Put('/:id') async updateSignature( @Param('id') id: string, diff --git a/apps/backend/src/api/routes/users.controller.ts b/apps/backend/src/api/routes/users.controller.ts index 2a4e66f2..19f341cf 100644 --- a/apps/backend/src/api/routes/users.controller.ts +++ b/apps/backend/src/api/routes/users.controller.ts @@ -203,6 +203,7 @@ export class UsersController { // Clear Sentry user context on logout clearSentryUserContext(); + 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 483ae851..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', 'scheduled']), - 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/public.api.module.ts b/apps/backend/src/public-api/public.api.module.ts index 73f85ebd..6610735a 100644 --- a/apps/backend/src/public-api/public.api.module.ts +++ b/apps/backend/src/public-api/public.api.module.ts @@ -34,3 +34,4 @@ export class PublicApiModule implements NestModule { consumer.apply(PublicAuthMiddleware).forRoutes(...authenticatedController); } } + 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 c24fa64d..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,53 @@ 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) }; + } + @Get('/posts') async getPosts( @GetOrgFromRequest() org: Organization, @Query() query: GetPostsDto ) { + Sentry.metrics.count("public_api-request", 1); const posts = await this._postsService.getPosts(org.id, query); return { posts, @@ -74,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, @@ -90,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, @@ -124,13 +176,17 @@ 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 - ) { - return this._mediaService.videoFunction(body.identifier, body.functionName, body.params); + videoFunction(@Body() body: VideoFunctionDto) { + Sentry.metrics.count("public_api-request", 1); + return this._mediaService.videoFunction( + body.identifier, + body.functionName, + body.params + ); } } diff --git a/apps/backend/src/services/auth/auth.middleware.ts b/apps/backend/src/services/auth/auth.middleware.ts index f4879d73..2afd0166 100644 --- a/apps/backend/src/services/auth/auth.middleware.ts +++ b/apps/backend/src/services/auth/auth.middleware.ts @@ -7,6 +7,7 @@ import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/us import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management'; import { HttpForbiddenException } from '@gitroom/nestjs-libraries/services/exception.filter'; import { setSentryUserContext } from '@gitroom/nestjs-libraries/sentry/sentry.user.context'; +import { MastraService } from '@gitroom/nestjs-libraries/chat/mastra.service'; export const removeAuth = (res: Response) => { res.cookie('auth', '', { @@ -21,7 +22,6 @@ export const removeAuth = (res: Response) => { expires: new Date(0), maxAge: -1, }); - res.header('logout', 'true'); }; diff --git a/apps/backend/src/services/auth/auth.service.ts b/apps/backend/src/services/auth/auth.service.ts index de53a531..59c4d11a 100644 --- a/apps/backend/src/services/auth/auth.service.ts +++ b/apps/backend/src/services/auth/auth.service.ts @@ -7,10 +7,10 @@ import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/o import { AuthService as AuthChecker } from '@gitroom/helpers/auth/auth.service'; import { ProvidersFactory } from '@gitroom/backend/services/auth/providers/providers.factory'; import dayjs from 'dayjs'; -import { NewsletterService } from '@gitroom/nestjs-libraries/services/newsletter.service'; import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service'; import { ForgotReturnPasswordDto } from '@gitroom/nestjs-libraries/dtos/auth/forgot-return.password.dto'; import { EmailService } from '@gitroom/nestjs-libraries/services/email.service'; +import { NewsletterService } from '@gitroom/nestjs-libraries/newsletter/newsletter.service'; @Injectable() export class AuthService { @@ -36,10 +36,13 @@ export class AuthService { addToOrg?: boolean | { orgId: string; role: 'USER' | 'ADMIN'; id: string } ) { if (provider === Provider.LOCAL) { + if (process.env.DISALLOW_PLUS && body.email.includes('+')) { + throw new Error('Email with plus sign is not allowed'); + } const user = await this._userService.getUserByEmail(body.email); if (body instanceof CreateOrgUserDto) { if (user) { - throw new Error('User already exists'); + throw new Error('Email already exists'); } if (!(await this.canRegister(provider))) { 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/cron.module.ts b/apps/cron/src/cron.module.ts index 253b8202..01eb6ed7 100644 --- a/apps/cron/src/cron.module.ts +++ b/apps/cron/src/cron.module.ts @@ -4,6 +4,8 @@ import { DatabaseModule } from '@gitroom/nestjs-libraries/database/prisma/databa import { BullMqModule } from '@gitroom/nestjs-libraries/bull-mq-transport-new/bull.mq.module'; import { SentryModule } from '@sentry/nestjs/setup'; import { FILTER } from '@gitroom/nestjs-libraries/sentry/sentry.exception'; +import { CheckMissingQueues } from '@gitroom/cron/tasks/check.missing.queues'; +import { PostNowPendingQueues } from '@gitroom/cron/tasks/post.now.pending.queues'; @Module({ imports: [ @@ -13,8 +15,6 @@ import { FILTER } from '@gitroom/nestjs-libraries/sentry/sentry.exception'; BullMqModule, ], controllers: [], - providers: [ - FILTER - ], + providers: [FILTER, CheckMissingQueues, PostNowPendingQueues], }) export class CronModule {} 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/cron/src/tasks/check.missing.queues.ts b/apps/cron/src/tasks/check.missing.queues.ts new file mode 100644 index 00000000..0664676c --- /dev/null +++ b/apps/cron/src/tasks/check.missing.queues.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service'; +import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client'; +import dayjs from 'dayjs'; + +@Injectable() +export class CheckMissingQueues { + constructor( + private _postService: PostsService, + private _workerServiceProducer: BullMqClient + ) {} + @Cron('0 * * * *') + async handleCron() { + const list = await this._postService.searchForMissingThreeHoursPosts(); + const notExists = ( + await Promise.all( + list.map(async (p) => ({ + id: p.id, + publishDate: p.publishDate, + isJob: + ['delayed', 'waiting'].indexOf( + await this._workerServiceProducer + .getQueue('post') + .getJobState(p.id) + ) > -1, + })) + ) + ).filter((p) => !p.isJob); + + + for (const job of notExists) { + this._workerServiceProducer.emit('post', { + id: job.id, + options: { + delay: dayjs(job.publishDate).diff(dayjs(), 'millisecond'), + }, + payload: { + id: job.id, + delay: dayjs(job.publishDate).diff(dayjs(), 'millisecond'), + }, + }); + } + } +} diff --git a/apps/cron/src/tasks/post.now.pending.queues.ts b/apps/cron/src/tasks/post.now.pending.queues.ts new file mode 100644 index 00000000..69105304 --- /dev/null +++ b/apps/cron/src/tasks/post.now.pending.queues.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service'; +import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client'; + +@Injectable() +export class PostNowPendingQueues { + constructor( + private _postService: PostsService, + private _workerServiceProducer: BullMqClient + ) {} + @Cron('*/16 * * * *') + async handleCron() { + const list = await this._postService.checkPending15minutesBack(); + const notExists = ( + await Promise.all( + list.map(async (p) => ({ + id: p.id, + publishDate: p.publishDate, + isJob: + ['delayed', 'waiting'].indexOf( + await this._workerServiceProducer + .getQueue('post') + .getJobState(p.id) + ) > -1, + })) + ) + ).filter((p) => !p.isJob); + + for (const job of notExists) { + this._workerServiceProducer.emit('post', { + id: job.id, + options: { + delay: 0, + }, + payload: { + id: job.id, + delay: 0, + }, + }); + } + } +} diff --git a/apps/frontend/next.config.js b/apps/frontend/next.config.js index 2fd3b0f2..f0f895bf 100644 --- a/apps/frontend/next.config.js +++ b/apps/frontend/next.config.js @@ -6,8 +6,31 @@ const nextConfig = { experimental: { proxyTimeout: 90_000, }, + // Document-Policy header for browser profiling + async headers() { + return [{ + source: "/:path*", + headers: [{ + key: "Document-Policy", + value: "js-profiling", + }, ], + }, ]; + }, reactStrictMode: false, transpilePackages: ['crypto-hash'], + // Enable production sourcemaps for Sentry + productionBrowserSourceMaps: true, + + // Custom webpack config to ensure sourcemaps are generated properly + webpack: (config, { buildId, dev, isServer, defaultLoaders }) => { + // Enable sourcemaps for both client and server in production + if (!dev) { + config.devtool = isServer ? 'source-map' : 'hidden-source-map'; + } + + return config; + }, + images: { remotePatterns: [ { @@ -42,9 +65,54 @@ const nextConfig = { ]; }, }; + export default withSentryConfig(nextConfig, { org: process.env.SENTRY_ORG, project: process.env.SENTRY_PROJECT, authToken: process.env.SENTRY_AUTH_TOKEN, + + // Sourcemap configuration optimized for monorepo + sourcemaps: { + disable: false, + // More comprehensive asset patterns for monorepo + assets: [ + ".next/static/**/*.js", + ".next/static/**/*.js.map", + ".next/server/**/*.js", + ".next/server/**/*.js.map", + ], + ignore: [ + "**/node_modules/**", + "**/*hot-update*", + "**/_buildManifest.js", + "**/_ssgManifest.js", + "**/*.test.js", + "**/*.spec.js", + ], + deleteSourcemapsAfterUpload: true, + }, + + // Release configuration + release: { + create: true, + finalize: true, + // Use git commit hash for releases in monorepo + name: process.env.VERCEL_GIT_COMMIT_SHA || process.env.GITHUB_SHA || undefined, + }, + + // NextJS specific optimizations for monorepo + widenClientFileUpload: true, + + // Additional configuration telemetry: false, + silent: process.env.NODE_ENV === 'production', + debug: process.env.NODE_ENV === 'development', + + // Error handling for CI/CD + errorHandler: (error) => { + console.warn("Sentry build error occurred:", error.message); + console.warn("This might be due to missing Sentry environment variables or network issues"); + // Don't fail the build if Sentry upload fails in monorepo context + return; + }, }); diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 219b42c5..85f5f408 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "dotenv -e ../../.env -- next dev -p 4200", "build": "next build", + "build:sentry": "dotenv -e ../../.env -- next build", "start": "dotenv -e ../../.env -- next start -p 4200", "pm2": "pm2 start pnpm --name frontend -- start" }, diff --git a/apps/frontend/public/icons/platforms/listmonk.png b/apps/frontend/public/icons/platforms/listmonk.png new file mode 100644 index 00000000..8c90e6da Binary files /dev/null and b/apps/frontend/public/icons/platforms/listmonk.png differ diff --git a/apps/frontend/src/app/(app)/(preview)/p/[id]/page.tsx b/apps/frontend/src/app/(app)/(preview)/p/[id]/page.tsx index e008ba84..85f7aa9a 100644 --- a/apps/frontend/src/app/(app)/(preview)/p/[id]/page.tsx +++ b/apps/frontend/src/app/(app)/(preview)/p/[id]/page.tsx @@ -10,6 +10,16 @@ import utc from 'dayjs/plugin/utc'; import { VideoOrImage } from '@gitroom/react/helpers/video.or.image'; import { CopyClient } from '@gitroom/frontend/components/preview/copy.client'; import { getT } from '@gitroom/react/translation/get.translation.service.backend'; +import dynamicLoad from 'next/dynamic'; + +const RenderPreviewDate = dynamicLoad( + () => + import('@gitroom/frontend/components/preview/render.preview.date').then( + (mod) => mod.RenderPreviewDate + ), + { ssr: false } +); + dayjs.extend(utc); export const metadata: Metadata = { title: `${isGeneralServerSide() ? 'Postiz' : 'Gitroom'} Preview`, @@ -91,11 +101,8 @@ export default async function Auth({ )}
- {t('publication_date', 'Publication Date:')} - {dayjs - .utc(post[0].publishDate) - .local() - .format('MMMM D, YYYY h:mm A')} + {t('publication_date', 'Publication Date:')}{' '} +
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/(app)/layout.tsx b/apps/frontend/src/app/(app)/layout.tsx index 6d14032a..bea3e05e 100644 --- a/apps/frontend/src/app/(app)/layout.tsx +++ b/apps/frontend/src/app/(app)/layout.tsx @@ -18,6 +18,13 @@ import { FacebookComponent } from '@gitroom/frontend/components/layout/facebook. import { headers } from 'next/headers'; import { headerName } from '@gitroom/react/translation/i18n.config'; import { HtmlComponent } from '@gitroom/frontend/components/layout/html.component'; +// import dynamicLoad from 'next/dynamic'; +// const SetTimezone = dynamicLoad( +// () => import('@gitroom/frontend/components/layout/set.timezone'), +// { +// ssr: false, +// } +// ); const jakartaSans = Plus_Jakarta_Sans({ weight: ['600', '500'], @@ -72,6 +79,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) { } > + {/**/} diff --git a/apps/frontend/src/app/colors.scss b/apps/frontend/src/app/colors.scss index 65b8198f..c0082f70 100644 --- a/apps/frontend/src/app/colors.scss +++ b/apps/frontend/src/app/colors.scss @@ -22,7 +22,8 @@ --new-col-color: #2c2b2b; --new-menu-dots: #696868; --new-menu-hover: #fff; - --menu-shadow: 0 8px 30px 0 rgba(0, 0, 0, 0.50); + --menu-shadow: 0 8px 30px 0 rgba(0, 0, 0, 0.5); + --popup-color: rgba(65, 64, 66, 0.3); } .light { --new-bgColor: #f0f2f4; @@ -33,8 +34,8 @@ --new-boxFocused: #ebe8ff; --new-textColor: 14 14 14; --new-blockSeparator: #f2f2f4; - --new-btn-simple: #ECEEF1; - --new-btn-text: #0E0E0E; + --new-btn-simple: #eceef1; + --new-btn-text: #0e0e0e; --new-btn-primary: #612bd3; --new-ai-btn: #d82d7e; --new-box-hover: #f4f6f8; @@ -43,15 +44,19 @@ --new-table-text: #777b7f; --new-table-text-focused: #fc69ff; --new-small-strips: #191818; - --new-big-strips: #F5F7F9; - --new-col-color: #EFF1F3; + --new-big-strips: #f5f7f9; + --new-col-color: #eff1f3; --new-menu-dots: #696868; --new-menu-hover: #000; - --menu-shadow: -22px 83px 24px 0 rgba(55, 52, 75, 0.00), -14px 53px 22px 0 rgba(55, 52, 75, 0.01), -8px 30px 19px 0 rgba(55, 52, 75, 0.05), -3px 13px 14px 0 rgba(55, 52, 75, 0.09), -1px 3px 8px 0 rgba(55, 52, 75, 0.10); + --menu-shadow: -22px 83px 24px 0 rgba(55, 52, 75, 0), + -14px 53px 22px 0 rgba(55, 52, 75, 0.01), + -8px 30px 19px 0 rgba(55, 52, 75, 0.05), + -3px 13px 14px 0 rgba(55, 52, 75, 0.09), + -1px 3px 8px 0 rgba(55, 52, 75, 0.1); + --popup-color: rgba(55, 37, 97, 0.2); } } - :root { .dark { --color-primary: #0e0e0e; @@ -193,4 +198,4 @@ --color-custom55: #d5d7e1; --color-modalCustom: transparent; } -} \ No newline at end of file +} diff --git a/apps/frontend/src/app/global.scss b/apps/frontend/src/app/global.scss index 4a047bb9..75993ff0 100644 --- a/apps/frontend/src/app/global.scss +++ b/apps/frontend/src/app/global.scss @@ -357,6 +357,10 @@ body * { // /*right: -5rem !important;*/ //} +.trz { + transform: translateZ(0); +} + .uppy-FileInput-container { @apply cursor-pointer font-[500] flex justify-center items-center gap-[4px] text-[12px] rounded-[4px] w-[107px] h-[25px] text-textColor border-[2px]; @apply bg-customColor3 border-customColor21; @@ -626,3 +630,77 @@ html[dir='rtl'] [dir='ltr'] { .mantine-Overlay-root { background: rgba(65, 64, 66, 0.3) !important; } + +.dropdown-menu { + @apply shadow-menu; + background: var(--new-bgColorInner); + border: 1px solid var(--new-bgLineColor); + border-radius: 18px; + display: flex; + flex-direction: column; + overflow: auto; + position: relative; + + button { + align-items: center; + background-color: transparent; + display: flex; + text-align: left; + width: 100%; + padding: 10px; + + &:hover, + &:hover.is-selected { + background-color: var(--new-bgLineColor); + } + } +} + +.tiptap { + a { + @apply underline; + } +} +.tiptap { + :first-child { + margin-top: 0; + } + + .mention { + background-color: var(--purple-light); + border-radius: 0.4rem; + box-decoration-break: clone; + color: #ae8afc; + &::after { + content: '\200B'; + } + } +} +.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 ( +