feat: Add AI Agent workflow action node (#12650)
https://github.com/user-attachments/assets/8593e488-cb00-4fd2-b903-5ba5766e0254 --------- Co-authored-by: Antoine Moreaux <moreaux.antoine@gmail.com> Co-authored-by: martmull <martmull@hotmail.fr> Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: Baptiste Devessier <baptiste@devessier.fr> Co-authored-by: Joseph Chiang <josephj6802@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Guillim <guillim@users.noreply.github.com> Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com> Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com> Co-authored-by: Marie <51697796+ijreilly@users.noreply.github.com> Co-authored-by: Naifer <161821705+omarNaifer12@users.noreply.github.com> Co-authored-by: prastoin <paul@twenty.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions <github-actions@twenty.com> Co-authored-by: Thomas Trompette <thomas.trompette@sfr.fr> Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> Co-authored-by: Ajay A Adsule <103304466+AjayAdsule@users.noreply.github.com> Co-authored-by: bosiraphael <raphael.bosi@gmail.com> Co-authored-by: Charles Bochet <charles@twenty.com> Co-authored-by: Marty <91310557+real-marty@users.noreply.github.com> Co-authored-by: Félix Malfait <felix@twenty.com> Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Co-authored-by: Weiko <corentin@twenty.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: nitin <142569587+ehconitin@users.noreply.github.com>
This commit is contained in:
@ -9,6 +9,7 @@ import { AI_DRIVER } from 'src/engine/core-modules/ai/ai.constants';
|
||||
import { AiService } from 'src/engine/core-modules/ai/ai.service';
|
||||
import { AiController } from 'src/engine/core-modules/ai/controllers/ai.controller';
|
||||
import { OpenAIDriver } from 'src/engine/core-modules/ai/drivers/openai.driver';
|
||||
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
|
||||
@Global()
|
||||
@ -33,8 +34,8 @@ export class AiModule {
|
||||
module: AiModule,
|
||||
imports: [FeatureFlagModule],
|
||||
controllers: [AiController],
|
||||
providers: [AiService, provider],
|
||||
exports: [AiService],
|
||||
providers: [AiService, AIBillingService, provider],
|
||||
exports: [AiService, AIBillingService],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
export enum ModelProvider {
|
||||
OPENAI = 'openai',
|
||||
ANTHROPIC = 'anthropic',
|
||||
}
|
||||
|
||||
export type ModelId =
|
||||
| 'gpt-4o'
|
||||
| 'gpt-4o-mini'
|
||||
| 'gpt-4-turbo'
|
||||
| 'claude-opus-4-20250514'
|
||||
| 'claude-sonnet-4-20250514'
|
||||
| 'claude-3-5-haiku-20241022';
|
||||
|
||||
export interface AIModelConfig {
|
||||
modelId: ModelId;
|
||||
label: string;
|
||||
provider: ModelProvider;
|
||||
inputCostPer1kTokensInCents: number;
|
||||
outputCostPer1kTokensInCents: number;
|
||||
}
|
||||
|
||||
export const AI_MODELS: AIModelConfig[] = [
|
||||
{
|
||||
modelId: 'gpt-4o',
|
||||
label: 'GPT-4o',
|
||||
provider: ModelProvider.OPENAI,
|
||||
inputCostPer1kTokensInCents: 0.25,
|
||||
outputCostPer1kTokensInCents: 1.0,
|
||||
},
|
||||
{
|
||||
modelId: 'gpt-4o-mini',
|
||||
label: 'GPT-4o Mini',
|
||||
provider: ModelProvider.OPENAI,
|
||||
inputCostPer1kTokensInCents: 0.015,
|
||||
outputCostPer1kTokensInCents: 0.06,
|
||||
},
|
||||
{
|
||||
modelId: 'gpt-4-turbo',
|
||||
label: 'GPT-4 Turbo',
|
||||
provider: ModelProvider.OPENAI,
|
||||
inputCostPer1kTokensInCents: 1.0,
|
||||
outputCostPer1kTokensInCents: 3.0,
|
||||
},
|
||||
{
|
||||
modelId: 'claude-opus-4-20250514',
|
||||
label: 'Claude Opus 4',
|
||||
provider: ModelProvider.ANTHROPIC,
|
||||
inputCostPer1kTokensInCents: 1.5,
|
||||
outputCostPer1kTokensInCents: 7.5,
|
||||
},
|
||||
{
|
||||
modelId: 'claude-sonnet-4-20250514',
|
||||
label: 'Claude Sonnet 4',
|
||||
provider: ModelProvider.ANTHROPIC,
|
||||
inputCostPer1kTokensInCents: 0.3,
|
||||
outputCostPer1kTokensInCents: 1.5,
|
||||
},
|
||||
{
|
||||
modelId: 'claude-3-5-haiku-20241022',
|
||||
label: 'Claude Haiku 3.5',
|
||||
provider: ModelProvider.ANTHROPIC,
|
||||
inputCostPer1kTokensInCents: 0.08,
|
||||
outputCostPer1kTokensInCents: 0.4,
|
||||
},
|
||||
];
|
||||
@ -0,0 +1,2 @@
|
||||
// Configuration: $0.001 = 1 credit
|
||||
export const DOLLAR_TO_CREDIT_MULTIPLIER = 1000; // 1 / 0.001 = 1000 credits per dollar
|
||||
@ -0,0 +1,90 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
|
||||
import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
|
||||
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
|
||||
import { AIBillingService } from './ai-billing.service';
|
||||
|
||||
describe('AIBillingService', () => {
|
||||
let service: AIBillingService;
|
||||
let mockWorkspaceEventEmitter: jest.Mocked<WorkspaceEventEmitter>;
|
||||
|
||||
const mockTokenUsage = {
|
||||
promptTokens: 1000,
|
||||
completionTokens: 500,
|
||||
totalTokens: 1500,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockEventEmitterMethods = {
|
||||
emitCustomBatchEvent: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AIBillingService,
|
||||
{
|
||||
provide: WorkspaceEventEmitter,
|
||||
useValue: mockEventEmitterMethods,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AIBillingService>(AIBillingService);
|
||||
mockWorkspaceEventEmitter = module.get(WorkspaceEventEmitter);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('calculateCost', () => {
|
||||
it('should calculate cost correctly for valid model and token usage', async () => {
|
||||
const costInCents = await service.calculateCost('gpt-4o', mockTokenUsage);
|
||||
|
||||
// Expected: (1000/1000 * 0.25) + (500/1000 * 1.0) = 0.25 + 0.5 = 0.75 cents
|
||||
expect(costInCents).toBe(0.75);
|
||||
});
|
||||
|
||||
it('should calculate cost correctly with different token usage', async () => {
|
||||
const differentTokenUsage = {
|
||||
promptTokens: 2000,
|
||||
completionTokens: 1000,
|
||||
totalTokens: 3000,
|
||||
};
|
||||
|
||||
const costInCents = await service.calculateCost(
|
||||
'gpt-4o',
|
||||
differentTokenUsage,
|
||||
);
|
||||
|
||||
// Expected: (2000/1000 * 0.25) + (1000/1000 * 1.0) = 0.5 + 1.0 = 1.5 cents
|
||||
expect(costInCents).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateAndBillUsage', () => {
|
||||
it('should calculate cost and emit billing event when model exists', async () => {
|
||||
await service.calculateAndBillUsage(
|
||||
'gpt-4o',
|
||||
mockTokenUsage,
|
||||
'workspace-1',
|
||||
);
|
||||
|
||||
// Expected credits: (0.75 cents / 100) * 1000 = 0.0075 * 1000 = 7.5 credits, rounded to 8
|
||||
expect(
|
||||
mockWorkspaceEventEmitter.emitCustomBatchEvent,
|
||||
).toHaveBeenCalledWith(
|
||||
BILLING_FEATURE_USED,
|
||||
[
|
||||
{
|
||||
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
|
||||
value: 8,
|
||||
},
|
||||
],
|
||||
'workspace-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,72 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
import { DOLLAR_TO_CREDIT_MULTIPLIER } from 'src/engine/core-modules/ai/constants/dollar-to-credit-multiplier';
|
||||
import { getAIModelById } from 'src/engine/core-modules/ai/utils/get-ai-model-by-id';
|
||||
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
|
||||
import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
|
||||
import { BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type';
|
||||
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
|
||||
export interface TokenUsage {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AIBillingService {
|
||||
private readonly logger = new Logger(AIBillingService.name);
|
||||
|
||||
constructor(private readonly workspaceEventEmitter: WorkspaceEventEmitter) {}
|
||||
|
||||
async calculateCost(modelId: ModelId, usage: TokenUsage): Promise<number> {
|
||||
const model = getAIModelById(modelId);
|
||||
|
||||
if (!model) {
|
||||
throw new Error(`AI model with id ${modelId} not found`);
|
||||
}
|
||||
|
||||
const inputCost =
|
||||
(usage.promptTokens / 1000) * model.inputCostPer1kTokensInCents;
|
||||
const outputCost =
|
||||
(usage.completionTokens / 1000) * model.outputCostPer1kTokensInCents;
|
||||
|
||||
const totalCost = inputCost + outputCost;
|
||||
|
||||
this.logger.log(
|
||||
`Calculated cost for model ${modelId}: ${totalCost} cents (input: ${inputCost}, output: ${outputCost})`,
|
||||
);
|
||||
|
||||
return totalCost;
|
||||
}
|
||||
|
||||
async calculateAndBillUsage(
|
||||
modelId: ModelId,
|
||||
usage: TokenUsage,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const costInCents = await this.calculateCost(modelId, usage);
|
||||
|
||||
const costInDollars = costInCents / 100;
|
||||
const creditsUsed = Math.round(costInDollars * DOLLAR_TO_CREDIT_MULTIPLIER);
|
||||
|
||||
this.sendAiTokenUsageEvent(workspaceId, creditsUsed);
|
||||
}
|
||||
|
||||
private sendAiTokenUsageEvent(
|
||||
workspaceId: string,
|
||||
creditsUsed: number,
|
||||
): void {
|
||||
this.workspaceEventEmitter.emitCustomBatchEvent<BillingUsageEvent>(
|
||||
BILLING_FEATURE_USED,
|
||||
[
|
||||
{
|
||||
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
|
||||
value: creditsUsed,
|
||||
},
|
||||
],
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Converts cost in cents to cost in credits
|
||||
* Formula: credits = cents / 100 * 1000 = cents * 10
|
||||
* @param cents - Cost in cents (real cost)
|
||||
* @returns Cost in credits (end-user cost)
|
||||
*/
|
||||
export const convertCentsToCredits = (cents: number): number => cents * 10;
|
||||
@ -0,0 +1,9 @@
|
||||
import {
|
||||
AI_MODELS,
|
||||
AIModelConfig,
|
||||
ModelId,
|
||||
} from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
|
||||
export const getAIModelById = (modelId: ModelId): AIModelConfig | undefined => {
|
||||
return AI_MODELS.find((model) => model.modelId === modelId);
|
||||
};
|
||||
@ -3,6 +3,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AiModule } from 'src/engine/core-modules/ai/ai.module';
|
||||
import { BillingResolver } from 'src/engine/core-modules/billing/billing.resolver';
|
||||
import { BillingAddWorkflowSubscriptionItemCommand } from 'src/engine/core-modules/billing/commands/billing-add-workflow-subscription-item.command';
|
||||
import { BillingSyncCustomerDataCommand } from 'src/engine/core-modules/billing/commands/billing-sync-customer-data.command';
|
||||
@ -41,6 +42,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
DomainManagerModule,
|
||||
MessageQueueModule,
|
||||
PermissionsModule,
|
||||
AiModule,
|
||||
TypeOrmModule.forFeature(
|
||||
[
|
||||
BillingSubscription,
|
||||
|
||||
@ -2,6 +2,10 @@ import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
|
||||
|
||||
import {
|
||||
ModelId,
|
||||
ModelProvider,
|
||||
} from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service';
|
||||
|
||||
import { ClientConfigController } from './client-config.controller';
|
||||
@ -44,6 +48,15 @@ describe('ClientConfigController', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
aiModels: [
|
||||
{
|
||||
modelId: 'gpt-4o' as ModelId,
|
||||
label: 'GPT-4o',
|
||||
provider: ModelProvider.OPENAI,
|
||||
inputCostPer1kTokensInCredits: 2.5,
|
||||
outputCostPer1kTokensInCredits: 10.0,
|
||||
},
|
||||
],
|
||||
authProviders: {
|
||||
google: true,
|
||||
magicLink: false,
|
||||
@ -92,8 +105,8 @@ describe('ClientConfigController', () => {
|
||||
|
||||
const result = await controller.getClientConfig();
|
||||
|
||||
expect(clientConfigService.getClientConfig).toHaveBeenCalled();
|
||||
expect(result).toEqual(mockClientConfig);
|
||||
expect(clientConfigService.getClientConfig).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,6 +2,10 @@ import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
|
||||
|
||||
import {
|
||||
ModelId,
|
||||
ModelProvider,
|
||||
} from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
import { BillingTrialPeriodDTO } from 'src/engine/core-modules/billing/dtos/billing-trial-period.dto';
|
||||
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
@ -11,6 +15,28 @@ registerEnumType(FeatureFlagKey, {
|
||||
name: 'FeatureFlagKey',
|
||||
});
|
||||
|
||||
registerEnumType(ModelProvider, {
|
||||
name: 'ModelProvider',
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class ClientAIModelConfig {
|
||||
@Field(() => String)
|
||||
modelId: ModelId;
|
||||
|
||||
@Field(() => String)
|
||||
label: string;
|
||||
|
||||
@Field(() => ModelProvider)
|
||||
provider: ModelProvider;
|
||||
|
||||
@Field(() => Number)
|
||||
inputCostPer1kTokensInCredits: number;
|
||||
|
||||
@Field(() => Number)
|
||||
outputCostPer1kTokensInCredits: number;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class Billing {
|
||||
@Field(() => Boolean)
|
||||
@ -88,6 +114,9 @@ export class ClientConfig {
|
||||
@Field(() => Billing, { nullable: false })
|
||||
billing: Billing;
|
||||
|
||||
@Field(() => [ClientAIModelConfig])
|
||||
aiModels: ClientAIModelConfig[];
|
||||
|
||||
@Field(() => Boolean)
|
||||
signInPrefilled: boolean;
|
||||
|
||||
|
||||
@ -9,6 +9,10 @@ import { DomainManagerService } from 'src/engine/core-modules/domain-manager/ser
|
||||
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
jest.mock('src/engine/core-modules/ai/constants/ai-models.const', () => ({
|
||||
AI_MODELS: [],
|
||||
}));
|
||||
|
||||
describe('ClientConfigService', () => {
|
||||
let service: ClientConfigService;
|
||||
let twentyConfigService: TwentyConfigService;
|
||||
@ -107,6 +111,7 @@ describe('ClientConfigService', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
aiModels: [],
|
||||
authProviders: {
|
||||
google: true,
|
||||
magicLink: false,
|
||||
@ -164,6 +169,7 @@ describe('ClientConfigService', () => {
|
||||
|
||||
expect(result.debugMode).toBe(false);
|
||||
expect(result.canManageFeatureFlags).toBe(false);
|
||||
expect(result.aiModels).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle missing captcha driver', async () => {
|
||||
@ -180,6 +186,7 @@ describe('ClientConfigService', () => {
|
||||
|
||||
expect(result.captcha.provider).toBeUndefined();
|
||||
expect(result.captcha.siteKey).toBe('site-key');
|
||||
expect(result.aiModels).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle missing support driver', async () => {
|
||||
@ -194,6 +201,7 @@ describe('ClientConfigService', () => {
|
||||
const result = await service.getClientConfig();
|
||||
|
||||
expect(result.support.supportDriver).toBe(SupportDriver.NONE);
|
||||
expect(result.aiModels).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle billing enabled with feature flags', async () => {
|
||||
|
||||
@ -3,7 +3,15 @@ import { Injectable } from '@nestjs/common';
|
||||
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
|
||||
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
|
||||
|
||||
import { ClientConfig } from 'src/engine/core-modules/client-config/client-config.entity';
|
||||
import {
|
||||
AI_MODELS,
|
||||
ModelProvider,
|
||||
} from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
import { convertCentsToCredits } from 'src/engine/core-modules/ai/utils/ai-cost.utils';
|
||||
import {
|
||||
ClientAIModelConfig,
|
||||
ClientConfig,
|
||||
} from 'src/engine/core-modules/client-config/client-config.entity';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
@ -18,6 +26,32 @@ export class ClientConfigService {
|
||||
async getClientConfig(): Promise<ClientConfig> {
|
||||
const captchaProvider = this.twentyConfigService.get('CAPTCHA_DRIVER');
|
||||
const supportDriver = this.twentyConfigService.get('SUPPORT_DRIVER');
|
||||
const openaiApiKey = this.twentyConfigService.get('OPENAI_API_KEY');
|
||||
const anthropicApiKey = this.twentyConfigService.get('ANTHROPIC_API_KEY');
|
||||
|
||||
const aiModels = AI_MODELS.reduce<ClientAIModelConfig[]>((acc, model) => {
|
||||
const isAvailable =
|
||||
(model.provider === ModelProvider.OPENAI && openaiApiKey) ||
|
||||
(model.provider === ModelProvider.ANTHROPIC && anthropicApiKey);
|
||||
|
||||
if (!isAvailable) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.push({
|
||||
modelId: model.modelId,
|
||||
label: model.label,
|
||||
provider: model.provider,
|
||||
inputCostPer1kTokensInCredits: convertCentsToCredits(
|
||||
model.inputCostPer1kTokensInCents,
|
||||
),
|
||||
outputCostPer1kTokensInCredits: convertCentsToCredits(
|
||||
model.outputCostPer1kTokensInCents,
|
||||
),
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const clientConfig: ClientConfig = {
|
||||
billing: {
|
||||
@ -38,6 +72,7 @@ export class ClientConfigService {
|
||||
},
|
||||
],
|
||||
},
|
||||
aiModels,
|
||||
authProviders: {
|
||||
google: this.twentyConfigService.get('AUTH_GOOGLE_ENABLED'),
|
||||
magicLink: false,
|
||||
|
||||
@ -984,6 +984,14 @@ export class ConfigVariables {
|
||||
})
|
||||
OPENAI_API_KEY: string;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.LLM,
|
||||
isSensitive: true,
|
||||
description: 'API key for Anthropic integration',
|
||||
type: ConfigVariableType.STRING,
|
||||
})
|
||||
ANTHROPIC_API_KEY: string;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.ServerConfig,
|
||||
description: 'Enable or disable multi-workspace support',
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { UseFilters, UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto';
|
||||
import { DeleteWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/delete-workflow-version-step-input.dto';
|
||||
import { SubmitFormStepInput } from 'src/engine/core-modules/workflow/dtos/submit-form-step-input.dto';
|
||||
@ -15,6 +17,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants';
|
||||
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
|
||||
import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service';
|
||||
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service';
|
||||
|
||||
@Resolver()
|
||||
@ -28,6 +31,7 @@ export class WorkflowStepResolver {
|
||||
constructor(
|
||||
private readonly workflowVersionStepWorkspaceService: WorkflowVersionStepWorkspaceService,
|
||||
private readonly workflowRunWorkspaceService: WorkflowRunWorkspaceService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => WorkflowActionDTO)
|
||||
@ -36,6 +40,19 @@ export class WorkflowStepResolver {
|
||||
@Args('input')
|
||||
input: CreateWorkflowVersionStepInput,
|
||||
): Promise<WorkflowActionDTO> {
|
||||
if (input.stepType === WorkflowActionType.AI_AGENT) {
|
||||
const isAiEnabled = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_AI_ENABLED,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!isAiEnabled) {
|
||||
throw new Error(
|
||||
'AI features are not available in your current workspace. Please contact support to enable them.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.workflowVersionStepWorkspaceService.createWorkflowVersionStep({
|
||||
workspaceId,
|
||||
input,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { WorkflowTriggerController } from 'src/engine/core-modules/workflow/controllers/workflow-trigger.controller';
|
||||
import { WorkflowBuilderResolver } from 'src/engine/core-modules/workflow/resolvers/workflow-builder.resolver';
|
||||
import { WorkflowStepResolver } from 'src/engine/core-modules/workflow/resolvers/workflow-step.resolver';
|
||||
@ -14,6 +15,7 @@ import { WorkflowTriggerModule } from 'src/modules/workflow/workflow-trigger/wor
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
FeatureFlagModule,
|
||||
WorkflowTriggerModule,
|
||||
WorkflowBuilderModule,
|
||||
WorkflowCommonModule,
|
||||
|
||||
@ -22,6 +22,7 @@ import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-p
|
||||
import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity';
|
||||
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
|
||||
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
|
||||
|
||||
registerEnumType(WorkspaceActivationStatus, {
|
||||
@ -121,6 +122,11 @@ export class Workspace {
|
||||
)
|
||||
workspaceSSOIdentityProviders: Relation<WorkspaceSSOIdentityProvider[]>;
|
||||
|
||||
@OneToMany(() => AgentEntity, (agent) => agent.workspace, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
agents: Relation<AgentEntity[]>;
|
||||
|
||||
@Field()
|
||||
@Column({ default: 1 })
|
||||
metadataVersion: number;
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { TypedReflect } from 'src/utils/typed-reflect';
|
||||
|
||||
export const FEATURE_FLAG_KEY = 'feature-flag-metadata-args';
|
||||
|
||||
export function RequireFeatureFlag(featureFlag: FeatureFlagKey) {
|
||||
return (
|
||||
target: object,
|
||||
propertyKey?: string,
|
||||
descriptor?: PropertyDescriptor,
|
||||
) => {
|
||||
TypedReflect.defineMetadata(
|
||||
FEATURE_FLAG_KEY,
|
||||
featureFlag,
|
||||
descriptor?.value || target,
|
||||
);
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FeatureFlagGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const ctx = GqlExecutionContext.create(context);
|
||||
const request = ctx.getContext().req;
|
||||
const workspaceId = request.workspace?.id;
|
||||
|
||||
if (!workspaceId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const featureFlag = this.reflector.get<FeatureFlagKey>(
|
||||
FEATURE_FLAG_KEY,
|
||||
context.getHandler(),
|
||||
);
|
||||
|
||||
if (!featureFlag) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isEnabled = await this.featureFlagService.isFeatureEnabled(
|
||||
featureFlag,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!isEnabled) {
|
||||
throw new Error(
|
||||
`Feature flag "${featureFlag}" is not enabled for this workspace`,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,131 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { generateObject } from 'ai';
|
||||
|
||||
import {
|
||||
ModelId,
|
||||
ModelProvider,
|
||||
} from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
import { getAIModelById } from 'src/engine/core-modules/ai/utils/get-ai-model-by-id';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
|
||||
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
|
||||
|
||||
import { AgentEntity } from './agent.entity';
|
||||
import { AgentException, AgentExceptionCode } from './agent.exception';
|
||||
|
||||
import { convertOutputSchemaToZod } from './utils/convert-output-schema-to-zod';
|
||||
|
||||
export interface AgentExecutionResult {
|
||||
object: object;
|
||||
usage: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AgentExecutionService {
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
|
||||
private getModel = (modelId: ModelId, provider: ModelProvider) => {
|
||||
switch (provider) {
|
||||
case ModelProvider.OPENAI: {
|
||||
const OpenAIProvider = createOpenAI({
|
||||
apiKey: this.twentyConfigService.get('OPENAI_API_KEY'),
|
||||
});
|
||||
|
||||
return OpenAIProvider(modelId);
|
||||
}
|
||||
case ModelProvider.ANTHROPIC: {
|
||||
const AnthropicProvider = createAnthropic({
|
||||
apiKey: this.twentyConfigService.get('ANTHROPIC_API_KEY'),
|
||||
});
|
||||
|
||||
return AnthropicProvider(modelId);
|
||||
}
|
||||
default:
|
||||
throw new AgentException(
|
||||
`Unsupported provider: ${provider}`,
|
||||
AgentExceptionCode.AGENT_EXECUTION_FAILED,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private async validateApiKey(provider: ModelProvider): Promise<void> {
|
||||
let apiKey: string | undefined;
|
||||
|
||||
switch (provider) {
|
||||
case ModelProvider.OPENAI:
|
||||
apiKey = this.twentyConfigService.get('OPENAI_API_KEY');
|
||||
break;
|
||||
case ModelProvider.ANTHROPIC:
|
||||
apiKey = this.twentyConfigService.get('ANTHROPIC_API_KEY');
|
||||
break;
|
||||
default:
|
||||
throw new AgentException(
|
||||
`Unsupported provider: ${provider}`,
|
||||
AgentExceptionCode.AGENT_EXECUTION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
throw new AgentException(
|
||||
`${provider.toUpperCase()} API key not configured`,
|
||||
AgentExceptionCode.API_KEY_NOT_CONFIGURED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async executeAgent({
|
||||
agent,
|
||||
context,
|
||||
schema,
|
||||
}: {
|
||||
agent: AgentEntity;
|
||||
context: Record<string, unknown>;
|
||||
schema: OutputSchema;
|
||||
}): Promise<AgentExecutionResult> {
|
||||
try {
|
||||
const aiModel = getAIModelById(agent.modelId);
|
||||
|
||||
if (!aiModel) {
|
||||
throw new AgentException(
|
||||
`AI model with id ${agent.modelId} not found`,
|
||||
AgentExceptionCode.AGENT_EXECUTION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
const provider = aiModel.provider;
|
||||
|
||||
await this.validateApiKey(provider);
|
||||
|
||||
const output = await generateObject({
|
||||
model: this.getModel(agent.modelId, provider),
|
||||
prompt: resolveInput(agent.prompt, context) as string,
|
||||
schema: convertOutputSchemaToZod(schema),
|
||||
});
|
||||
|
||||
return {
|
||||
object: output.object,
|
||||
usage: {
|
||||
promptTokens: output.usage?.promptTokens ?? 0,
|
||||
completionTokens: output.usage?.completionTokens ?? 0,
|
||||
totalTokens: output.usage?.totalTokens,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AgentException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AgentException(
|
||||
error instanceof Error ? error.message : 'Agent execution failed',
|
||||
AgentExceptionCode.AGENT_EXECUTION_FAILED,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
|
||||
|
||||
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Entity('agent')
|
||||
@Index('IDX_AGENT_ID_DELETED_AT', ['id', 'deletedAt'])
|
||||
export class AgentEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ nullable: false })
|
||||
name: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ nullable: false, type: 'text' })
|
||||
prompt: string;
|
||||
|
||||
@Column({ nullable: false, type: 'varchar' })
|
||||
modelId: ModelId;
|
||||
|
||||
@Column({ nullable: true, type: 'jsonb' })
|
||||
responseFormat: object;
|
||||
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
workspaceId: string;
|
||||
|
||||
@ManyToOne(() => Workspace, (workspace) => workspace.agents, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'workspaceId' })
|
||||
workspace: Relation<Workspace>;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@DeleteDateColumn({ type: 'timestamptz' })
|
||||
deletedAt?: Date;
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class AgentException extends CustomException {
|
||||
declare code: AgentExceptionCode;
|
||||
constructor(message: string, code: AgentExceptionCode) {
|
||||
super(message, code);
|
||||
}
|
||||
}
|
||||
|
||||
export enum AgentExceptionCode {
|
||||
AGENT_NOT_FOUND = 'AGENT_NOT_FOUND',
|
||||
FEATURE_FLAG_INVALID = 'FEATURE_FLAG_INVALID',
|
||||
AGENT_ALREADY_EXISTS = 'AGENT_ALREADY_EXISTS',
|
||||
AGENT_EXECUTION_FAILED = 'AGENT_EXECUTION_FAILED',
|
||||
AGENT_EXECUTION_LIMIT_REACHED = 'AGENT_EXECUTION_LIMIT_REACHED',
|
||||
AGENT_INVALID_PROMPT = 'AGENT_INVALID_PROMPT',
|
||||
AGENT_INVALID_MODEL = 'AGENT_INVALID_MODEL',
|
||||
UNSUPPORTED_MODEL = 'UNSUPPORTED_MODEL',
|
||||
API_KEY_NOT_CONFIGURED = 'API_KEY_NOT_CONFIGURED',
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AiModule } from 'src/engine/core-modules/ai/ai.module';
|
||||
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
|
||||
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
|
||||
|
||||
import { AgentExecutionService } from './agent-execution.service';
|
||||
import { AgentEntity } from './agent.entity';
|
||||
import { AgentResolver } from './agent.resolver';
|
||||
import { AgentService } from './agent.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AgentEntity, FeatureFlag], 'core'),
|
||||
AiModule,
|
||||
ThrottlerModule,
|
||||
AuditModule,
|
||||
FeatureFlagModule,
|
||||
],
|
||||
providers: [AgentResolver, AgentService, AgentExecutionService],
|
||||
exports: [
|
||||
AgentService,
|
||||
AgentExecutionService,
|
||||
TypeOrmModule.forFeature([AgentEntity], 'core'),
|
||||
],
|
||||
})
|
||||
export class AgentModule {}
|
||||
@ -0,0 +1,66 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import {
|
||||
FeatureFlagGuard,
|
||||
RequireFeatureFlag,
|
||||
} from 'src/engine/guards/feature-flag.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
import { AgentService } from './agent.service';
|
||||
|
||||
import { AgentIdInput } from './dtos/agent-id.input';
|
||||
import { AgentDTO } from './dtos/agent.dto';
|
||||
import { CreateAgentInput } from './dtos/create-agent.input';
|
||||
import { UpdateAgentInput } from './dtos/update-agent.input';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
|
||||
@Resolver()
|
||||
export class AgentResolver {
|
||||
constructor(private readonly agentService: AgentService) {}
|
||||
|
||||
@Query(() => AgentDTO)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
async findOneAgent(
|
||||
@Args('input') { id }: AgentIdInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
) {
|
||||
return this.agentService.findOneAgent(id, workspaceId);
|
||||
}
|
||||
|
||||
@Query(() => [AgentDTO])
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
async findManyAgents(@AuthWorkspace() { id: workspaceId }: Workspace) {
|
||||
return this.agentService.findManyAgents(workspaceId);
|
||||
}
|
||||
|
||||
@Mutation(() => AgentDTO)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
async createOneAgent(
|
||||
@Args('input') input: CreateAgentInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
) {
|
||||
return this.agentService.createOneAgent(input, workspaceId);
|
||||
}
|
||||
|
||||
@Mutation(() => AgentDTO)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
async updateOneAgent(
|
||||
@Args('input') input: UpdateAgentInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
) {
|
||||
return this.agentService.updateOneAgent(input, workspaceId);
|
||||
}
|
||||
|
||||
@Mutation(() => AgentDTO)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
async deleteOneAgent(
|
||||
@Args('input') { id }: AgentIdInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
) {
|
||||
return this.agentService.deleteOneAgent(id, workspaceId);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
|
||||
import { AgentEntity } from './agent.entity';
|
||||
import { AgentException, AgentExceptionCode } from './agent.exception';
|
||||
|
||||
@Injectable()
|
||||
export class AgentService {
|
||||
constructor(
|
||||
@InjectRepository(AgentEntity, 'core')
|
||||
private readonly agentRepository: Repository<AgentEntity>,
|
||||
) {}
|
||||
|
||||
async findManyAgents(workspaceId: string) {
|
||||
return this.agentRepository.find({
|
||||
where: { workspaceId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOneAgent(id: string, workspaceId: string) {
|
||||
const agent = await this.agentRepository.findOne({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
|
||||
if (!agent) {
|
||||
throw new AgentException(
|
||||
`Agent with id ${id} not found`,
|
||||
AgentExceptionCode.AGENT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
async createOneAgent(
|
||||
input: {
|
||||
name: string;
|
||||
description?: string;
|
||||
prompt: string;
|
||||
modelId: ModelId;
|
||||
responseFormat?: object;
|
||||
},
|
||||
workspaceId: string,
|
||||
) {
|
||||
const agent = this.agentRepository.create({
|
||||
...input,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const createdAgent = await this.agentRepository.save(agent);
|
||||
|
||||
return this.findOneAgent(createdAgent.id, workspaceId);
|
||||
}
|
||||
|
||||
async updateOneAgent(
|
||||
input: {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
prompt?: string;
|
||||
modelId?: ModelId;
|
||||
responseFormat?: object;
|
||||
},
|
||||
workspaceId: string,
|
||||
) {
|
||||
const agent = await this.findOneAgent(input.id, workspaceId);
|
||||
|
||||
const updatedAgent = await this.agentRepository.save({
|
||||
...agent,
|
||||
...input,
|
||||
});
|
||||
|
||||
return updatedAgent;
|
||||
}
|
||||
|
||||
async deleteOneAgent(id: string, workspaceId: string) {
|
||||
const agent = await this.findOneAgent(id, workspaceId);
|
||||
|
||||
await this.agentRepository.softDelete({ id: agent.id });
|
||||
|
||||
return agent;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@InputType()
|
||||
export class AgentIdInput {
|
||||
@Field(() => UUIDScalarType, { description: 'The id of the agent.' })
|
||||
id!: string;
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
import { Field, HideField, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IsDateString, IsNotEmpty, IsString, IsUUID } from 'class-validator';
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
|
||||
@ObjectType('Agent')
|
||||
export class AgentDTO {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
@Field(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@IsString()
|
||||
@Field()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@Field({ nullable: true })
|
||||
description: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
prompt: string;
|
||||
|
||||
@Field(() => String)
|
||||
modelId: ModelId;
|
||||
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
responseFormat: object;
|
||||
|
||||
@HideField()
|
||||
workspaceId: string;
|
||||
|
||||
@IsDateString()
|
||||
@Field()
|
||||
createdAt: Date;
|
||||
|
||||
@IsDateString()
|
||||
@Field()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
|
||||
@InputType()
|
||||
export class CreateAgentInput {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Field({ nullable: true })
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
prompt: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field(() => String)
|
||||
modelId: ModelId;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
responseFormat?: object;
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
|
||||
@InputType()
|
||||
export class UpdateAgentInput {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
@Field(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Field()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Field({ nullable: true })
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Field()
|
||||
prompt?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Field(() => String)
|
||||
modelId?: ModelId;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
responseFormat?: object;
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
|
||||
|
||||
export const convertOutputSchemaToZod = (
|
||||
schema: OutputSchema,
|
||||
): z.ZodObject<Record<string, z.ZodTypeAny>> => {
|
||||
const shape: Record<string, z.ZodTypeAny> = {};
|
||||
|
||||
for (const [fieldName, field] of Object.entries(schema)) {
|
||||
if (field.isLeaf) {
|
||||
let fieldSchema: z.ZodTypeAny;
|
||||
|
||||
switch (field.type) {
|
||||
case 'TEXT':
|
||||
fieldSchema = z.string();
|
||||
break;
|
||||
case 'NUMBER':
|
||||
fieldSchema = z.number();
|
||||
break;
|
||||
case 'BOOLEAN':
|
||||
fieldSchema = z.boolean();
|
||||
break;
|
||||
case 'DATE':
|
||||
fieldSchema = z.string().describe('Date-time string');
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
`Unsupported field type for AI agent output: ${field.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (field.description) {
|
||||
fieldSchema = fieldSchema.describe(field.description);
|
||||
}
|
||||
|
||||
shape[fieldName] = fieldSchema;
|
||||
}
|
||||
}
|
||||
|
||||
return z.object(shape);
|
||||
};
|
||||
@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
|
||||
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||
@ -16,6 +17,7 @@ import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-
|
||||
FieldMetadataModule,
|
||||
ObjectMetadataModule,
|
||||
ServerlessFunctionModule,
|
||||
AgentModule,
|
||||
WorkspaceMetadataVersionModule,
|
||||
WorkspaceMigrationModule,
|
||||
RemoteServerModule,
|
||||
@ -28,6 +30,7 @@ import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-
|
||||
FieldMetadataModule,
|
||||
ObjectMetadataModule,
|
||||
ServerlessFunctionModule,
|
||||
AgentModule,
|
||||
RemoteServerModule,
|
||||
RoleModule,
|
||||
PermissionsModule,
|
||||
|
||||
@ -5,6 +5,7 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
|
||||
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
|
||||
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
|
||||
@ -20,6 +21,7 @@ import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverles
|
||||
FileModule,
|
||||
ThrottlerModule,
|
||||
AuditModule,
|
||||
FeatureFlagModule,
|
||||
],
|
||||
providers: [ServerlessFunctionService, ServerlessFunctionResolver],
|
||||
exports: [ServerlessFunctionService],
|
||||
|
||||
@ -5,9 +5,9 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import graphqlTypeJson from 'graphql-type-json';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { FeatureFlagGuard } from 'src/engine/guards/feature-flag.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { CreateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input';
|
||||
import { ExecuteServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/execute-serverless-function.input';
|
||||
@ -21,13 +21,11 @@ import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless
|
||||
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
|
||||
import { serverlessFunctionGraphQLApiExceptionHandler } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
|
||||
@Resolver()
|
||||
export class ServerlessFunctionResolver {
|
||||
constructor(
|
||||
private readonly serverlessFunctionService: ServerlessFunctionService,
|
||||
@InjectRepository(FeatureFlag, 'core')
|
||||
private readonly featureFlagRepository: Repository<FeatureFlag>,
|
||||
@InjectRepository(ServerlessFunctionEntity, 'core')
|
||||
private readonly serverlessFunctionRepository: Repository<ServerlessFunctionEntity>,
|
||||
) {}
|
||||
|
||||
Reference in New Issue
Block a user