diff --git a/packages/server/src/Interface.ts b/packages/server/src/Interface.ts index 97f66d10920..78497eab939 100644 --- a/packages/server/src/Interface.ts +++ b/packages/server/src/Interface.ts @@ -70,6 +70,9 @@ export interface IChatFlow { category?: string type?: ChatflowType workspaceId?: string + lastPublishedAt?: Date + lastPublishedCommit?: string + isDirty?: boolean } export interface IChatMessage { diff --git a/packages/server/src/controllers/chatflows/index.ts b/packages/server/src/controllers/chatflows/index.ts index 7d5fe04df9b..958b13c8146 100644 --- a/packages/server/src/controllers/chatflows/index.ts +++ b/packages/server/src/controllers/chatflows/index.ts @@ -8,6 +8,7 @@ import chatflowsService from '../../services/chatflows' import { getRunningExpressApp } from '../../utils/getRunningExpressApp' import { checkUsageLimit } from '../../utils/quotaUsage' import { RateLimiterManager } from '../../utils/rateLimit' +import { FlowVersionService } from '../../enterprise/services/flow-version.service' const checkIfChatflowIsValidForStreaming = async (req: Request, res: Response, next: NextFunction) => { try { @@ -99,6 +100,18 @@ const getChatflowById = async (req: Request, res: Response, next: NextFunction) if (typeof req.params === 'undefined' || !req.params.id) { throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, `Error: chatflowsController.getChatflowById - id not provided!`) } + + // Check if commitId query parameter is provided + const commitId = req.query.commitId as string + if (commitId) { + const flowVersionService = new FlowVersionService() + const apiResponse = await flowVersionService.getChatflowByCommitId(req.params.id, commitId) + apiResponse.isDirty = false + apiResponse.lastPublishedCommit = commitId + return res.json(apiResponse) + } + + // Fall back to regular chatflow service if no commitId provided const apiResponse = await chatflowsService.getChatflowById(req.params.id) return res.json(apiResponse) } catch (error) { @@ -200,6 +213,7 @@ const updateChatflow = async (req: Request, res: Response, next: NextFunction) = } const subscriptionId = req.user?.activeOrganizationSubscriptionId || '' const body = req.body + body.isDirty = true const updateChatFlow = new ChatFlow() Object.assign(updateChatFlow, body) diff --git a/packages/server/src/database/entities/ChatFlow.ts b/packages/server/src/database/entities/ChatFlow.ts index 4c14e99c1c4..6782fa09eb5 100644 --- a/packages/server/src/database/entities/ChatFlow.ts +++ b/packages/server/src/database/entities/ChatFlow.ts @@ -53,4 +53,13 @@ export class ChatFlow implements IChatFlow { @Column({ nullable: true, type: 'text' }) workspaceId?: string + + @Column({ nullable: true }) + lastPublishedAt?: Date + + @Column({ nullable: true, type: 'text' }) + lastPublishedCommit?: string + + @Column({ nullable: true, type: 'boolean', default: true }) + isDirty?: boolean } diff --git a/packages/server/src/database/entities/index.ts b/packages/server/src/database/entities/index.ts index b65ea28b58a..397ae9dd622 100644 --- a/packages/server/src/database/entities/index.ts +++ b/packages/server/src/database/entities/index.ts @@ -25,6 +25,7 @@ import { OrganizationUser } from '../../enterprise/database/entities/organizatio import { Workspace } from '../../enterprise/database/entities/workspace.entity' import { WorkspaceUser } from '../../enterprise/database/entities/workspace-user.entity' import { LoginMethod } from '../../enterprise/database/entities/login-method.entity' +import { GitConfig } from '../../enterprise/database/entities/git-config.entity' export const entities = { ChatFlow, @@ -55,5 +56,6 @@ export const entities = { OrganizationUser, Workspace, WorkspaceUser, - LoginMethod + LoginMethod, + GitConfig } diff --git a/packages/server/src/database/migrations/mariadb/index.ts b/packages/server/src/database/migrations/mariadb/index.ts index 272a6bb1ff2..f0c32938a27 100644 --- a/packages/server/src/database/migrations/mariadb/index.ts +++ b/packages/server/src/database/migrations/mariadb/index.ts @@ -48,6 +48,7 @@ import { AddSSOColumns1730519457880 } from '../../../enterprise/database/migrati import { AddPersonalWorkspace1734074497540 } from '../../../enterprise/database/migrations/mariadb/1734074497540-AddPersonalWorkspace' import { RefactorEnterpriseDatabase1737076223692 } from '../../../enterprise/database/migrations/mariadb/1737076223692-RefactorEnterpriseDatabase' import { ExecutionLinkWorkspaceId1746862866554 } from '../../../enterprise/database/migrations/mariadb/1746862866554-ExecutionLinkWorkspaceId' +import { AddGitConfig1751035139965 } from '../../../enterprise/database/migrations/mariadb/1751035139965-AddGitConfig' export const mariadbMigrations = [ Init1693840429259, @@ -98,5 +99,6 @@ export const mariadbMigrations = [ FixOpenSourceAssistantTable1743758056188, AddErrorToEvaluationRun1744964560174, ExecutionLinkWorkspaceId1746862866554, - ModifyExecutionDataColumnType1747902489801 + ModifyExecutionDataColumnType1747902489801, + AddGitConfig1751035139965 ] diff --git a/packages/server/src/database/migrations/mysql/index.ts b/packages/server/src/database/migrations/mysql/index.ts index c51ebb8a945..4b82d9ff55d 100644 --- a/packages/server/src/database/migrations/mysql/index.ts +++ b/packages/server/src/database/migrations/mysql/index.ts @@ -49,6 +49,7 @@ import { AddSSOColumns1730519457880 } from '../../../enterprise/database/migrati import { AddPersonalWorkspace1734074497540 } from '../../../enterprise/database/migrations/mysql/1734074497540-AddPersonalWorkspace' import { RefactorEnterpriseDatabase1737076223692 } from '../../../enterprise/database/migrations/mysql/1737076223692-RefactorEnterpriseDatabase' import { ExecutionLinkWorkspaceId1746862866554 } from '../../../enterprise/database/migrations/mysql/1746862866554-ExecutionLinkWorkspaceId' +import { AddGitConfig1751035139965 } from '../../../enterprise/database/migrations/mysql/1751035139965-AddGitConfig' export const mysqlMigrations = [ Init1693840429259, @@ -100,5 +101,6 @@ export const mysqlMigrations = [ AddErrorToEvaluationRun1744964560174, FixErrorsColumnInEvaluationRun1746437114935, ExecutionLinkWorkspaceId1746862866554, - ModifyExecutionDataColumnType1747902489801 + ModifyExecutionDataColumnType1747902489801, + AddGitConfig1751035139965 ] diff --git a/packages/server/src/database/migrations/postgres/index.ts b/packages/server/src/database/migrations/postgres/index.ts index 4da17daa4ab..eab0d2b1007 100644 --- a/packages/server/src/database/migrations/postgres/index.ts +++ b/packages/server/src/database/migrations/postgres/index.ts @@ -48,6 +48,7 @@ import { AddSSOColumns1730519457880 } from '../../../enterprise/database/migrati import { AddPersonalWorkspace1734074497540 } from '../../../enterprise/database/migrations/postgres/1734074497540-AddPersonalWorkspace' import { RefactorEnterpriseDatabase1737076223692 } from '../../../enterprise/database/migrations/postgres/1737076223692-RefactorEnterpriseDatabase' import { ExecutionLinkWorkspaceId1746862866554 } from '../../../enterprise/database/migrations/postgres/1746862866554-ExecutionLinkWorkspaceId' +import { AddGitConfig1751035139965 } from '../../../enterprise/database/migrations/postgres/1751035139965-AddGitConfig' export const postgresMigrations = [ Init1693891895163, @@ -98,5 +99,6 @@ export const postgresMigrations = [ FixOpenSourceAssistantTable1743758056188, AddErrorToEvaluationRun1744964560174, ExecutionLinkWorkspaceId1746862866554, - ModifyExecutionSessionIdFieldType1748450230238 + ModifyExecutionSessionIdFieldType1748450230238, + AddGitConfig1751035139965 ] diff --git a/packages/server/src/database/migrations/sqlite/index.ts b/packages/server/src/database/migrations/sqlite/index.ts index 0b15e26938f..dee35bb228e 100644 --- a/packages/server/src/database/migrations/sqlite/index.ts +++ b/packages/server/src/database/migrations/sqlite/index.ts @@ -46,6 +46,7 @@ import { AddSSOColumns1730519457880 } from '../../../enterprise/database/migrati import { AddPersonalWorkspace1734074497540 } from '../../../enterprise/database/migrations/sqlite/1734074497540-AddPersonalWorkspace' import { RefactorEnterpriseDatabase1737076223692 } from '../../../enterprise/database/migrations/sqlite/1737076223692-RefactorEnterpriseDatabase' import { ExecutionLinkWorkspaceId1746862866554 } from '../../../enterprise/database/migrations/sqlite/1746862866554-ExecutionLinkWorkspaceId' +import { AddGitConfig1751035139965 } from '../../../enterprise/database/migrations/sqlite/1751035139965-AddGitConfig' export const sqliteMigrations = [ Init1693835579790, @@ -94,5 +95,6 @@ export const sqliteMigrations = [ AddExecutionEntity1738090872625, FixOpenSourceAssistantTable1743758056188, AddErrorToEvaluationRun1744964560174, - ExecutionLinkWorkspaceId1746862866554 + ExecutionLinkWorkspaceId1746862866554, + AddGitConfig1751035139965 ] diff --git a/packages/server/src/enterprise/controllers/flow-version.controller.ts b/packages/server/src/enterprise/controllers/flow-version.controller.ts new file mode 100644 index 00000000000..17322f948b3 --- /dev/null +++ b/packages/server/src/enterprise/controllers/flow-version.controller.ts @@ -0,0 +1,68 @@ +import { Request, Response, NextFunction } from 'express' +import { StatusCodes } from 'http-status-codes' +import { InternalFlowiseError } from '../../errors/internalFlowiseError' +import { FlowVersionService } from '../services/flow-version.service' + +export class FlowVersionController { + public async publishFlow(req: Request, res: Response, next: NextFunction) { + try { + if (!req.params || !req.params.id) { + throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, 'id not provided!') + } + const message = req.body?.message + if (!message) { + throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, 'message not provided!') + } + const service = new FlowVersionService() + const result = await service.publishFlow(req.params.id, message) + if (result.success) { + return res.status(StatusCodes.OK).json(result) + } else { + return res.status(StatusCodes.BAD_REQUEST).json(result) + } + } catch (error) { + next(error) + } + } + + public async getVersions(req: Request, res: Response, next: NextFunction) { + try { + if (!req.params || !req.params.id || req.params.id === "undefined") { + throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, 'id not provided!') + } + const service = new FlowVersionService() + const result = await service.getVersions(req.params.id) + return res.status(StatusCodes.OK).json(result) + } catch (error) { + next(error) + } + } + + public async makeDraft(req: Request, res: Response, next: NextFunction) { + try { + if (!req.params || !req.params.id || req.params.id === "undefined") { + throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, 'id not provided!') + } + if (!req.params || !req.params.commitId || req.params.commitId === "undefined") { + throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, 'commitId not provided!') + } + const service = new FlowVersionService() + const result = await service.makeDraft(req.params.id, req.params.commitId) + return res.status(StatusCodes.OK).json(result) + } catch (error) { + next(error) + } + } + + public async check(req: Request, res: Response, next: NextFunction) { + try { + const service = new FlowVersionService() + const result = await service.check() + return res.status(StatusCodes.OK).json({ + isActive: result + }) + } catch (error) { + next(error) + } + } +} \ No newline at end of file diff --git a/packages/server/src/enterprise/controllers/git-config.controller.ts b/packages/server/src/enterprise/controllers/git-config.controller.ts new file mode 100644 index 00000000000..81747f4a76a --- /dev/null +++ b/packages/server/src/enterprise/controllers/git-config.controller.ts @@ -0,0 +1,145 @@ +import { Request, Response, NextFunction } from 'express' +import { StatusCodes } from 'http-status-codes' +import { GitConfigService } from '../services/git-config.service' +import { InternalFlowiseError } from '../../errors/internalFlowiseError' +import { GitConfig } from '../database/entities/git-config.entity' + +export class GitConfigController { + public async getAll(req: Request, res: Response, next: NextFunction) { + try { + const currentUser = req.user + if (!currentUser) { + throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, 'User not found') + } + const organizationId = currentUser.activeOrganizationId + const service = new GitConfigService() + const configs = await service.getAllGitConfigs(organizationId) + return res.status(StatusCodes.OK).json(configs) + } catch (error) { + next(error) + } + } + + public async getById(req: Request, res: Response, next: NextFunction) { + try { + const currentUser = req.user + if (!currentUser) { + throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, 'User not found') + } + const organizationId = currentUser.activeOrganizationId + + if (!req.params || !req.params.id) { + throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, 'id not provided!') + } + const service = new GitConfigService() + const config = await service.getGitConfigById(req.params.id, organizationId) + if (!config) return res.status(StatusCodes.NOT_FOUND).json({ message: 'Git config not found' }) + return res.status(StatusCodes.OK).json(config) + } catch (error) { + next(error) + } + } + + public async create(req: Request, res: Response, next: NextFunction) { + try { + if (!req.body) { + throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, 'body not provided!') + } + const currentUser = req.user + if (!currentUser) { + throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, 'User not found') + } + const organizationId = currentUser.activeOrganizationId + const body: Partial = { + ...req.body, + organizationId, + createdBy: currentUser.id, + createdByUser: currentUser, + updatedBy: currentUser.id, + updatedByUser: currentUser, + isActive: true + } + const service = new GitConfigService() + const config = await service.createGitConfig(body) + return res.status(StatusCodes.CREATED).json(config) + } catch (error) { + next(error) + } + } + + public async testConnection(req: Request, res: Response, next: NextFunction) { + try { + if (!req.body || !req.body.username || !req.body.secret || !req.body.repository) { + throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, 'username, secret, and repository are required!') + } + const service = new GitConfigService() + const config = await service.testGitConfig(req.body) + return res.status(StatusCodes.OK).json(config) + } catch (error) { + next(error) + } + } + + public async getBranches(req: Request, res: Response, next: NextFunction) { + try { + if (!req.params || !req.params.id) { + throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, 'id not provided!') + } + const currentUser = req.user + if (!currentUser) { + throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, 'User not found') + } + const organizationId = currentUser.activeOrganizationId + const service = new GitConfigService() + const branches = await service.getBranches(req.params.id, organizationId) + return res.status(StatusCodes.OK).json(branches) + } catch (error) { + next(error) + } + } + + public async update(req: Request, res: Response, next: NextFunction) { + try { + if (!req.params || !req.params.id) { + throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, 'id not provided!') + } + if (!req.body) { + throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, 'body not provided!') + } + const currentUser = req.user + if (!currentUser) { + throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, 'User not found') + } + const organizationId = currentUser.activeOrganizationId + const body = { + ...req.body, + organizationId + } + const service = new GitConfigService() + const config = await service.updateGitConfig(req.params.id, body) + if (!config) return res.status(StatusCodes.NOT_FOUND).json({ message: 'Git config not found' }) + return res.status(StatusCodes.OK).json(config) + } catch (error) { + next(error) + } + } + + public async delete(req: Request, res: Response, next: NextFunction) { + try { + if (!req.params || !req.params.id) { + throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, 'id not provided!') + } + const currentUser = req.user + if (!currentUser) { + throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, 'User not found') + } + const organizationId = currentUser.activeOrganizationId + const service = new GitConfigService() + const result = await service.deleteGitConfig(req.params.id, organizationId) + if (!result) return res.status(StatusCodes.NOT_FOUND).json({ message: 'Git config not found' }) + return res.status(StatusCodes.NO_CONTENT).send() + } catch (error) { + next(error) + } + } +} \ No newline at end of file diff --git a/packages/server/src/enterprise/database/entities/EnterpriseEntities.ts b/packages/server/src/enterprise/database/entities/EnterpriseEntities.ts index da111d98ab6..814d15b6e3e 100644 --- a/packages/server/src/enterprise/database/entities/EnterpriseEntities.ts +++ b/packages/server/src/enterprise/database/entities/EnterpriseEntities.ts @@ -60,3 +60,4 @@ export class LoginActivity implements ILoginActivity { @UpdateDateColumn() attemptedDateTime: Date } + diff --git a/packages/server/src/enterprise/database/entities/git-config.entity.ts b/packages/server/src/enterprise/database/entities/git-config.entity.ts new file mode 100644 index 00000000000..b8640f2e021 --- /dev/null +++ b/packages/server/src/enterprise/database/entities/git-config.entity.ts @@ -0,0 +1,54 @@ +import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm' +import { User } from './user.entity' +import { Organization } from './organization.entity' + +@Entity('git_config') +export class GitConfig { + @PrimaryGeneratedColumn('uuid') + id: string + + @Column({ nullable: true }) + organizationId?: string + @ManyToOne(() => Organization, (organization) => organization.id) + @JoinColumn({ name: 'organizationId' }) + organization?: Organization + + @Column({ type: 'varchar', length: 32, default: 'github' }) + provider: string // 'github', 'gitlab', 'bitbucket', 'generic' + + @Column({ type: 'text' }) + repository: string + + @Column({ type: 'varchar', length: 16, default: 'token' }) + authMode: string // 'basic', 'token', 'ssh' + + @Column({ type: 'varchar', length: 100 }) + username: string + + @Column({ type: 'text' }) + secret: string + + @Column({ type: 'varchar', length: 100, default: 'main' }) + branchName: string + + @Column({ type: 'boolean', default: false }) + isActive: boolean + + @CreateDateColumn() + createdDate: Date + + @UpdateDateColumn() + updatedDate: Date + + @Column({ type: 'uuid' }) + createdBy: string + @ManyToOne(() => User) + @JoinColumn({ name: 'createdBy' }) + createdByUser?: User + + @Column({ type: 'uuid' }) + updatedBy: string + @ManyToOne(() => User) + @JoinColumn({ name: 'updatedBy' }) + updatedByUser?: User +} \ No newline at end of file diff --git a/packages/server/src/enterprise/database/migrations/mariadb/1751035139965-AddGitConfig.ts b/packages/server/src/enterprise/database/migrations/mariadb/1751035139965-AddGitConfig.ts new file mode 100644 index 00000000000..857ced135b2 --- /dev/null +++ b/packages/server/src/enterprise/database/migrations/mariadb/1751035139965-AddGitConfig.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddGitConfig1751035139965 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'CREATE TABLE IF NOT EXISTS `git_config` (' + + ' `id` varchar(36) PRIMARY KEY,' + + ' `organizationId` varchar(36),' + + ' `provider` varchar(32) NOT NULL DEFAULT \'github\',' + + ' `repository` text NOT NULL,' + + ' `authMode` varchar(16) NOT NULL DEFAULT \'token\',' + + ' `username` varchar(100) NOT NULL,' + + ' `secret` text NOT NULL,' + + ' `branchName` varchar(100) DEFAULT \'main\',' + + ' `isActive` boolean DEFAULT false,' + + ' `createdDate` datetime DEFAULT CURRENT_TIMESTAMP,' + + ' `updatedDate` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,' + + ' `createdBy` varchar(36) NOT NULL,' + + ' `updatedBy` varchar(36) NOT NULL,' + + ' CONSTRAINT `fk_gitconfig_organizationId` FOREIGN KEY (`organizationId`) REFERENCES `organization`(`id`),' + + ' CONSTRAINT `fk_gitconfig_createdBy` FOREIGN KEY (`createdBy`) REFERENCES `user`(`id`),' + + ' CONSTRAINT `fk_gitconfig_updatedBy` FOREIGN KEY (`updatedBy`) REFERENCES `user`(`id`)' + + ' );' + ); + + // Add columns to chat_flow table + await queryRunner.query( + 'ALTER TABLE `chat_flow` ' + + 'ADD COLUMN `lastPublishedAt` datetime NULL, ' + + 'ADD COLUMN `lastPublishedCommit` varchar(255) NULL, ' + + 'ADD COLUMN `isDirty` boolean DEFAULT true;' + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Remove columns from chat_flow table + await queryRunner.query( + 'ALTER TABLE `chat_flow` ' + + 'DROP COLUMN `lastPublishedAt`, ' + + 'DROP COLUMN `lastPublishedCommit`, ' + + 'DROP COLUMN `isDirty`;' + ); + + await queryRunner.query('DROP TABLE IF EXISTS `git_config`;'); + } +} \ No newline at end of file diff --git a/packages/server/src/enterprise/database/migrations/mysql/1751035139965-AddGitConfig.ts b/packages/server/src/enterprise/database/migrations/mysql/1751035139965-AddGitConfig.ts new file mode 100644 index 00000000000..857ced135b2 --- /dev/null +++ b/packages/server/src/enterprise/database/migrations/mysql/1751035139965-AddGitConfig.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddGitConfig1751035139965 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'CREATE TABLE IF NOT EXISTS `git_config` (' + + ' `id` varchar(36) PRIMARY KEY,' + + ' `organizationId` varchar(36),' + + ' `provider` varchar(32) NOT NULL DEFAULT \'github\',' + + ' `repository` text NOT NULL,' + + ' `authMode` varchar(16) NOT NULL DEFAULT \'token\',' + + ' `username` varchar(100) NOT NULL,' + + ' `secret` text NOT NULL,' + + ' `branchName` varchar(100) DEFAULT \'main\',' + + ' `isActive` boolean DEFAULT false,' + + ' `createdDate` datetime DEFAULT CURRENT_TIMESTAMP,' + + ' `updatedDate` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,' + + ' `createdBy` varchar(36) NOT NULL,' + + ' `updatedBy` varchar(36) NOT NULL,' + + ' CONSTRAINT `fk_gitconfig_organizationId` FOREIGN KEY (`organizationId`) REFERENCES `organization`(`id`),' + + ' CONSTRAINT `fk_gitconfig_createdBy` FOREIGN KEY (`createdBy`) REFERENCES `user`(`id`),' + + ' CONSTRAINT `fk_gitconfig_updatedBy` FOREIGN KEY (`updatedBy`) REFERENCES `user`(`id`)' + + ' );' + ); + + // Add columns to chat_flow table + await queryRunner.query( + 'ALTER TABLE `chat_flow` ' + + 'ADD COLUMN `lastPublishedAt` datetime NULL, ' + + 'ADD COLUMN `lastPublishedCommit` varchar(255) NULL, ' + + 'ADD COLUMN `isDirty` boolean DEFAULT true;' + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Remove columns from chat_flow table + await queryRunner.query( + 'ALTER TABLE `chat_flow` ' + + 'DROP COLUMN `lastPublishedAt`, ' + + 'DROP COLUMN `lastPublishedCommit`, ' + + 'DROP COLUMN `isDirty`;' + ); + + await queryRunner.query('DROP TABLE IF EXISTS `git_config`;'); + } +} \ No newline at end of file diff --git a/packages/server/src/enterprise/database/migrations/postgres/1751035139965-AddGitConfig.ts b/packages/server/src/enterprise/database/migrations/postgres/1751035139965-AddGitConfig.ts new file mode 100644 index 00000000000..548caad7bdb --- /dev/null +++ b/packages/server/src/enterprise/database/migrations/postgres/1751035139965-AddGitConfig.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddGitConfig1751035139965 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "git_config" ( + "id" uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + "organizationId" uuid, + "provider" varchar(32) NOT NULL DEFAULT 'github', + "repository" text NOT NULL, + "authMode" varchar(16) NOT NULL DEFAULT 'token', + "username" varchar(100) NOT NULL, + "secret" text NOT NULL, + "branchName" varchar(100) DEFAULT 'main', + "isActive" boolean DEFAULT false, + "createdDate" timestamp DEFAULT now(), + "updatedDate" timestamp DEFAULT now(), + "createdBy" uuid NOT NULL, + "updatedBy" uuid NOT NULL, + CONSTRAINT "fk_gitconfig_organizationId" FOREIGN KEY ("organizationId") REFERENCES "organization"("id"), + CONSTRAINT "fk_gitconfig_createdBy" FOREIGN KEY ("createdBy") REFERENCES "user"("id"), + CONSTRAINT "fk_gitconfig_updatedBy" FOREIGN KEY ("updatedBy") REFERENCES "user"("id") + ); + `) + + // Add columns to chat_flow table + await queryRunner.query(` + ALTER TABLE "chat_flow" + ADD COLUMN "lastPublishedAt" timestamp, + ADD COLUMN "lastPublishedCommit" varchar, + ADD COLUMN "isDirty" boolean DEFAULT true; + `) + } + + public async down(queryRunner: QueryRunner): Promise { + // Remove columns from chat_flow table + await queryRunner.query(` + ALTER TABLE "chat_flow" + DROP COLUMN IF EXISTS "lastPublishedAt", + DROP COLUMN IF EXISTS "lastPublishedCommit", + DROP COLUMN IF EXISTS "isDirty"; + `) + + await queryRunner.query('DROP TABLE IF EXISTS "git_config";') + } +} \ No newline at end of file diff --git a/packages/server/src/enterprise/database/migrations/sqlite/1751035139965-AddGitConfig.ts b/packages/server/src/enterprise/database/migrations/sqlite/1751035139965-AddGitConfig.ts new file mode 100644 index 00000000000..01a1049d9fa --- /dev/null +++ b/packages/server/src/enterprise/database/migrations/sqlite/1751035139965-AddGitConfig.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddGitConfig1751035139965 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "git_config" ( + "id" varchar(36) PRIMARY KEY, + "organizationId" varchar(36), + "provider" varchar(32) NOT NULL DEFAULT 'github', + "repository" text NOT NULL, + "authMode" varchar(16) NOT NULL DEFAULT 'token', + "username" varchar(100) NOT NULL, + "secret" text NOT NULL, + "branchName" varchar(100) DEFAULT 'main', + "isActive" boolean DEFAULT false, + "createdDate" datetime DEFAULT (datetime('now')), + "updatedDate" datetime DEFAULT (datetime('now')), + "createdBy" varchar(36) NOT NULL, + "updatedBy" varchar(36) NOT NULL, + FOREIGN KEY ("organizationId") REFERENCES "organization"("id"), + FOREIGN KEY ("createdBy") REFERENCES "user"("id"), + FOREIGN KEY ("updatedBy") REFERENCES "user"("id") + ); + `) + + // Add columns to chat_flow table + await queryRunner.query(` + ALTER TABLE "chat_flow" + ADD COLUMN "lastPublishedAt" datetime; + `) + await queryRunner.query(` + ALTER TABLE "chat_flow" + ADD COLUMN "lastPublishedCommit" varchar; + `) + await queryRunner.query(` + ALTER TABLE "chat_flow" + ADD COLUMN "isDirty" boolean DEFAULT 1; + `) + } + + public async down(queryRunner: QueryRunner): Promise { + // Note: SQLite doesn't support DROP COLUMN in older versions + // This would require recreating the table, but for simplicity we'll leave it as is + // In a real scenario, you might want to create a new migration to handle this + + await queryRunner.query('DROP TABLE IF EXISTS "git_config";') + } +} \ No newline at end of file diff --git a/packages/server/src/enterprise/git-providers/GitProviderFactory.ts b/packages/server/src/enterprise/git-providers/GitProviderFactory.ts new file mode 100644 index 00000000000..0c54607e3ad --- /dev/null +++ b/packages/server/src/enterprise/git-providers/GitProviderFactory.ts @@ -0,0 +1,42 @@ +import { IGitProvider, GitProviderConfig } from './IGitProvider' +import { GithubProvider } from './GithubProvider' +import { GitlabProvider } from './GitlabProvider' +import { GitConfig } from '../database/entities/git-config.entity' + +export enum GitProviderType { + GITHUB = 'github', + GITLAB = 'gitlab', + // Add more providers as needed +} + +export class GitProviderFactory { + static createProvider(type: GitProviderType, config: GitProviderConfig): IGitProvider { + switch (type) { + case GitProviderType.GITHUB: + return new GithubProvider(config) + case GitProviderType.GITLAB: + throw new Error('GitLab provider is not yet supported.'); + default: + throw new Error(`Unsupported git provider type: ${type}`) + } + } + + static createProviderFromConfig(config: GitConfig): IGitProvider { + // Map the provider string to the enum, default to GITHUB if unknown + let providerType: GitProviderType + switch ((config.provider || '').toLowerCase()) { + case 'github': + providerType = GitProviderType.GITHUB + break + default: + throw new Error('Unsupported git provider type: ' + config.provider); + } + const gitConfig: GitProviderConfig = { + username: config.username, + repository: config.repository, + secret: config.secret, + branchName: config.branchName + } + return this.createProvider(providerType, gitConfig) + } +} \ No newline at end of file diff --git a/packages/server/src/enterprise/git-providers/GithubProvider.ts b/packages/server/src/enterprise/git-providers/GithubProvider.ts new file mode 100644 index 00000000000..6d142c1769a --- /dev/null +++ b/packages/server/src/enterprise/git-providers/GithubProvider.ts @@ -0,0 +1,243 @@ +import * as https from 'https' +import { IGitProvider, GitProviderConfig, CommitInfo, CommitResult } from './IGitProvider' + +export class GithubProvider implements IGitProvider { + private config: GitProviderConfig + private baseUrl = 'api.github.com' + + constructor(config: GitProviderConfig) { + this.config = config + } + + getProviderName(): string { + return 'GitHub' + } + + private githubApiRequest(path: string, method: string = 'GET', body?: any): Promise { + return new Promise((resolve, reject) => { + const bodyString = body ? JSON.stringify(body) : undefined + + const options = { + hostname: this.baseUrl, + port: 443, + path: path, + method: method, + headers: { + 'User-Agent': 'FlowiseAI', + 'Authorization': `token ${this.config.secret}`, + 'Accept': 'application/vnd.github.v3+json', + ...(body && { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(bodyString!) + }) + } + } + + const req = https.request(options, (res) => { + let data = '' + res.on('data', (chunk) => { data += chunk }) + res.on('end', () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + try { + const json = JSON.parse(data) + resolve(json) + } catch { + resolve(data) + } + } else { + try { + const error = JSON.parse(data) + reject(new Error(error.message || `HTTP ${res.statusCode}`)) + } catch { + reject(new Error(`HTTP ${res.statusCode}`)) + } + } + }) + }) + + req.on('error', (err) => reject(err)) + + if (bodyString) { + req.write(bodyString) + } + req.end() + }) + } + + async getFileSha(fileName: string, branch: string = 'main'): Promise { + try { + const branchToUse = branch || this.config.branchName || 'main' + const path = `/repos/${this.config.username}/${this.config.repository}/contents/${encodeURIComponent(fileName)}?ref=${branchToUse}` + const response = await this.githubApiRequest(path) + + // Validate that we got a valid SHA + if (response && response.sha && typeof response.sha === 'string' && response.sha.trim() !== '') { + return response.sha + } + + return null + } catch (error) { + // File doesn't exist or other error - this is expected for files that don't exist + return null + } + } + + async commitFlow(flowPath: string, flowContent: string, flowMessagesContent: string, commitMessage: string, branch: string = 'main'): Promise { + try { + const branchToUse = branch || this.config.branchName || 'main' + + // Create flow.json file + const flowFileName = `${flowPath}/flow.json` + const flowSha = await this.getFileSha(flowFileName, branchToUse) + + const flowBody = { + message: commitMessage, + content: Buffer.from(flowContent).toString('base64'), + branch: branchToUse, + ...(flowSha ? { sha: flowSha } : {}) + } + + const flowPath_ = `/repos/${this.config.username}/${this.config.repository}/contents/${encodeURIComponent(flowFileName)}` + const flowResponse = await this.githubApiRequest(flowPath_, 'PUT', flowBody) + + // Create messages.json file + const messagesFileName = `${flowPath}/messages.json` + const messagesSha = await this.getFileSha(messagesFileName, branchToUse) + + const messagesBody = { + message: commitMessage, + content: Buffer.from(flowMessagesContent).toString('base64'), + branch: branchToUse, + ...(messagesSha ? { sha: messagesSha } : {}) + } + + const messagesPath_ = `/repos/${this.config.username}/${this.config.repository}/contents/${encodeURIComponent(messagesFileName)}` + const messagesResponse = await this.githubApiRequest(messagesPath_, 'PUT', messagesBody) + + return { + success: true as const, + url: flowResponse.content?.html_url, + commitId: flowResponse.commit?.sha + } + } catch (error) { + return { + success: false as const, + error: error instanceof Error ? error.message : 'Unknown error' + } + } + } + + async getFlowHistory(flowPath: string, branch: string = 'main'): Promise { + try { + const branchToUse = branch || this.config.branchName || 'main' + const flowFileName = `${flowPath}/flow.json` + const path = `/repos/${this.config.username}/${this.config.repository}/commits?path=${encodeURIComponent(flowFileName)}&sha=${branchToUse}` + const commits = await this.githubApiRequest(path) + + return Array.isArray(commits) + ? commits.map((c: any) => ({ + commitId: c.sha, + date: c.commit?.committer?.date, + message: c.commit?.message, + external: false, + filePath: `https://github.com/${this.config.username}/${this.config.repository}/blob/${c.sha}/${flowFileName}` + })) + : [] + } catch (error) { + return [] + } + } + + async getFileContent(fileName: string, commitId: string): Promise { + const path = `/repos/${this.config.username}/${this.config.repository}/contents/${encodeURIComponent(fileName)}?ref=${commitId}` + const fileData = await this.githubApiRequest(path) + const content = Buffer.from(fileData.content, 'base64').toString('utf-8') + return content + } + + async deleteFlow(flowPath: string, commitMessage: string, branch: string = 'main'): Promise { + // TODO: OPTIMIZATION - Delete both files in a single commit using GitHub's Git Data API + // Current implementation uses separate DELETE requests for each file, which creates multiple commits. + // To delete both files in one commit, implement using Git Data API: + // 1. GET /repos/{owner}/{repo}/git/trees/{tree_sha} to get current tree + // 2. POST /repos/{owner}/{repo}/git/trees to create new tree without the files + // 3. POST /repos/{owner}/{repo}/git/commits to create commit with new tree + // 4. PATCH /repos/{owner}/{repo}/git/refs/heads/{branch} to update branch reference + // This would ensure both files are deleted atomically in a single commit. + + try { + const branchToUse = branch || this.config.branchName || 'main' + + // Get SHAs for both files first + const flowFileName = `${flowPath}/flow.json` + const messagesFileName = `${flowPath}/messages.json` + + const flowSha = await this.getFileSha(flowFileName, branchToUse) + const messagesSha = await this.getFileSha(messagesFileName, branchToUse) + + // If neither file exists, consider it a successful deletion + if (!flowSha && !messagesSha) { + return { + success: true as const, + url: this.getRepositoryUrl() + } + } + + // Delete both files - if one fails, we'll throw an error + let lastCommitId: string | undefined + let lastUrl: string | undefined + + // Delete flow.json if it exists and has a valid SHA + if (flowSha && flowSha.trim() !== '') { + const flowBody = { + message: commitMessage, + sha: flowSha + } + const flowPath_ = `/repos/${this.config.username}/${this.config.repository}/contents/${encodeURIComponent(flowFileName)}` + const flowResponse = await this.githubApiRequest(flowPath_, 'DELETE', flowBody) + lastCommitId = flowResponse.commit?.sha + lastUrl = flowResponse.content?.html_url + } + + // Delete messages.json if it exists and has a valid SHA + if (messagesSha && messagesSha.trim() !== '') { + const messagesBody = { + message: commitMessage, + sha: messagesSha + } + const messagesPath_ = `/repos/${this.config.username}/${this.config.repository}/contents/${encodeURIComponent(messagesFileName)}` + const messagesResponse = await this.githubApiRequest(messagesPath_, 'DELETE', messagesBody) + lastCommitId = messagesResponse.commit?.sha + lastUrl = messagesResponse.content?.html_url + } + + return { + success: true as const, + url: lastUrl, + commitId: lastCommitId + } + } catch (error) { + return { + success: false as const, + error: error instanceof Error ? error.message : 'Unknown error occurred while deleting flow' + } + } + } + + getRepositoryUrl(): string { + return `https://github.com/${this.config.username}/${this.config.repository}` + } + + async getBranches(): Promise { + try { + const path = `/repos/${this.config.username}/${this.config.repository}/branches` + const branches = await this.githubApiRequest(path) + + return Array.isArray(branches) + ? branches.map((branch: any) => branch.name) + : [] + } catch (error) { + return [] + } + } +} \ No newline at end of file diff --git a/packages/server/src/enterprise/git-providers/GitlabProvider.ts b/packages/server/src/enterprise/git-providers/GitlabProvider.ts new file mode 100644 index 00000000000..9465ff370ca --- /dev/null +++ b/packages/server/src/enterprise/git-providers/GitlabProvider.ts @@ -0,0 +1,56 @@ +import { IGitProvider, GitProviderConfig, CommitInfo, CommitResult } from './IGitProvider' + +export class GitlabProvider implements IGitProvider { + private config: GitProviderConfig + private baseUrl: string + + getProviderName(): string { + return 'GitLab' + } + + constructor(config: GitProviderConfig) { + this.config = config + // GitLab API base URL - could be configurable for self-hosted instances + this.baseUrl = 'gitlab.com' + } + + async getFileSha(fileName: string, branch: string = 'main'): Promise { + // TODO: Implement GitLab API call to get file SHA + // GitLab API: GET /projects/:id/repository/files/:file_path?ref=:branch + throw new Error('GitLab provider not yet implemented') + } + + async commitFlow(flowPath: string, flowContent: string, flowMessagesContent: string, commitMessage: string, branch: string = 'main'): Promise { + // TODO: Implement GitLab API call to commit flow files + // GitLab API: PUT /projects/:id/repository/files/:file_path + throw new Error('GitLab provider not yet implemented') + } + + async getFlowHistory(flowPath: string, branch: string = 'main'): Promise { + // TODO: Implement GitLab API call to get commit history + // GitLab API: GET /projects/:id/repository/commits?path=:file_path&ref_name=:branch + throw new Error('GitLab provider not yet implemented') + } + + async getFileContent(fileName: string, commitId: string): Promise { + // TODO: Implement GitLab API call to get file content at specific commit + // GitLab API: GET /projects/:id/repository/files/:file_path?ref=:commit_id + throw new Error('GitLab provider not yet implemented') + } + + async deleteFlow(flowPath: string, message: string, branch: string = 'main'): Promise { + // TODO: Implement GitLab API call to delete flow files + // GitLab API: DELETE /projects/:id/repository/files/:file_path + throw new Error('GitLab provider not yet implemented') + } + + getRepositoryUrl(): string { + return `https://gitlab.com/${this.config.username}/${this.config.repository}` + } + + async getBranches(): Promise { + // TODO: Implement GitLab API call to get all branches + // GitLab API: GET /projects/:id/repository/branches + throw new Error('GitLab provider not yet implemented') + } +} \ No newline at end of file diff --git a/packages/server/src/enterprise/git-providers/IGitProvider.ts b/packages/server/src/enterprise/git-providers/IGitProvider.ts new file mode 100644 index 00000000000..b6b256c33da --- /dev/null +++ b/packages/server/src/enterprise/git-providers/IGitProvider.ts @@ -0,0 +1,86 @@ +import { IChatMessage, IChatMessageFeedback } from "../../Interface"; + +export interface CommitInfo { + commitId: string; + date: string; + message: string; + filePath: string; + external?: boolean; +} + +export interface VersionInfo { + provider: string; // github, gitlab, etc. + repository: string; // e.g. https://github.com/flowiseai/flowise-chatflow-example + branch: string; // e.g. main + draft: boolean; // true if the flow is a draft + filename: string; // e.g. flow.json + commits: CommitInfo[]; +} + +export interface GitProviderConfig { + username: string; + repository: string; + secret: string; + branchName?: string; +} + +export interface FileContent { + content: string; + sha?: string; +} + +export type CommitResult = { + success: true; + url?: string; + commitId?: string; +} | { + success: false; + error: string; +} + +export interface FlowMessagesWithFeedback { + messages: IChatMessage[]; + feedback: IChatMessageFeedback[]; +} + +export interface IGitProvider { + /** + * Gets the name of the provider + */ + getProviderName(): string; + + /** + * Gets the SHA of a file from the repository + */ + getFileSha(fileName: string, branch?: string): Promise; + + /** + * Creates or updates a flow in the repository (handles multiple files like flow.json, messages.json) + */ + commitFlow(flowPath: string, flowContent: string, flowMessagesContent: string, commitMessage: string, branch?: string): Promise; + + /** + * Gets the commit history for a flow + */ + getFlowHistory(flowPath: string, branch?: string): Promise; + + /** + * Gets the content of a file at a specific commit + */ + getFileContent(fileName: string, commitId: string, branch?: string): Promise; + + /** + * Deletes a flow from the repository (deletes all associated files) + */ + deleteFlow(flowPath: string, commitMessage: string, branch?: string): Promise; + + /** + * Gets the repository URL for display purposes + */ + getRepositoryUrl(): string; + + /** + * Gets all branches from the repository + */ + getBranches(): Promise; +} \ No newline at end of file diff --git a/packages/server/src/enterprise/git-providers/README.md b/packages/server/src/enterprise/git-providers/README.md new file mode 100644 index 00000000000..a2894c57314 --- /dev/null +++ b/packages/server/src/enterprise/git-providers/README.md @@ -0,0 +1,116 @@ +# Git Provider Abstraction + +This directory contains the git provider abstraction for the FlowVersionService. The system is designed to be pluggable, allowing different git providers (GitHub, GitLab, etc.) to be used interchangeably. + +## Architecture + +### Core Components + +1. **IGitProvider** - The main interface that all git providers must implement +2. **GitProviderFactory** - Factory class for creating provider instances +3. **GithubProvider** - Concrete implementation for GitHub +4. **GitlabProvider** - Placeholder implementation for GitLab (to be completed) + +### Interface Methods + +The `IGitProvider` interface defines the following methods: + +- `getFileSha(fileName, branch)` - Get the SHA of a file +- `commitFile(fileName, content, message, branch)` - Create or update a file +- `getFileHistory(fileName, branch)` - Get commit history for a file +- `getFileContent(fileName, commitId)` - Get file content at a specific commit +- `deleteFile(fileName, message, branch)` - Delete a file +- `getRepositoryUrl()` - Get the repository URL for display + +## Usage + +The `FlowVersionService` now uses the git provider abstraction: + +```typescript +// Get the git provider for the active config +const gitProvider = await this.getGitProvider() + +// Use the provider to perform operations +const result = await gitProvider.commitFile(fileName, content, message) +``` + +## Adding a New Git Provider + +To add support for a new git provider (e.g., Bitbucket, Azure DevOps): + +1. **Create a new provider class** that implements `IGitProvider`: + +```typescript +export class BitbucketProvider implements IGitProvider { + // Implement all interface methods +} +``` + +2. **Add the provider type** to the `GitProviderType` enum in `GitProviderFactory.ts`: + +```typescript +export enum GitProviderType { + GITHUB = 'github', + GITLAB = 'gitlab', + BITBUCKET = 'bitbucket', // Add new type +} +``` + +3. **Update the factory** to handle the new provider type: + +```typescript +case GitProviderType.BITBUCKET: + return new BitbucketProvider(config) +``` + +4. **Export the new provider** in `index.ts`: + +```typescript +export * from './BitbucketProvider' +``` + +## Configuration + +The `GitProviderConfig` interface defines the common configuration needed for all providers: + +```typescript +interface GitProviderConfig { + username: string; + repository: string; + secret: string; + branchName?: string; +} +``` + +Provider-specific configuration can be added by extending this interface or adding provider-specific fields. + +## Error Handling + +All providers should handle errors gracefully and return appropriate error messages. The `CommitResult` type uses discriminated unions to ensure type safety: + +```typescript +type CommitResult = { + success: true; + url?: string; + commitId?: string; +} | { + success: false; + error: string; +} +``` + +## Testing + +When implementing a new provider, ensure to: + +1. Test all interface methods +2. Handle API rate limits and errors +3. Test with different repository configurations +4. Verify error messages are user-friendly + +## Future Enhancements + +- Add support for self-hosted git instances +- Implement caching for API responses +- Add provider-specific features (e.g., GitHub's pull requests) +- Support for different authentication methods \ No newline at end of file diff --git a/packages/server/src/enterprise/git-providers/index.ts b/packages/server/src/enterprise/git-providers/index.ts new file mode 100644 index 00000000000..0671594801a --- /dev/null +++ b/packages/server/src/enterprise/git-providers/index.ts @@ -0,0 +1,4 @@ +export * from './IGitProvider' +export * from './GithubProvider' +export * from './GitlabProvider' +export * from './GitProviderFactory' \ No newline at end of file diff --git a/packages/server/src/enterprise/routes/flow-version.route.ts b/packages/server/src/enterprise/routes/flow-version.route.ts new file mode 100644 index 00000000000..63e8f28ea3f --- /dev/null +++ b/packages/server/src/enterprise/routes/flow-version.route.ts @@ -0,0 +1,13 @@ +import express from 'express' +import { FlowVersionController } from '../controllers/flow-version.controller' + +const router = express.Router() +const flowVersionController = new FlowVersionController() + +router.post('/:id/publish', flowVersionController.publishFlow) +router.get('/:id/versions', flowVersionController.getVersions) + +// MAKE DRAFT +router.put('/:id/make-draft/:commitId', flowVersionController.makeDraft) +router.get('/check', flowVersionController.check) +export default router \ No newline at end of file diff --git a/packages/server/src/enterprise/routes/git-config.route.ts b/packages/server/src/enterprise/routes/git-config.route.ts new file mode 100644 index 00000000000..9cc0bbc626e --- /dev/null +++ b/packages/server/src/enterprise/routes/git-config.route.ts @@ -0,0 +1,15 @@ +import express from 'express' +import { GitConfigController } from '../controllers/git-config.controller' + +const router = express.Router() +const gitConfigController = new GitConfigController() + +router.get('/', gitConfigController.getAll) +router.get('/:id/branches', gitConfigController.getBranches) +router.get('/:id', gitConfigController.getById) +router.post('/', gitConfigController.create) +router.put('/:id', gitConfigController.update) +router.delete('/:id', gitConfigController.delete) +router.post('/test', gitConfigController.testConnection) + +export default router \ No newline at end of file diff --git a/packages/server/src/enterprise/services/flow-version.service.ts b/packages/server/src/enterprise/services/flow-version.service.ts new file mode 100644 index 00000000000..f56eec8a697 --- /dev/null +++ b/packages/server/src/enterprise/services/flow-version.service.ts @@ -0,0 +1,263 @@ +import { DataSource } from 'typeorm' +import { getRunningExpressApp } from '../../utils/getRunningExpressApp' +import { GitConfig } from '../database/entities/git-config.entity' +import { InternalFlowiseError } from '../../errors/internalFlowiseError' +import { StatusCodes } from 'http-status-codes' +import { ChatFlow } from '../../database/entities/ChatFlow' +import { Workspace } from '../database/entities/workspace.entity' +import { IGitProvider, VersionInfo, GitProviderFactory, FlowMessagesWithFeedback } from '../git-providers' +import { ChatMessage } from '../../database/entities/ChatMessage' +import { ChatMessageFeedback } from '../../database/entities/ChatMessageFeedback' + +export class FlowVersionService { + private dataSource: DataSource + + constructor() { + const appServer = getRunningExpressApp() + this.dataSource = appServer.AppDataSource + } + + /** + * Gets the git provider instance for the active git config + */ + private async getGitProvider(): Promise<{provider: IGitProvider, config: GitConfig}> { + const gitConfig = await this.dataSource.getRepository(GitConfig).findOneBy({ isActive: true }) + if (!gitConfig) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, 'No active Git config found') + } + const provider = GitProviderFactory.createProviderFromConfig(gitConfig) + return {provider, config: gitConfig} + } + + /** + * Constructs the file path for a chatflow based on workspace and chatflow information + * @param chatflowId The ID of the chatflow + * @returns Promise The constructed file path + */ + private async constructFlowPath(chatflowId: string): Promise { + // Get the chatflow to find workspaceId + const chatflow = await this.dataSource.getRepository(ChatFlow).findOneBy({ id: chatflowId }) + if (!chatflow) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, 'Chatflow not found') + } + + if (!chatflow.workspaceId) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, 'Chatflow has no workspace assigned') + } + + // Get the workspace to find workspace name + const workspace = await this.dataSource.getRepository(Workspace).findOneBy({ id: chatflow.workspaceId }) + if (!workspace) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, 'Workspace not found') + } + + // Construct flow path: workspaceId_workspaceName/chatflowId + const workspacePath = `${chatflow.workspaceId}_${workspace.name.replace(/[^a-zA-Z0-9-_]/g, '_')}` + return `${workspacePath}/${chatflowId}` + } + + /** + * Publishes a chatflow as JSON files to the active git repository. + * @param chatflowId The ID of the chatflow to publish + */ + public async publishFlow(chatflowId: string, message?: string): Promise<{ success: true; url?: string; commitId?: string } | { success: false; error: string }> { + try { + // 1. Get the git provider + const {provider: gitProvider, config: gitConfig} = await this.getGitProvider() + + // 2. Retrieve the chatflow + const chatflow = await this.dataSource.getRepository(ChatFlow).findOneBy({ id: chatflowId }) + if (!chatflow) { + return { success: false, error: 'Chatflow not found' } + } + + // 3. Prepare file content and construct flow path + const flowPath = await this.constructFlowPath(chatflowId) + const flowContent = JSON.stringify(chatflow, null, 2) + const flowMessagesWithFeedback:FlowMessagesWithFeedback = { + messages: [], + feedback: [] + } + + // 4. Retrieve the chatflow messages and feedback + const messages = await this.dataSource.getRepository(ChatMessage).findBy({ chatflowid: chatflowId }) + if (!messages) { + return { success: false, error: 'Chatflow messages not found' } + } + flowMessagesWithFeedback.messages = messages + const feedback = await this.dataSource.getRepository(ChatMessageFeedback).findBy({ chatflowid: chatflowId }) + if (!feedback) { + return { success: false, error: 'Chatflow feedback not found' } + } + flowMessagesWithFeedback.feedback = feedback + + const messagesContent = JSON.stringify({ flowMessagesWithFeedback }) + const commitMessage = message || `Publish chatflow: ${chatflow.name}` + + // 5. Commit the flow using the git provider + const result = await gitProvider.commitFlow(flowPath, flowContent, messagesContent, commitMessage, gitConfig.branchName) + + if (result.success) { + // Update chatflow with Git information after successful publish + await this.dataSource.getRepository(ChatFlow).update( + { id: chatflowId }, + { + lastPublishedAt: new Date(), + lastPublishedCommit: result.commitId, + isDirty: false + } + ) + } + + return result + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } + } + } + + /** + * Returns the commit history for a chatflow in the active git repository. + * @param chatflowId The ID of the chatflow + */ + public async getVersions(chatflowId: string): Promise { + try { + // 1. Get the git provider + const {provider: gitProvider, config: gitConfig} = await this.getGitProvider() + + // 2. Retrieve the chatflow + const chatflow = await this.dataSource.getRepository(ChatFlow).findOneBy({ id: chatflowId }) + if (!chatflow) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, 'Chatflow not found') + } + + // 3. Prepare flow path + const flowPath = await this.constructFlowPath(chatflowId) + + // 4. Get commit history using the git provider + const commits = await gitProvider.getFlowHistory(flowPath, gitConfig.branchName) + + // 5. iterate over the commits and add the external property + // -- any commit that is after the lastPublishedCommit should be external + const lastPublishedAt = chatflow.lastPublishedAt + if (lastPublishedAt) { + commits.forEach((commit) => { + commit.external = new Date(commit.date) > lastPublishedAt + }) + } + + return { + provider: gitProvider.getProviderName(), + repository: gitProvider.getRepositoryUrl(), + branch: gitConfig.branchName, + filename: `${flowPath}/flow.json`, + draft: chatflow.isDirty || false, + commits: commits + } + } catch (error) { + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + error instanceof Error ? error.message : 'Failed to get versions' + ) + } + } + + /** + * Fetches a specific version of a chatflow by commitId from the git repository. + * @param chatflowId The ID of the chatflow + * @param commitId The commit ID to fetch the version from + */ + public async getChatflowByCommitId(chatflowId: string, commitId: string): Promise { + try { + // 1. Get the git provider + const {provider: gitProvider, config: gitConfig} = await this.getGitProvider() + + // 2. Construct flow path + const flowPath = await this.constructFlowPath(chatflowId) + const flowFileName = `${flowPath}/flow.json` + + // 3. Get flow content using the git provider + const content = await gitProvider.getFileContent(flowFileName, commitId, gitConfig.branchName) + const chatflowData = JSON.parse(content) + return chatflowData + } catch (error) { + if (error instanceof Error && error.message.includes('not found')) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, 'Chatflow version not found for the specified commit') + } + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + error instanceof Error ? error.message : 'Failed to fetch chatflow version' + ) + } + } + + public async makeDraft(chatflowId: string, commitId: string): Promise { + try { + // 1. Retrieve the chatflow from the git repository + const chatflow = await this.getChatflowByCommitId(chatflowId, commitId) + if (!chatflow) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, 'Chatflow not found') + } + + // 2. fetch the currentchatflow from the database + const chatflowFromDb = await this.dataSource.getRepository(ChatFlow).findOneBy({ id: chatflowId }) + if (!chatflowFromDb) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, 'Chatflow not found') + } + + // 3. update the lastPublishedCommit and lastPublishedAt. + // This is needed as the draft can be from any previous commit. + // we should never lose the last published commit and date. (most recent commit) + chatflow.lastPublishedCommit = chatflowFromDb.lastPublishedCommit + chatflow.lastPublishedAt = chatflowFromDb.lastPublishedAt + chatflow.isDirty = true + await this.dataSource.getRepository(ChatFlow).save(chatflow) + return chatflow + } catch (error) { + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + error instanceof Error ? error.message : 'Failed to make draft' + ) + } + } + + /** + * Deletes a chatflow from the git repository by chatflowId + * @param chatflowId The ID of the chatflow to delete + */ + public async deleteChatflowById(chatflowId: string): Promise { + try { + // 1. Get the git provider + const {provider: gitProvider, config: gitConfig} = await this.getGitProvider() + + // 2. Construct flow path + const flowPath = await this.constructFlowPath(chatflowId) + + // 3. Delete the flow using the git provider + const result = await gitProvider.deleteFlow(flowPath, `Delete flow: ${chatflowId}`, gitConfig.branchName) + + if (!result.success) { + throw new Error(result.error || 'Failed to delete flow from git repository') + } + } catch (error) { + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + error instanceof Error ? error.message : 'Failed to delete chatflow from git repository' + ) + } + } + + /** + * Checks if there is an active git config + */ + public async check(): Promise { + try { + const gitConfig = await this.dataSource.getRepository(GitConfig).findOneBy({ isActive: true }) + return gitConfig !== null + } catch (error) { + return false + } + } +} \ No newline at end of file diff --git a/packages/server/src/enterprise/services/git-config.service.ts b/packages/server/src/enterprise/services/git-config.service.ts new file mode 100644 index 00000000000..eff4d98afb0 --- /dev/null +++ b/packages/server/src/enterprise/services/git-config.service.ts @@ -0,0 +1,193 @@ +import { DataSource } from 'typeorm' +import { getRunningExpressApp } from '../../utils/getRunningExpressApp' +import { GitConfig } from '../database/entities/git-config.entity' +import { InternalFlowiseError } from '../../errors/internalFlowiseError' +import { StatusCodes } from 'http-status-codes' +import * as https from 'https' +import { ChatFlow } from '../../database/entities/ChatFlow' +import { GitProviderFactory } from '../git-providers/GitProviderFactory' + +export class GitConfigService { + private dataSource: DataSource + + constructor() { + const appServer = getRunningExpressApp() + this.dataSource = appServer.AppDataSource + } + + public async getAllGitConfigs(organizationId: string) { + return await this.dataSource + .getRepository(GitConfig) + .createQueryBuilder('git_config') + .where('git_config.organizationId = :organizationId', { organizationId }) + .getMany() + } + + public async getGitConfigById(id: string, organizationId: string) { + return await this.dataSource + .getRepository(GitConfig) + .createQueryBuilder('git_config') + .where('git_config.id = :id', { id }) + .andWhere('git_config.organizationId = :organizationId', { organizationId }) + .getOne() + } + + public async createGitConfig(data: Partial) { + const repo = this.dataSource.getRepository(GitConfig) + const config = repo.create(data) + return await repo.save(config) + } + + public async updateGitConfig(id: string, data: Partial) { + const repo = this.dataSource.getRepository(GitConfig) + const config = await repo.findOneBy({ id, organizationId: data.organizationId }) + if (!config) throw new InternalFlowiseError(StatusCodes.NOT_FOUND, 'Git config not found') + // only update the branch name and isActive + delete data.secret + delete data.username + delete data.repository + delete data.provider + delete data.authMode + const updated = repo.merge(config, data) + return await repo.save(updated) + } + + public async deleteGitConfig(id: string, organizationId: string) { + const repo = this.dataSource.getRepository(GitConfig) + const config = await repo.findOneBy({ id, organizationId }) + if (!config) throw new InternalFlowiseError(StatusCodes.NOT_FOUND, 'Git config not found') + await repo.remove(config) + return true + } + + public async testGitConfig(data: Partial): Promise<{ success: boolean; permissions?: any; error?: string }> { + try { + // Service layer assumes validation is done in controller + // Test GitHub API credentials + if (!data.username || !data.secret || !data.repository) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, 'Missing required credentials') + } + + const permissions = await this.testGitHubCredentials(data.username, data.secret, data.repository) + + return { + success: true, + permissions + } + } catch (error) { + if (error instanceof InternalFlowiseError) { + throw error + } + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + } + } + } + + private async testGitHubCredentials(username: string, token: string, repository: string): Promise { + return new Promise((resolve, reject) => { + // Use username as owner, repository is the repo name + const owner = username + const repo = repository + + // Make request to GitHub API to test credentials and get permissions + const options = { + hostname: 'api.github.com', + port: 443, + path: `/repos/${owner}/${repo}`, + method: 'GET', + headers: { + 'User-Agent': 'FlowiseAI', + 'Authorization': `token ${token}`, + 'Accept': 'application/vnd.github.v3+json' + } + } + + const req = https.request(options, (res) => { + let data = '' + + res.on('data', (chunk) => { + data += chunk + }) + + res.on('end', () => { + if (res.statusCode === 200) { + try { + const repoData = JSON.parse(data) + + // Extract permissions from the response + const permissions = { + admin: repoData.permissions?.admin || false, + push: repoData.permissions?.push || false, + pull: repoData.permissions?.pull || false, + maintain: repoData.permissions?.maintain || false, + triage: repoData.permissions?.triage || false + } + + resolve({ + repository: repoData.full_name, + description: repoData.description, + private: repoData.private, + permissions, + user: { + login: repoData.owner?.login, + type: repoData.owner?.type + } + }) + } catch (parseError) { + reject(new Error('Failed to parse GitHub API response')) + } + } else if (res.statusCode === 401) { + reject(new Error('Invalid token or insufficient permissions')) + } else if (res.statusCode === 404) { + reject(new Error('Repository not found or access denied')) + } else { + try { + const errorData = JSON.parse(data) + reject(new Error(errorData.message || `GitHub API error: ${res.statusCode}`)) + } catch { + reject(new Error(`GitHub API error: ${res.statusCode}`)) + } + } + }) + }) + + req.on('error', (error) => { + reject(new Error(`Network error: ${error.message}`)) + }) + + req.setTimeout(10000, () => { + req.destroy() + reject(new Error('Request timeout')) + }) + + req.end() + }) + } + + public async getBranches(id: string, organizationId: string): Promise { + try { + // Get the git config + const config = await this.getGitConfigById(id, organizationId) + if (!config) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, 'Git config not found') + } + + // Create git provider instance + const provider = GitProviderFactory.createProviderFromConfig(config) + + // Get branches from the provider + const branches = await provider.getBranches() + return branches + } catch (error) { + if (error instanceof InternalFlowiseError) { + throw error + } + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + error instanceof Error ? error.message : 'Failed to fetch branches' + ) + } + } +} \ No newline at end of file diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts index 4941a0076f9..30aa79d62fe 100644 --- a/packages/server/src/routes/index.ts +++ b/packages/server/src/routes/index.ts @@ -68,6 +68,9 @@ import accountRouter from '../enterprise/routes/account.route' import loginMethodRouter from '../enterprise/routes/login-method.route' import { IdentityManager } from '../IdentityManager' +import gitConfigRouter from '../enterprise/routes/git-config.route' +import flowVersionRouter from '../enterprise/routes/flow-version.route' + const router = express.Router() router.use('/ping', pingRouter) @@ -137,5 +140,7 @@ router.use('/account', accountRouter) router.use('/loginmethod', loginMethodRouter) router.use('/logs', IdentityManager.checkFeatureByPlan('feat:logs'), logsRouter) router.use('/files', IdentityManager.checkFeatureByPlan('feat:files'), filesRouter) +router.use('/git-config', gitConfigRouter) +router.use('/flow-version', flowVersionRouter) export default router diff --git a/packages/server/src/services/chatflows/index.ts b/packages/server/src/services/chatflows/index.ts index 9900b7c1b7b..2e7f097f86e 100644 --- a/packages/server/src/services/chatflows/index.ts +++ b/packages/server/src/services/chatflows/index.ts @@ -19,6 +19,7 @@ import { getRunningExpressApp } from '../../utils/getRunningExpressApp' import { utilGetUploadsConfig } from '../../utils/getUploadsConfig' import logger from '../../utils/logger' import { checkUsageLimit, updateStorageUsage } from '../../utils/quotaUsage' +import { FlowVersionService } from '../../enterprise/services/flow-version.service' // Check if chatflow valid for streaming const checkIfChatflowIsValidForStreaming = async (chatflowId: string): Promise => { @@ -97,6 +98,22 @@ const deleteChatflow = async (chatflowId: string, orgId: string, workspaceId: st try { const appServer = getRunningExpressApp() + // check if git is configured and active + // this has to be done before deleting the chatflow, as the flowVersionService uses + // the chatflow id to fetch the name of the chatflow + const flowVersionService = new FlowVersionService() + const isGitConfiguredAndActive = await flowVersionService.check() + if (isGitConfiguredAndActive) { + const chatflow = await appServer.AppDataSource.getRepository(ChatFlow).findOneBy({ id: chatflowId }) + if (chatflow && chatflow.chatbotConfig) { + const chatbotConfig = JSON.parse(chatflow.chatbotConfig) + if (chatbotConfig.gitVersioning) { + // delete the chatflow from the git repository + await flowVersionService.deleteChatflowById(chatflowId) + } + } + } + const dbResponse = await appServer.AppDataSource.getRepository(ChatFlow).delete({ id: chatflowId }) // Update document store usage @@ -118,6 +135,7 @@ const deleteChatflow = async (chatflowId: string, orgId: string, workspaceId: st } catch (e) { logger.error(`[server]: Error deleting file storage for chatflow ${chatflowId}`) } + return dbResponse } catch (error) { throw new InternalFlowiseError( diff --git a/packages/server/src/utils/buildChatflow.ts b/packages/server/src/utils/buildChatflow.ts index 0805308d19d..000e85ea9f6 100644 --- a/packages/server/src/utils/buildChatflow.ts +++ b/packages/server/src/utils/buildChatflow.ts @@ -69,6 +69,7 @@ import { OMIT_QUEUE_JOB_DATA } from './constants' import { executeAgentFlow } from './buildAgentflow' import { Workspace } from '../enterprise/database/entities/workspace.entity' import { Organization } from '../enterprise/database/entities/organization.entity' +import { FlowVersionService } from '../enterprise/services/flow-version.service' /* * Initialize the ending node to be executed @@ -890,13 +891,32 @@ export const utilBuildChatflow = async (req: Request, isInternal: boolean = fals const chatflowid = req.params.id // Check if chatflow exists - const chatflow = await appServer.AppDataSource.getRepository(ChatFlow).findOneBy({ + let chatflow = await appServer.AppDataSource.getRepository(ChatFlow).findOneBy({ id: chatflowid }) if (!chatflow) { throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${chatflowid} not found`) } + // if the user specified a commitId, load the chatflow from the git repository + if (req.params.commitId) { + const flowVersionService = new FlowVersionService() + const chatflowFromGit = await flowVersionService.getChatflowByCommitId(chatflowid, req.params.commitId) + if (!chatflowFromGit) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${chatflowid} not found`) + } + chatflow = chatflowFromGit + } else if (chatflow.isDirty && chatflow.lastPublishedCommit) { + // Check if chatflow is dirty and has a lastPublishedCommit + // if yes, load the chatflow from the git repository (flow-version service) + const flowVersionService = new FlowVersionService() + const chatflowFromGit = await flowVersionService.getChatflowByCommitId(chatflowid, chatflow.lastPublishedCommit) + if (!chatflowFromGit) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${chatflowid} not found`) + } + chatflow = chatflowFromGit + } + const isAgentFlow = chatflow.type === 'MULTIAGENT' const httpProtocol = req.get('x-forwarded-proto') || req.protocol const baseURL = `${httpProtocol}://${req.get('host')}` diff --git a/packages/server/test/enterprise/git-providers/index.test.ts b/packages/server/test/enterprise/git-providers/index.test.ts new file mode 100644 index 00000000000..d19535febe1 --- /dev/null +++ b/packages/server/test/enterprise/git-providers/index.test.ts @@ -0,0 +1,109 @@ +import { GitProviderFactory, GitProviderType } from '../../../src/enterprise/git-providers/GitProviderFactory' +import { GithubProvider } from '../../../src/enterprise/git-providers/GithubProvider' +import { GitlabProvider } from '../../../src/enterprise/git-providers/GitlabProvider' + +describe('GitProviderFactory', () => { + const mockConfig = { + username: 'testuser', + repository: 'testrepo', + secret: 'testsecret', + branchName: 'main' + } + + describe('createProvider', () => { + it('should create GitHub provider', () => { + const provider = GitProviderFactory.createProvider(GitProviderType.GITHUB, mockConfig) + expect(provider).toBeInstanceOf(GithubProvider) + }) + + it('should throw error for GitLab provider', () => { + expect(() => { + GitProviderFactory.createProvider(GitProviderType.GITLAB, mockConfig) + }).toThrow('GitLab provider is not yet supported.') + }) + + it('should throw error for unsupported provider', () => { + expect(() => { + GitProviderFactory.createProvider('unsupported' as GitProviderType, mockConfig) + }).toThrow('Unsupported git provider type: unsupported') + }) + }) + + describe('createProviderFromConfig', () => { + it('should create GitHub provider by default', () => { + const provider = GitProviderFactory.createProviderFromConfig({ + ...mockConfig, + provider: 'github' + } as any) + expect(provider).toBeInstanceOf(GithubProvider) + }) + + it('should throw error for GitLab provider', () => { + expect(() => { + GitProviderFactory.createProviderFromConfig({ + ...mockConfig, + provider: 'gitlab' + } as any) + }).toThrow('Unsupported git provider type: gitlab') + }) + }) +}) + +describe('GithubProvider', () => { + const mockConfig = { + username: 'testuser', + repository: 'testrepo', + secret: 'testsecret', + branchName: 'main' + } + + let provider: GithubProvider + + beforeEach(() => { + provider = new GithubProvider(mockConfig) + }) + + it('should return correct repository URL', () => { + const url = provider.getRepositoryUrl() + expect(url).toBe('https://github.com/testuser/testrepo') + }) + + // Note: These tests would require mocking the GitHub API calls + // For now, we're just testing the basic structure + it('should implement all required interface methods', () => { + expect(typeof provider.getFileSha).toBe('function') + expect(typeof provider.commitFlow).toBe('function') + expect(typeof provider.getFlowHistory).toBe('function') + expect(typeof provider.getFileContent).toBe('function') + expect(typeof provider.deleteFlow).toBe('function') + expect(typeof provider.getRepositoryUrl).toBe('function') + }) + + it('should return correct type for deleteFlow method', async () => { + // Mock the githubApiRequest method to avoid actual API calls + const originalGithubApiRequest = (provider as any).githubApiRequest + ;(provider as any).githubApiRequest = jest.fn().mockResolvedValue({ + commit: { sha: 'test-commit-sha' }, + content: { html_url: 'https://github.com/testuser/testrepo/blob/main/test' } + }) + + try { + const result = await provider.deleteFlow('test-flow', 'Test commit message') + expect(result).toHaveProperty('success') + expect(typeof result.success).toBe('boolean') + + if (result.success) { + expect(result).toHaveProperty('url') + if ('commitId' in result) { + expect(typeof result.commitId === 'undefined' || typeof result.commitId === 'string').toBe(true) + } + } else { + expect(result).toHaveProperty('error') + expect(typeof result.error).toBe('string') + } + } finally { + // Restore original method + ;(provider as any).githubApiRequest = originalGithubApiRequest + } + }) +}) \ No newline at end of file diff --git a/packages/ui/src/api/chatflows.js b/packages/ui/src/api/chatflows.js index 3176947d15b..e8d12f3b32a 100644 --- a/packages/ui/src/api/chatflows.js +++ b/packages/ui/src/api/chatflows.js @@ -4,7 +4,10 @@ const getAllChatflows = () => client.get('/chatflows?type=CHATFLOW') const getAllAgentflows = (type) => client.get(`/chatflows?type=${type}`) -const getSpecificChatflow = (id) => client.get(`/chatflows/${id}`) +const getSpecificChatflow = (id, commitId) => { + const url = commitId ? `/chatflows/${id}?commitId=${commitId}` : `/chatflows/${id}` + return client.get(url) +} const getSpecificChatflowFromPublicEndpoint = (id) => client.get(`/public-chatflows/${id}`) diff --git a/packages/ui/src/api/flowversion.js b/packages/ui/src/api/flowversion.js new file mode 100644 index 00000000000..834566371c0 --- /dev/null +++ b/packages/ui/src/api/flowversion.js @@ -0,0 +1,13 @@ +import client from './client' + +const getFlowVersions = (id) => client.get(`/flow-version/${id}/versions`) +const publishFlow = (id, message) => client.post(`/flow-version/${id}/publish`, { message: message || 'Publish from Flowise' }) +const makeDraft = (id, commitId) => client.put(`/flow-version/${id}/make-draft/${commitId}`) +const check = () => client.get(`/flow-version/check`) + +export default { + getFlowVersions, + publishFlow, + makeDraft, + check +} diff --git a/packages/ui/src/api/gitconfig.js b/packages/ui/src/api/gitconfig.js new file mode 100644 index 00000000000..be18c4daf48 --- /dev/null +++ b/packages/ui/src/api/gitconfig.js @@ -0,0 +1,28 @@ +import client from './client' + +const getAllGitConfigs = () => client.get('/git-config') + +const getGitConfigById = (id) => client.get(`/git-config/${id}`) + +const createGitConfig = (body) => client.post('/git-config', body) + +const updateGitConfig = (id, body) => client.put(`/git-config/${id}`, body) + +const deleteGitConfig = (id) => client.delete(`/git-config/${id}`) + +const activateGitConfig = (id) => client.post(`/git-config/${id}/activate`) + +const testGitConfig = (body) => client.post(`/git-config/test`, body) + +const getBranches = (id) => client.get(`/git-config/${id}/branches`) + +export default { + getAllGitConfigs, + getGitConfigById, + createGitConfig, + updateGitConfig, + deleteGitConfig, + activateGitConfig, + testGitConfig, + getBranches, +} \ No newline at end of file diff --git a/packages/ui/src/menu-items/dashboard.js b/packages/ui/src/menu-items/dashboard.js index a320d0ca3f9..62676a78fbb 100644 --- a/packages/ui/src/menu-items/dashboard.js +++ b/packages/ui/src/menu-items/dashboard.js @@ -23,7 +23,8 @@ import { IconLockCheck, IconFileDatabase, IconShieldLock, - IconListCheck + IconListCheck, + IconGitBranch } from '@tabler/icons-react' // constant @@ -51,7 +52,8 @@ const icons = { IconLockCheck, IconFileDatabase, IconShieldLock, - IconListCheck + IconListCheck, + IconGitBranch } // ==============================|| DASHBOARD MENU ITEMS ||============================== // @@ -257,6 +259,16 @@ const dashboard = { title: 'Others', type: 'group', children: [ + { + id: 'git', + title: 'Git Configs', + type: 'item', + url: '/git-configs', + icon: icons.IconGitBranch, + breadcrumbs: true, + display: 'feat:logs', /*TODO: Change to 'feat:git-configs' when feature support is added*/ + permission: 'logs:view' /*TODO: Change to 'gitconfigs:view' when RBAC is added*/ + }, { id: 'logs', title: 'Logs', diff --git a/packages/ui/src/routes/MainRoutes.jsx b/packages/ui/src/routes/MainRoutes.jsx index ce7caa0423d..db1568ef2f7 100644 --- a/packages/ui/src/routes/MainRoutes.jsx +++ b/packages/ui/src/routes/MainRoutes.jsx @@ -70,6 +70,8 @@ const WorkspaceDetails = Loadable(lazy(() => import('@/views/workspace/Workspace const SSOConfig = Loadable(lazy(() => import('@/views/auth/ssoConfig'))) const SSOSuccess = Loadable(lazy(() => import('@/views/auth/ssoSuccess'))) +const GitConfigList = Loadable(lazy(() => import('@/views/gitconfig'))) + // ==============================|| MAIN ROUTING ||============================== // const MainRoutes = { @@ -355,7 +357,15 @@ const MainRoutes = { { path: '/sso-success', element: - } + }, + { + path: '/git-configs', + element: ( + + + + ) + }, ] } diff --git a/packages/ui/src/store/actions.js b/packages/ui/src/store/actions.js index c6ace4a6422..507e11eaac3 100644 --- a/packages/ui/src/store/actions.js +++ b/packages/ui/src/store/actions.js @@ -15,6 +15,7 @@ export const SHOW_CANVAS_DIALOG = '@canvas/SHOW_CANVAS_DIALOG' export const HIDE_CANVAS_DIALOG = '@canvas/HIDE_CANVAS_DIALOG' export const SET_COMPONENT_NODES = '@canvas/SET_COMPONENT_NODES' export const SET_COMPONENT_CREDENTIALS = '@canvas/SET_COMPONENT_CREDENTIALS' +export const SET_READONLY = '@canvas/SET_READONLY' // action - notifier reducer export const ENQUEUE_SNACKBAR = 'ENQUEUE_SNACKBAR' diff --git a/packages/ui/src/store/reducers/canvasReducer.js b/packages/ui/src/store/reducers/canvasReducer.js index 1c5e486f79e..b239e998cd0 100644 --- a/packages/ui/src/store/reducers/canvasReducer.js +++ b/packages/ui/src/store/reducers/canvasReducer.js @@ -3,6 +3,7 @@ import * as actionTypes from '../actions' export const initialState = { isDirty: false, + isReadonly: false, chatflow: null, canvasDialogShow: false, componentNodes: [], @@ -14,10 +15,13 @@ export const initialState = { const canvasReducer = (state = initialState, action) => { switch (action.type) { case actionTypes.SET_DIRTY: - return { - ...state, - isDirty: true + if (!action.isReadonly) { + return { + ...state, + isDirty: true + } } + return state case actionTypes.REMOVE_DIRTY: return { ...state, @@ -48,6 +52,11 @@ const canvasReducer = (state = initialState, action) => { ...state, componentCredentials: action.componentCredentials } + case actionTypes.SET_READONLY: + return { + ...state, + isReadonly: action.isReadonly + } default: return state } diff --git a/packages/ui/src/ui-component/dialog/ChatflowConfigurationDialog.jsx b/packages/ui/src/ui-component/dialog/ChatflowConfigurationDialog.jsx index cfa10cfbe49..c3ef2755d30 100644 --- a/packages/ui/src/ui-component/dialog/ChatflowConfigurationDialog.jsx +++ b/packages/ui/src/ui-component/dialog/ChatflowConfigurationDialog.jsx @@ -6,6 +6,7 @@ import { tabsClasses } from '@mui/material/Tabs' import SpeechToText from '@/ui-component/extended/SpeechToText' import Security from '@/ui-component/extended/Security' import ChatFeedback from '@/ui-component/extended/ChatFeedback' +import GitVersioning from '@/ui-component/extended/GitVersioning' import AnalyseFlow from '@/ui-component/extended/AnalyseFlow' import StarterPrompts from '@/ui-component/extended/StarterPrompts' import Leads from '@/ui-component/extended/Leads' @@ -34,6 +35,10 @@ const CHATFLOW_CONFIGURATION_TABS = [ label: 'Chat Feedback', id: 'chatFeedback' }, + { + label: 'Git Versioning', + id: 'gitVersioning' + }, { label: 'Analyse Chatflow', id: 'analyseChatflow' @@ -138,6 +143,7 @@ const ChatflowConfigurationDialog = ({ show, isAgentCanvas, dialogProps, onCance {item.id === 'followUpPrompts' ? : null} {item.id === 'speechToText' ? : null} {item.id === 'chatFeedback' ? : null} + {item.id === 'gitVersioning' ? : null} {item.id === 'analyseChatflow' ? : null} {item.id === 'leads' ? : null} {item.id === 'fileUpload' ? : null} diff --git a/packages/ui/src/ui-component/dialog/GitCommitDialog.jsx b/packages/ui/src/ui-component/dialog/GitCommitDialog.jsx new file mode 100644 index 00000000000..05b75337302 --- /dev/null +++ b/packages/ui/src/ui-component/dialog/GitCommitDialog.jsx @@ -0,0 +1,60 @@ +import { createPortal } from 'react-dom' +import PropTypes from 'prop-types' +import { Dialog, DialogTitle, DialogContent, DialogActions, OutlinedInput, Button } from '@mui/material' +import { StyledButton } from '@/ui-component/button/StyledButton' + +const GitCommitDialog = ({ show, message, onMessageChange, onCancel, onCommit, loading }) => { + const portalElement = document.getElementById('portal') + + const component = show ? ( + + + Commit Message + + + + + + + + {loading ? 'Committing...' : 'Commit'} + + + + ) : null + + return createPortal(component, portalElement) +} + +GitCommitDialog.propTypes = { + show: PropTypes.bool.isRequired, + message: PropTypes.string.isRequired, + onMessageChange: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + onCommit: PropTypes.func.isRequired, + loading: PropTypes.bool +} + +export default GitCommitDialog \ No newline at end of file diff --git a/packages/ui/src/ui-component/extended/GitVersioning.jsx b/packages/ui/src/ui-component/extended/GitVersioning.jsx new file mode 100644 index 00000000000..8898c80d291 --- /dev/null +++ b/packages/ui/src/ui-component/extended/GitVersioning.jsx @@ -0,0 +1,108 @@ +import { useDispatch } from 'react-redux' +import { useState, useEffect } from 'react' +import PropTypes from 'prop-types' + +// material-ui +import { Button, Box } from '@mui/material' +import { IconX } from '@tabler/icons-react' + +// Project import +import { StyledButton } from '@/ui-component/button/StyledButton' +import { SwitchInput } from '@/ui-component/switch/Switch' + +// store +import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction, SET_CHATFLOW } from '@/store/actions' +import useNotifier from '@/utils/useNotifier' + +// API +import chatflowsApi from '@/api/chatflows' + +const GitVersioning = ({ dialogProps }) => { + const dispatch = useDispatch() + + useNotifier() + + const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args)) + const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args)) + + const [gitVersioningStatus, setGitVersioningStatus] = useState(false) + const [chatbotConfig, setChatbotConfig] = useState({}) + + const handleChange = (value) => { + setGitVersioningStatus(value) + } + + const onSave = async () => { + try { + let value = { + gitVersioning: { + status: gitVersioningStatus + } + } + chatbotConfig.gitVersioning = value.gitVersioning + const saveResp = await chatflowsApi.updateChatflow(dialogProps.chatflow.id, { + chatbotConfig: JSON.stringify(chatbotConfig) + }) + if (saveResp.data) { + enqueueSnackbar({ + message: 'Git Versioning Settings Saved', + options: { + key: new Date().getTime() + Math.random(), + variant: 'success', + action: (key) => ( + + ) + } + }) + dispatch({ type: SET_CHATFLOW, chatflow: saveResp.data }) + } + } catch (error) { + enqueueSnackbar({ + message: `Failed to save Git Versioning Settings: ${ + typeof error.response.data === 'object' ? error.response.data.message : error.response.data + }`, + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + } + } + + useEffect(() => { + if (dialogProps.chatflow && dialogProps.chatflow.chatbotConfig) { + let chatbotConfig = JSON.parse(dialogProps.chatflow.chatbotConfig) + setChatbotConfig(chatbotConfig || {}) + if (chatbotConfig.gitVersioning) { + setGitVersioningStatus(chatbotConfig.gitVersioning.status) + } + } + + return () => {} + }, [dialogProps]) + + return ( + <> + + + + + Save + + + ) +} + +GitVersioning.propTypes = { + dialogProps: PropTypes.object +} + +export default GitVersioning \ No newline at end of file diff --git a/packages/ui/src/views/canvas/CanvasHeader.jsx b/packages/ui/src/views/canvas/CanvasHeader.jsx index ade71b6e2b6..b1a5083580b 100644 --- a/packages/ui/src/views/canvas/CanvasHeader.jsx +++ b/packages/ui/src/views/canvas/CanvasHeader.jsx @@ -5,10 +5,10 @@ import { useEffect, useRef, useState } from 'react' // material-ui import { useTheme } from '@mui/material/styles' -import { Avatar, Box, ButtonBase, Typography, Stack, TextField, Button } from '@mui/material' +import { Avatar, Box, ButtonBase, Typography, Stack, TextField, Button, Dialog, DialogTitle, DialogContent, DialogActions, OutlinedInput, Chip } from '@mui/material' // icons -import { IconSettings, IconChevronLeft, IconDeviceFloppy, IconPencil, IconCheck, IconX, IconCode } from '@tabler/icons-react' +import { IconSettings, IconChevronLeft, IconDeviceFloppy, IconPencil, IconCheck, IconX, IconCode, IconGitBranch, IconBrandGit } from '@tabler/icons-react' // project imports import Settings from '@/views/settings' @@ -23,6 +23,7 @@ import { Available } from '@/ui-component/rbac/available' // API import chatflowsApi from '@/api/chatflows' +import flowVersionApi from '@/api/flowversion' // Hooks import useApi from '@/hooks/useApi' @@ -31,10 +32,11 @@ import useApi from '@/hooks/useApi' import { generateExportFlowData } from '@/utils/genericHelper' import { uiBaseURL } from '@/store/constant' import { closeSnackbar as closeSnackbarAction, enqueueSnackbar as enqueueSnackbarAction, SET_CHATFLOW } from '@/store/actions' +import VersionsSideDrawer from './VersionSideDrawer' // ==============================|| CANVAS HEADER ||============================== // -const CanvasHeader = ({ chatflow, isAgentCanvas, isAgentflowV2, handleSaveFlow, handleDeleteFlow, handleLoadFlow }) => { +const CanvasHeader = ({ chatflow, isAgentCanvas, isAgentflowV2, handleSaveFlow, handleDeleteFlow, handleLoadFlow, isReadonly, commitId }) => { const theme = useTheme() const dispatch = useDispatch() const navigate = useNavigate() @@ -68,6 +70,25 @@ const CanvasHeader = ({ chatflow, isAgentCanvas, isAgentflowV2, handleSaveFlow, const updateChatflowApi = useApi(chatflowsApi.updateChatflow) const canvas = useSelector((state) => state.canvas) + + const checkGitConfigApi = useApi(flowVersionApi.check) + const [showVersionSideDrawer, setShowVersionSideDrawer] = useState(false) + const [versionDrawerDialogProps, setVersionDrawerDialogProps] = useState({}) + const [gitVersioningEnabled, setGitVersioningEnabled] = useState(false) + const [isActiveGitConfig, setIsActiveGitConfig] = useState(false) + + const openVersionsDrawer = () => { + setVersionDrawerDialogProps({ + id: chatflow?.id, + isDirty: chatflow?.isDirty + }) + setShowVersionSideDrawer(true) + } + + const closeVersionsDrawer = (refreshData) => { + setShowVersionSideDrawer(false) + } + const onSettingsItemClick = (setting) => { setSettingsOpen(false) @@ -160,6 +181,23 @@ const CanvasHeader = ({ chatflow, isAgentCanvas, isAgentflowV2, handleSaveFlow, handleLoadFlow(file) } + const onVersionHistoryClick = (commitId) => { + setShowVersionSideDrawer(false) + if (isAgentCanvas) { + if (commitId) { + navigate(`/agentcanvas/${chatflow.id}?commitId=${commitId}`) + } else { + navigate(`/agentcanvas/${chatflow.id}`) + } + } else { + if (commitId) { + navigate(`/canvas/${chatflow.id}?commitId=${commitId}`) + } else { + navigate(`/canvas/${chatflow.id}`) + } + } + } + const submitFlowName = () => { if (chatflow.id) { const updateBody = { @@ -234,9 +272,24 @@ const CanvasHeader = ({ chatflow, isAgentCanvas, isAgentflowV2, handleSaveFlow, // eslint-disable-next-line react-hooks/exhaustive-deps }, [updateChatflowApi.data]) + useEffect(() => { + if (checkGitConfigApi.data) { + setIsActiveGitConfig(checkGitConfigApi.data.isActive) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [checkGitConfigApi.data]) + useEffect(() => { if (chatflow) { setFlowName(chatflow.name) + setGitVersioningEnabled(false) + checkGitConfigApi.request() + if (chatflow.chatbotConfig) { + const chatbotConfig = JSON.parse(chatflow.chatbotConfig) + setGitVersioningEnabled(chatbotConfig?.gitVersioning?.status) + } + // if configuration dialog is open, update its data if (chatflowConfigurationDialogOpen) { setChatflowConfigurationDialogProps({ @@ -294,7 +347,7 @@ const CanvasHeader = ({ chatflow, isAgentCanvas, isAgentflowV2, handleSaveFlow, > {canvas.isDirty && *} {flowName} - {chatflow?.id && ( + {chatflow?.id && !isReadonly && ( - + - + + {gitVersioningEnabled && isActiveGitConfig && ( + <> + {chatflow?.isDirty ? ( + + ) : ( + // if it is not dirty and there is a last published commit, show the last published commit id and mark it as readonly + chatflow?.lastPublishedCommit && ( + <> + + {isReadonly && } + + ) + )} + {chatflow?.id && ( + + + + + + )} + + )} + {chatflow?.id && ( - + - + )} - + - + setChatflowConfigurationDialogOpen(false)} isAgentCanvas={isAgentCanvas} /> + ) } @@ -507,7 +605,9 @@ CanvasHeader.propTypes = { handleDeleteFlow: PropTypes.func, handleLoadFlow: PropTypes.func, isAgentCanvas: PropTypes.bool, - isAgentflowV2: PropTypes.bool + isAgentflowV2: PropTypes.bool, + isReadonly: PropTypes.bool, + commitId: PropTypes.string } export default CanvasHeader diff --git a/packages/ui/src/views/canvas/CanvasNode.jsx b/packages/ui/src/views/canvas/CanvasNode.jsx index 471b4d07960..f9e477c1eb5 100644 --- a/packages/ui/src/views/canvas/CanvasNode.jsx +++ b/packages/ui/src/views/canvas/CanvasNode.jsx @@ -56,6 +56,7 @@ const CanvasNode = ({ data }) => { const onDialogClicked = () => { const dialogProps = { data, + disabled: isReadonly, inputParams: data.inputParams.filter((inputParam) => !inputParam.hidden).filter((param) => param.additionalParams), confirmButtonName: 'Save', cancelButtonName: 'Cancel' @@ -70,6 +71,8 @@ const CanvasNode = ({ data }) => { else return theme.palette.grey[900] + 50 } + const isReadonly = canvas?.isReadonly + useEffect(() => { const componentNode = canvas.componentNodes.find((nd) => nd.name === data.name) if (componentNode) { @@ -102,6 +105,7 @@ const CanvasNode = ({ data }) => { open={getNodeInfoOpenStatus()} onClose={handleClose} onOpen={handleOpen} + disabled={isReadonly} disableFocusListener={true} title={
{ onClick={() => { duplicateNode(data.id) }} + disabled={isReadonly} sx={{ height: '35px', width: '35px', '&:hover': { color: theme?.palette.primary.main } }} color={theme?.customization?.isDarkMode ? theme.colors?.paper : 'inherit'} > @@ -126,6 +131,7 @@ const CanvasNode = ({ data }) => { onClick={() => { deleteNode(data.id) }} + disabled={isReadonly} sx={{ height: '35px', width: '35px', '&:hover': { color: 'red' } }} color={theme?.customization?.isDarkMode ? theme.colors?.paper : 'inherit'} > @@ -137,6 +143,7 @@ const CanvasNode = ({ data }) => { setInfoDialogProps({ data }) setShowInfoDialog(true) }} + disabled={isReadonly} sx={{ height: '35px', width: '35px', '&:hover': { color: theme?.palette.secondary.main } }} color={theme?.customization?.isDarkMode ? theme.colors?.paper : 'inherit'} > @@ -222,7 +229,7 @@ const CanvasNode = ({ data }) => { )} {data.inputAnchors.map((inputAnchor, index) => ( - + ))} {data.inputParams .filter((inputParam) => !inputParam.hidden) @@ -232,6 +239,7 @@ const CanvasNode = ({ data }) => { key={index} inputParam={inputParam} data={data} + disabled={isReadonly} onHideNodeInfoDialog={(status) => { if (status) { setIsForceCloseNodeInfo(true) @@ -252,7 +260,7 @@ const CanvasNode = ({ data }) => { : 0 }} > -
diff --git a/packages/ui/src/views/canvas/CredentialInputHandler.jsx b/packages/ui/src/views/canvas/CredentialInputHandler.jsx index e713a22d1fa..fd38f15ed82 100644 --- a/packages/ui/src/views/canvas/CredentialInputHandler.jsx +++ b/packages/ui/src/views/canvas/CredentialInputHandler.jsx @@ -115,7 +115,7 @@ const CredentialInputHandler = ({ inputParam, data, onSelect, disabled = false } onCreateNew={() => addAsyncOption(inputParam.name)} /> {credentialId && hasPermission('credentials:update') && ( - editCredential(credentialId)}> + editCredential(credentialId)} disabled={disabled}> )} diff --git a/packages/ui/src/views/canvas/VersionSideDrawer.jsx b/packages/ui/src/views/canvas/VersionSideDrawer.jsx new file mode 100644 index 00000000000..319bd875ec8 --- /dev/null +++ b/packages/ui/src/views/canvas/VersionSideDrawer.jsx @@ -0,0 +1,465 @@ +import { useEffect, useState, useMemo } from 'react' +import PropTypes from 'prop-types' +import dayjs from 'dayjs' +import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction } from '@/store/actions' +import { Button, Box, SwipeableDrawer, Stack, Typography, Divider, Link, Tooltip, IconButton, useTheme, Menu, MenuItem } from '@mui/material' +import { IconX, IconDotsVertical, IconEye, IconFileText, IconCopy } from '@tabler/icons-react' +import useApi from '@/hooks/useApi' +import versioningApi from '@/api/flowversion' +import { useDispatch } from 'react-redux' +import GitCommitDialog from '@/ui-component/dialog/GitCommitDialog' +import { IconGitBranch, IconBrandGithub } from '@tabler/icons-react' +import { IconBrandGit } from '@tabler/icons-react' +import { IconGitCommit } from '@tabler/icons-react' +import { IconAlertTriangle } from '@tabler/icons-react' + +// VersionHistory type structure: +// { +// repository: string; // Format: "owner/repo" +// branch: string; // Branch name (e.g., "main") +// filename: string; // File name (e.g., "chatflow-name.json") +// commits: { // Array of commit objects +// commitId: string; +// date: string; +// message: string; +// filePath: string; +// }[] +// } + +const VersionsSideDrawer = ({ show, dialogProps, onClickFunction, onSelectVersion, commitId }) => { + const theme = useTheme() + const onOpen = () => { } + const [versionHistory, setVersionHistory] = useState({}) + const [publishDialogOpen, setPublishDialogOpen] = useState(false) + const [commitMessage, setCommitMessage] = useState('') + const [isPublishing, setIsPublishing] = useState(false) + const [menuAnchorEl, setMenuAnchorEl] = useState(null) + const [selectedCommit, setSelectedCommit] = useState(null) + const [isDraftAvailable, setIsDraftAvailable] = useState(false) + + // const publishNewVersionApi = useApi(versioningApi.publishFlow) + const getAllVersionsApi = useApi(versioningApi.getFlowVersions) + + const dispatch = useDispatch() + const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args)) + const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args)) + + useEffect(() => { + if (dialogProps.id && show) { + getAllVersionsApi.request(dialogProps.id) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dialogProps]) + + useEffect(() => { + if (getAllVersionsApi.data) { + setVersionHistory(getAllVersionsApi.data) + setIsDraftAvailable(getAllVersionsApi.data.draft) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [getAllVersionsApi.data]) + + const closeAndRefreshAsNeeded = () => { + onClickFunction(undefined) + } + + function groupByDate(commits) { + if (!commits || !Array.isArray(commits)) return {}; + return commits.reduce((acc, commit) => { + const date = dayjs(commit.date).format('MMM DD, YYYY'); + if (!acc[date]) acc[date] = []; + acc[date].push(commit); + return acc; + }, {}); + } + + const publishNewVersion = () => { + setCommitMessage('') + setPublishDialogOpen(true) + } + + const handlePublishCommit = async () => { + setIsPublishing(true) + try { + const result = await versioningApi.publishFlow(dialogProps.id, commitMessage) + if (result.data?.success) { + enqueueSnackbar({ + message: ( + + Flow published to Git. View Commit
+ Commit ID: {result.data.commitId} +
+ ), + options: { + key: new Date().getTime() + Math.random(), + variant: 'success', + autoHideDuration: 3000, + persist: true, + action: (key) => ( + + ) + } + }) + onSelectVersion(result.data.commitId) + } else { + enqueueSnackbar({ + message: result.error || 'Failed to publish flow to Git', + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + } + } catch (error) { + enqueueSnackbar({ + message: 'Failed to publish flow to Git', + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + } finally { + setIsPublishing(false) + setPublishDialogOpen(false) + } + } + + const handleMenuClick = (event, commit) => { + event.preventDefault() + event.stopPropagation() + setMenuAnchorEl(event.currentTarget) + setSelectedCommit(commit) + } + + const handleMenuClose = () => { + setMenuAnchorEl(null) + setSelectedCommit(null) + } + + const handleShowCommit = () => { + handleMenuClose() + onSelectVersion(selectedCommit?.commitId) + } + + const handleMakeDraft = async () => { + handleMenuClose() + try { + const result = await versioningApi.makeDraft(dialogProps.id, selectedCommit?.commitId) + if (result.data?.isDirty === true) { + enqueueSnackbar({ + message: 'Draft created', + options: { + key: new Date().getTime() + Math.random(), + variant: 'success', + autoHideDuration: 2000 + } + }) + } else { + enqueueSnackbar({ + message: result.error || 'Failed to make draft', + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + autoHideDuration: 2000 + } + }) + } + onSelectVersion(undefined) + } catch (error) { + enqueueSnackbar({ + message: 'Failed to make draft', + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + autoHideDuration: 2000 + } + }) + } + } + + const handleCopyCommitId = () => { + handleMenuClose() + if (selectedCommit?.commitId) { + navigator.clipboard.writeText(selectedCommit.commitId).then(() => { + enqueueSnackbar({ + message: 'Commit ID copied to clipboard', + options: { + key: new Date().getTime() + Math.random(), + variant: 'success', + autoHideDuration: 2000 + } + }) + }).catch(() => { + enqueueSnackbar({ + message: 'Failed to copy commit ID', + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + autoHideDuration: 2000 + } + }) + }) + } + } + + const handleViewInGithub = () => { + handleMenuClose() + if (selectedCommit?.filePath) { + window.open(selectedCommit.filePath, '_blank', 'noopener,noreferrer') + } + } + + const grouped = useMemo(() => groupByDate(versionHistory?.commits), [versionHistory?.commits]) + + return ( + + + + + + Versions + + + + + + + + + + + {dialogProps?.id && ( + + )} + + + + {versionHistory.repository} + + + {versionHistory.branch} + + + {/* if commitId is provided, highlight the commit in the list and have a highlighted left border */} + {versionHistory?.commits?.length > 0 && ( + <> + + + + {isDraftAvailable && ( + <> + + Unpublished Changes + + + + + {'Current Draft'} + + + + + handleMenuClick(e, 'draft')} + > + + + + + + + )} + + {Object.entries(grouped).map(([date, commits]) => ( + + + Commits on {date} + + {commits.map((commit, idx) => ( + + + + {commit.message} + + + + Commit  + + {commit.commitId.slice(0, 7)} + + + + {dayjs(commit.date).format('hh:mm:ss A')} + + + + {commit.external ? ( + + + + + + + + ) : ( + + + handleMenuClick(e, commit)} + > + + + + + )} + + ))} + + ))} + + + End of Commits for this flow + + + + + )} + {versionHistory?.commits?.length === 0 && ( + + + No versions history found for this flow + + + )} + + + + {selectedCommit === commitId ? 'Current Commit' : 'Show Commit'} + + + + Make Draft + + + + + Copy Commit ID + + + {versionHistory.provider === 'GitHub' ? : } + View in {versionHistory.provider} + + + setCommitMessage(e.target.value)} + onCancel={() => setPublishDialogOpen(false)} + onCommit={handlePublishCommit} + loading={isPublishing} + /> + + ) +} + +VersionsSideDrawer.propTypes = { + show: PropTypes.bool, + dialogProps: PropTypes.object, + onClickFunction: PropTypes.func, + onSelectVersion: PropTypes.func, + commitId: PropTypes.string +} + +export default VersionsSideDrawer \ No newline at end of file diff --git a/packages/ui/src/views/canvas/index.jsx b/packages/ui/src/views/canvas/index.jsx index ebfbd0506fa..e17e100c916 100644 --- a/packages/ui/src/views/canvas/index.jsx +++ b/packages/ui/src/views/canvas/index.jsx @@ -8,6 +8,7 @@ import { REMOVE_DIRTY, SET_DIRTY, SET_CHATFLOW, + SET_READONLY, enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction } from '@/store/actions' @@ -31,6 +32,7 @@ import { flowContext } from '@/store/context/ReactFlowContext' // API import nodesApi from '@/api/nodes' import chatflowsApi from '@/api/chatflows' +import flowVersionApi from '@/api/flowversion' // Hooks import useApi from '@/hooks/useApi' @@ -68,12 +70,24 @@ const Canvas = () => { const { state } = useLocation() const templateFlowData = state ? state.templateFlowData : '' - const URLpath = document.location.pathname.toString().split('/') + // Utility function to safely parse URL parameters + const getUrlParameter = (paramName) => { + try { + const urlParams = new URLSearchParams(document.location.search) + return urlParams.get(paramName) + } catch (error) { + console.warn(`Error parsing URL parameter ${paramName}:`, error) + return null + } + } + + const URLpath = window.location.pathname.split('/') const chatflowId = URLpath[URLpath.length - 1] === 'canvas' || URLpath[URLpath.length - 1] === 'agentcanvas' ? '' : URLpath[URLpath.length - 1] const isAgentCanvas = URLpath.includes('agentcanvas') ? true : false const canvasTitle = URLpath.includes('agentcanvas') ? 'Agent' : 'Chatflow' + const commitId = getUrlParameter('commitId') const { confirm } = useConfirm() const dispatch = useDispatch() @@ -105,6 +119,11 @@ const Canvas = () => { const [chatflowName, setChatflowName] = useState('') const [flowData, setFlowData] = useState('') + // versioning + const [isActiveGitConfig, setIsActiveGitConfig] = useState(false) + const [isReadonly, setIsReadonly] = useState(false) + + // ==============================|| Chatflow API ||============================== // const getNodesApi = useApi(nodesApi.getAllNodes) @@ -112,7 +131,6 @@ const Canvas = () => { const updateChatflowApi = useApi(chatflowsApi.updateChatflow) const getSpecificChatflowApi = useApi(chatflowsApi.getSpecificChatflow) const getHasChatflowChangedApi = useApi(chatflowsApi.getHasChatflowChanged) - // ==============================|| Events & Actions ||============================== // const onConnect = (params) => { @@ -417,6 +435,8 @@ const Canvas = () => { setLasUpdatedDateTime(chatflow.updatedDate) setNodes(initialFlow.nodes || []) setEdges(initialFlow.edges || []) + setIsReadonly(isActiveGitConfig && chatflow.lastPublishedCommit && !chatflow.isDirty ? true : false) + dispatch({ type: SET_READONLY, isReadonly: chatflow.lastPublishedCommit && !chatflow.isDirty ? true : false }) dispatch({ type: SET_CHATFLOW, chatflow }) } else if (getSpecificChatflowApi.error) { errorFailed(`Failed to retrieve ${canvasTitle}: ${getSpecificChatflowApi.error.response.data.message}`) @@ -490,6 +510,10 @@ const Canvas = () => { checkIfSyncNodesAvailable(flowData.nodes || []) } + flowVersionApi.check().then((res) => { + setIsActiveGitConfig(res.data.isActive) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [canvasDataStore.chatflow]) @@ -498,7 +522,10 @@ const Canvas = () => { setIsSyncNodesButtonEnabled(false) setIsUpsertButtonEnabled(false) if (chatflowId) { - getSpecificChatflowApi.request(chatflowId) + // commitId is the query param not a path param + // http://localhost:8080/canvas/84620a63-cb4b-4a82-884b-21d72b3ea72f?commitId=f6f9c7cbf5ed00d6e48e5ed275c85bb8554661d5 + const commitId = getUrlParameter('commitId') + getSpecificChatflowApi.request(chatflowId, commitId) } else { if (localStorage.getItem('duplicatedFlowData')) { handleLoadFlow(localStorage.getItem('duplicatedFlowData')) @@ -523,7 +550,7 @@ const Canvas = () => { } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [commitId]) useEffect(() => { setCanvasDataStore(canvas) @@ -576,6 +603,8 @@ const Canvas = () => { handleDeleteFlow={handleDeleteFlow} handleLoadFlow={handleLoadFlow} isAgentCanvas={isAgentCanvas} + isReadonly={isActiveGitConfig && isReadonly} + commitId={commitId} /> @@ -623,8 +652,11 @@ const Canvas = () => { - - {isSyncNodesButtonEnabled && ( + {!isReadonly && isActiveGitConfig && ( + + )} + + {isSyncNodesButtonEnabled && (!isReadonly && isActiveGitConfig) && ( { + const portalElement = document.getElementById('portal') + const [form, setForm] = useState({ + provider: 'github', + repository: '', + authMode: 'token', + username: '', + secret: '', + branchName: 'main', + isActive: true + }) + const [errors, setErrors] = useState({}) + const [testResult, setTestResult] = useState(null) + const [isTesting, setIsTesting] = useState(false) + const [branches, setBranches] = useState([]) + const [isLoadingBranches, setIsLoadingBranches] = useState(false) + + const dialogType = useMemo(() => dialogProps?.type || 'ADD', [dialogProps?.type]) + const data = useMemo(() => dialogProps?.data || {}, [dialogProps?.data]) + + useEffect(() => { + if (dialogType === 'EDIT' && data && Object.keys(data).length > 0) { + setForm({ + provider: data.provider || 'github', + repository: data.repository || '', + authMode: data.authMode || 'token', + username: data.username || '', + secret: '', // never prefill secret + branchName: data.branchName || 'main', + isActive: data.isActive !== undefined ? data.isActive : true + }) + // Load branches for edit mode + if (data.id) { + loadBranches(data.id) + } + } else if (dialogType === 'ADD') { + setForm({ + provider: 'github', + repository: '', + authMode: 'token', + username: '', + secret: '', + branchName: 'main', + isActive: true + }) + } + // Clear test result when dialog opens + setTestResult(null) + }, [dialogType, data]) + + const loadBranches = async (configId) => { + setIsLoadingBranches(true) + try { + const response = await gitconfigApi.getBranches(configId) + const branchesData = response?.data || response || [] + setBranches(branchesData) + } catch (error) { + console.error('Failed to load branches:', error) + setBranches([]) + } finally { + setIsLoadingBranches(false) + } + } + + const handleRefreshBranches = () => { + if (data?.id) { + loadBranches(data.id) + } + } + + const handleChange = (e) => { + const { name, value, type: inputType, checked } = e.target + setForm((prev) => ({ + ...prev, + [name]: inputType === 'checkbox' ? checked : value + })) + // Clear test result when form changes + setTestResult(null) + } + + const onProviderChange = (provider) => { + setForm((prev) => ({ + ...prev, + provider: provider + })) + setTestResult(null) + } + + const onAuthModeChange = (authMode) => { + setForm((prev) => ({ + ...prev, + authMode: authMode + })) + setTestResult(null) + } + + const handleTestConnection = async () => { + const newErrors = validate() + setErrors(newErrors) + if (Object.keys(newErrors).length === 0) { + setIsTesting(true) + setTestResult(null) + try { + const response = await gitconfigApi.testGitConfig(form) + // Handle different response structures + const responseData = response?.data || response + + // Check the success field from the server response + if (responseData?.success === true) { + setTestResult({ + success: true, + data: responseData + }) + } else { + // Server returned success: false with error message + setTestResult({ + success: false, + error: responseData?.error || 'Test connection failed' + }) + } + } catch (error) { + console.error('Test connection error:', error) + // Handle different error response structures + let errorMessage = 'Test connection failed' + if (error?.response?.data?.error) { + errorMessage = error.response.data.error + } else if (error?.response?.data?.message) { + errorMessage = error.response.data.message + } else if (error?.message) { + errorMessage = error.message + } + + setTestResult({ + success: false, + error: errorMessage + }) + } finally { + setIsTesting(false) + } + } + } + + const validate = () => { + const newErrors = {} + if (dialogType === 'ADD') { + if (!form.username) newErrors.username = 'Username is required' + if (!form.repository) newErrors.repository = 'Repository name is required' + if (!form.secret) newErrors.secret = 'Token is required' + } + return newErrors + } + + const handleSubmit = (e) => { + e.preventDefault() + const newErrors = validate() + setErrors(newErrors) + if (Object.keys(newErrors).length === 0) { + const submitData = { ...form } + if (dialogType === 'EDIT') submitData.id = data.id + onConfirm(submitData) + } + } + + const renderTestResult = () => { + if (!testResult) return null + + if (testResult.success && testResult.data) { + const { permissions } = testResult.data + if (!permissions) return null + + return ( + + + + Connection successful! ✅ + + + + Repository: + + + + {permissions.private ? ( + } + label="Private" + size="small" + color="primary" + variant="outlined" + /> + ) : ( + } + label="Public" + size="small" + color="primary" + variant="outlined" + /> + )} + + + + + Permissions: + + {permissions.permissions?.admin && ( + } + label="Admin" + size="small" + color="primary" + variant="outlined" + /> + )} + {permissions.permissions?.push && ( + } + label="Push" + size="small" + color="primary" + variant="outlined" + /> + )} + {permissions.permissions?.pull && ( + } + label="Pull" + size="small" + color="primary" + variant="outlined" + /> + )} + {permissions.permissions?.maintain && ( + } + label="Maintain" + size="small" + color="primary" + variant="outlined" + /> + )} + {permissions.permissions?.triage && ( + } + label="Triage" + size="small" + color="primary" + variant="outlined" + /> + )} + + + + ) + } else { + return ( + + + + Connection failed! ❌ + + + {testResult?.error || 'Unknown error occurred'} + + + + ) + } + } + + const component = show ? ( + + +
+ + {dialogType === 'ADD' ? 'Add Git Config' : 'Edit Git Config'} +
+
+
+ + {renderTestResult()} + + Provider * + {/* Provider is disabled as only GitHub is currently supported, support for other providers will be added in the future */} + + + + Auth Mode * + {/* Auth Mode is disabled as only Token-based authentication is currently supported, support for other auth modes will be added in the future */} + + + + Username * + + + + Repository Name * + + + {dialogType === 'ADD' && ( + + Token * + + + + + + )} + + Branch Name * + + + + + {dialogType === 'EDIT' && ( + + + + + + )} + + + {dialogType === 'EDIT' && ( + + + } + label="Active" + /> + + Uncheck to deactivate this git configuration + + + )} + + + {dialogType === 'ADD' && ( + + )} + + + + {dialogProps.confirmButtonName || (dialogType === 'ADD' ? 'Add' : 'Save')} + + + +
+
+ ) : null + + return createPortal(component, portalElement) +} + +AddEditGitConfigDialog.propTypes = { + show: PropTypes.bool, + dialogProps: PropTypes.object, + onCancel: PropTypes.func, + onConfirm: PropTypes.func +} + +export default AddEditGitConfigDialog \ No newline at end of file diff --git a/packages/ui/src/views/gitconfig/index.jsx b/packages/ui/src/views/gitconfig/index.jsx new file mode 100644 index 00000000000..4c8f9b9e44f --- /dev/null +++ b/packages/ui/src/views/gitconfig/index.jsx @@ -0,0 +1,240 @@ +import { useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction } from '@/store/actions' + +// Hooks +import useConfirm from '@/hooks/useConfirm' + +// material-ui +import { + Button, + Box, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Chip, + Skeleton +} from '@mui/material' +import { useTheme } from '@mui/material/styles' + +// project imports +import MainCard from '@/ui-component/cards/MainCard' +import ConfirmDialog from '@/ui-component/dialog/ConfirmDialog' + +import ErrorBoundary from '@/ErrorBoundary' +import gitConfigApi from '@/api/gitconfig' +import ViewHeader from '@/layout/MainLayout/ViewHeader' +import { StyledPermissionButton, PermissionIconButton } from '@/ui-component/button/RBACButtons' +import { StyledTableCell, StyledTableRow } from '@/ui-component/table/TableStyles' +import AddEditGitConfigDialog from './AddEditGitConfigDialog' + +// icons +import { IconTrash, IconEdit, IconCheck, IconPlus } from '@tabler/icons-react' + +const GitConfigList = () => { + const dispatch = useDispatch() + const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args)) + const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args)) + + const [isLoading, setLoading] = useState(true) + const [gitConfigs, setGitConfigs] = useState([]) + + const [error, setError] = useState(null) + // Placeholder for add/edit dialog state + const [showDialog, setShowDialog] = useState(false) + const [dialogType, setDialogType] = useState('ADD') + const [selectedConfig, setSelectedConfig] = useState(null) + + const theme = useTheme() + const customization = useSelector((state) => state.customization) + const { confirm } = useConfirm() + + const fetchGitConfigs = async () => { + setLoading(true) + try { + const res = await gitConfigApi.getAllGitConfigs() + setGitConfigs(res.data || res) + } catch (err) { + setError(err) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchGitConfigs() + }, []) + + const handleAdd = () => { + setDialogType('ADD') + setSelectedConfig(null) + setShowDialog(true) + } + + const handleEdit = (config) => { + setDialogType('EDIT') + setSelectedConfig(config) + setShowDialog(true) + } + + const handleDelete = async (config) => { + const confirmPayload = { + title: `Delete`, + description: `Delete Git Config for provider ${config.provider}?`, + confirmButtonName: 'Delete', + cancelButtonName: 'Cancel' + } + const isConfirmed = await confirm(confirmPayload) + + if (isConfirmed) { + try { + await gitConfigApi.deleteGitConfig(config.id) + enqueueSnackbar({ + message: 'Git Config deleted', + options: { variant: 'success' } + }) + fetchGitConfigs() + } catch (err) { + enqueueSnackbar({ + message: 'Failed to delete Git Config', + options: { variant: 'error' } + }) + } + } + } + // Placeholder for add/edit dialog submit + const handleDialogSubmit = async (data) => { + setShowDialog(false) + try { + if (dialogType === 'ADD') { + await gitConfigApi.createGitConfig(data) + enqueueSnackbar({ message: 'Git Config added', options: { variant: 'success' } }) + } else { + await gitConfigApi.updateGitConfig(selectedConfig.id, data) + enqueueSnackbar({ message: 'Git Config updated', options: { variant: 'success' } }) + } + fetchGitConfigs() + } catch (err) { + enqueueSnackbar({ message: 'Failed to save Git Config', options: { variant: 'error' } }) + } + } + + return ( + + {error ? ( + + ) : ( + + + {/* TODO: Change the permissionId to 'gitconfig:create' when support for other providers is added */} + } + > + Add New + + + + + + + + Provider + Repository + Username + Auth Mode + Branch Name + Active + Actions + + + + {isLoading ? ( + + + + ) : gitConfigs.length === 0 ? ( + + No Git Configs found. + + ) : ( + gitConfigs.map((config) => ( + + {config.provider} + {config.repository} + {config.username} + {config.authMode} + {config.branchName} + + {config.isActive ? ( + } /> + ) : ( + + )} + + + handleEdit(config)} + > + + + handleDelete(config)} + > + + + + + )) + )} + +
+
+
+
+ )} + setShowDialog(false)} + onConfirm={handleDialogSubmit} + /> + +
+ ) +} + +export default GitConfigList