From ded413b99b3328bd894b3b012f41a97d0e770ec6 Mon Sep 17 00:00:00 2001 From: Francisco Gaona Date: Sat, 17 Jan 2026 20:16:04 +0100 Subject: [PATCH] WIP - ai process builder codex attempt --- ...250321000001_create_ai_processes_module.js | 82 +++++ backend/package.json | 3 + backend/prisma/schema.prisma | 95 ++++++ .../src/ai-assistant/ai-assistant.module.ts | 1 + .../__tests__/ai-processes.compiler.spec.ts | 25 ++ .../__tests__/ai-processes.runner.spec.ts | 40 +++ .../src/ai-processes/ai-processes.compiler.ts | 183 +++++++++++ .../ai-processes/ai-processes.controller.ts | 142 +++++++++ .../src/ai-processes/ai-processes.module.ts | 19 ++ .../ai-processes.orchestrator.service.ts | 120 +++++++ .../src/ai-processes/ai-processes.runner.ts | 208 ++++++++++++ .../src/ai-processes/ai-processes.schemas.ts | 79 +++++ .../src/ai-processes/ai-processes.service.ts | 295 ++++++++++++++++++ .../ai-processes.stream.service.ts | 33 ++ .../src/ai-processes/ai-processes.types.ts | 124 ++++++++ backend/src/ai-processes/demo-process.ts | 173 ++++++++++ backend/src/ai-processes/dto/ai-chat.dto.ts | 28 ++ .../src/ai-processes/dto/ai-process.dto.ts | 24 ++ backend/src/ai-processes/dto/ai-run.dto.ts | 19 ++ .../src/ai-processes/tools/tool-registry.ts | 48 +++ backend/src/app.module.ts | 2 + backend/src/models/ai-chat.model.ts | 64 ++++ backend/src/models/ai-process.model.ts | 147 +++++++++ frontend/ai-processes-editor/index.html | 12 + frontend/ai-processes-editor/package.json | 23 ++ frontend/ai-processes-editor/src/App.tsx | 43 +++ frontend/ai-processes-editor/src/main.tsx | 7 + frontend/ai-processes-editor/src/styles.css | 33 ++ frontend/ai-processes-editor/vite.config.ts | 9 + frontend/components/AIChatBar.vue | 44 ++- .../components/ai-processes/NeedInputForm.vue | 52 +++ .../ai-processes/ReactFlowIframe.vue | 19 ++ frontend/nuxt.config.ts | 2 + frontend/pages/admin/ai-processes.vue | 14 + 34 files changed, 2199 insertions(+), 13 deletions(-) create mode 100644 backend/migrations/tenant/20250321000001_create_ai_processes_module.js create mode 100644 backend/src/ai-processes/__tests__/ai-processes.compiler.spec.ts create mode 100644 backend/src/ai-processes/__tests__/ai-processes.runner.spec.ts create mode 100644 backend/src/ai-processes/ai-processes.compiler.ts create mode 100644 backend/src/ai-processes/ai-processes.controller.ts create mode 100644 backend/src/ai-processes/ai-processes.module.ts create mode 100644 backend/src/ai-processes/ai-processes.orchestrator.service.ts create mode 100644 backend/src/ai-processes/ai-processes.runner.ts create mode 100644 backend/src/ai-processes/ai-processes.schemas.ts create mode 100644 backend/src/ai-processes/ai-processes.service.ts create mode 100644 backend/src/ai-processes/ai-processes.stream.service.ts create mode 100644 backend/src/ai-processes/ai-processes.types.ts create mode 100644 backend/src/ai-processes/demo-process.ts create mode 100644 backend/src/ai-processes/dto/ai-chat.dto.ts create mode 100644 backend/src/ai-processes/dto/ai-process.dto.ts create mode 100644 backend/src/ai-processes/dto/ai-run.dto.ts create mode 100644 backend/src/ai-processes/tools/tool-registry.ts create mode 100644 backend/src/models/ai-chat.model.ts create mode 100644 backend/src/models/ai-process.model.ts create mode 100644 frontend/ai-processes-editor/index.html create mode 100644 frontend/ai-processes-editor/package.json create mode 100644 frontend/ai-processes-editor/src/App.tsx create mode 100644 frontend/ai-processes-editor/src/main.tsx create mode 100644 frontend/ai-processes-editor/src/styles.css create mode 100644 frontend/ai-processes-editor/vite.config.ts create mode 100644 frontend/components/ai-processes/NeedInputForm.vue create mode 100644 frontend/components/ai-processes/ReactFlowIframe.vue create mode 100644 frontend/pages/admin/ai-processes.vue diff --git a/backend/migrations/tenant/20250321000001_create_ai_processes_module.js b/backend/migrations/tenant/20250321000001_create_ai_processes_module.js new file mode 100644 index 0000000..94fa358 --- /dev/null +++ b/backend/migrations/tenant/20250321000001_create_ai_processes_module.js @@ -0,0 +1,82 @@ +exports.up = async function (knex) { + await knex.schema.createTable('ai_processes', (table) => { + table.uuid('id').primary(); + table.string('tenant_id').notNullable(); + table.string('name').notNullable(); + table.text('description'); + table.integer('latest_version').notNullable().defaultTo(1); + table.string('created_by').notNullable(); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.index(['tenant_id']); + }); + + await knex.schema.createTable('ai_process_versions', (table) => { + table.uuid('id').primary(); + table.string('tenant_id').notNullable(); + table.uuid('process_id').notNullable(); + table.integer('version').notNullable(); + table.json('graph_json').notNullable(); + table.json('compiled_json').notNullable(); + table.string('created_by').notNullable(); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.unique(['process_id', 'version']); + table.index(['tenant_id']); + table.index(['process_id']); + }); + + await knex.schema.createTable('ai_process_runs', (table) => { + table.uuid('id').primary(); + table.string('tenant_id').notNullable(); + table.uuid('process_id').notNullable(); + table.integer('version').notNullable(); + table.string('status').notNullable(); + table.json('input_json').notNullable(); + table.json('output_json'); + table.json('error_json'); + table.json('state_json'); + table.string('current_node_id'); + table.timestamp('started_at').defaultTo(knex.fn.now()); + table.timestamp('ended_at'); + table.index(['tenant_id']); + table.index(['process_id']); + }); + + await knex.schema.createTable('ai_chat_sessions', (table) => { + table.uuid('id').primary(); + table.string('tenant_id').notNullable(); + table.string('user_id').notNullable(); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.index(['tenant_id']); + table.index(['user_id']); + }); + + await knex.schema.createTable('ai_chat_messages', (table) => { + table.uuid('id').primary(); + table.uuid('session_id').notNullable(); + table.string('role').notNullable(); + table.text('content').notNullable(); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.index(['session_id']); + }); + + await knex.schema.createTable('ai_audit_events', (table) => { + table.uuid('id').primary(); + table.string('tenant_id').notNullable(); + table.uuid('run_id').notNullable(); + table.string('event_type').notNullable(); + table.json('payload_json').notNullable(); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.index(['tenant_id']); + table.index(['run_id']); + }); +}; + +exports.down = async function (knex) { + await knex.schema.dropTableIfExists('ai_audit_events'); + await knex.schema.dropTableIfExists('ai_chat_messages'); + await knex.schema.dropTableIfExists('ai_chat_sessions'); + await knex.schema.dropTableIfExists('ai_process_runs'); + await knex.schema.dropTableIfExists('ai_process_versions'); + await knex.schema.dropTableIfExists('ai_processes'); +}; diff --git a/backend/package.json b/backend/package.json index 83e842a..6b1e72f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -54,6 +54,9 @@ "openai": "^6.15.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "json-logic-js": "^2.0.5", "reflect-metadata": "^0.2.1", "rxjs": "^7.8.1", "socket.io": "^4.8.3", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 4e53e58..647c044 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -181,6 +181,101 @@ model ContactDetail { @@map("contact_details") } +// AI Process Builder + Chat Orchestrator +model AiProcess { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + name String + description String? @db.Text + latestVersion Int @default(1) @map("latest_version") + createdBy String @map("created_by") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + versions AiProcessVersion[] + runs AiProcessRun[] + + @@index([tenantId]) + @@map("ai_processes") +} + +model AiProcessVersion { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + processId String @map("process_id") + version Int + graphJson Json @map("graph_json") + compiledJson Json @map("compiled_json") + createdBy String @map("created_by") + createdAt DateTime @default(now()) @map("created_at") + + process AiProcess @relation(fields: [processId], references: [id], onDelete: Cascade) + + @@unique([processId, version]) + @@index([tenantId]) + @@map("ai_process_versions") +} + +model AiProcessRun { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + processId String @map("process_id") + version Int + status String + inputJson Json @map("input_json") + outputJson Json? @map("output_json") + errorJson Json? @map("error_json") + stateJson Json? @map("state_json") + currentNodeId String? @map("current_node_id") + startedAt DateTime @default(now()) @map("started_at") + endedAt DateTime? @map("ended_at") + + process AiProcess @relation(fields: [processId], references: [id], onDelete: Cascade) + + @@index([tenantId]) + @@index([processId]) + @@map("ai_process_runs") +} + +model AiChatSession { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + userId String @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + + messages AiChatMessage[] + + @@index([tenantId]) + @@index([userId]) + @@map("ai_chat_sessions") +} + +model AiChatMessage { + id String @id @default(uuid()) + sessionId String @map("session_id") + role String + content String @db.Text + createdAt DateTime @default(now()) @map("created_at") + + session AiChatSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + + @@index([sessionId]) + @@map("ai_chat_messages") +} + +model AiAuditEvent { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + runId String @map("run_id") + eventType String @map("event_type") + payloadJson Json @map("payload_json") + createdAt DateTime @default(now()) @map("created_at") + + @@index([tenantId]) + @@index([runId]) + @@map("ai_audit_events") +} + // Application Builder model App { id String @id @default(uuid()) diff --git a/backend/src/ai-assistant/ai-assistant.module.ts b/backend/src/ai-assistant/ai-assistant.module.ts index 33473db..361efcc 100644 --- a/backend/src/ai-assistant/ai-assistant.module.ts +++ b/backend/src/ai-assistant/ai-assistant.module.ts @@ -10,5 +10,6 @@ import { MeilisearchModule } from '../search/meilisearch.module'; imports: [ObjectModule, PageLayoutModule, TenantModule, MeilisearchModule], controllers: [AiAssistantController], providers: [AiAssistantService], + exports: [AiAssistantService], }) export class AiAssistantModule {} diff --git a/backend/src/ai-processes/__tests__/ai-processes.compiler.spec.ts b/backend/src/ai-processes/__tests__/ai-processes.compiler.spec.ts new file mode 100644 index 0000000..39261fc --- /dev/null +++ b/backend/src/ai-processes/__tests__/ai-processes.compiler.spec.ts @@ -0,0 +1,25 @@ +import { compileProcessGraph, GraphValidationError } from '../ai-processes.compiler'; +import { demoRegisterNewPetProcess } from '../demo-process'; + +describe('ai-processes compiler', () => { + it('throws when missing start node', () => { + const badGraph = { + ...demoRegisterNewPetProcess, + nodes: demoRegisterNewPetProcess.nodes.filter((n) => n.type !== 'Start'), + }; + + expect(() => + compileProcessGraph(badGraph, { tenantId: 'default', version: 1 }), + ).toThrow(GraphValidationError); + }); + + it('compiles the demo process graph', () => { + const compiled = compileProcessGraph(demoRegisterNewPetProcess, { + tenantId: 'default', + version: 1, + }); + + expect(compiled.startNodeId).toBe('start'); + expect(compiled.endNodeIds).toContain('end'); + }); +}); diff --git a/backend/src/ai-processes/__tests__/ai-processes.runner.spec.ts b/backend/src/ai-processes/__tests__/ai-processes.runner.spec.ts new file mode 100644 index 0000000..084c503 --- /dev/null +++ b/backend/src/ai-processes/__tests__/ai-processes.runner.spec.ts @@ -0,0 +1,40 @@ +import { compileProcessGraph } from '../ai-processes.compiler'; +import { demoRegisterNewPetProcess } from '../demo-process'; +import { runCompiledGraph } from '../ai-processes.runner'; +import { ToolRegistry } from '../tools/tool-registry'; + +describe('ai-processes runner', () => { + it('runs the demo process until human input is required', async () => { + const compiled = compileProcessGraph(demoRegisterNewPetProcess, { + tenantId: 'default', + version: 1, + }); + + const result = await runCompiledGraph({ + compiledGraph: compiled, + input: { + accountName: 'Acme Inc', + firstName: 'Jamie', + lastName: 'Doe', + }, + toolRegistry: new ToolRegistry(), + toolContext: { tenantId: 'default', userId: 'user-1' }, + llmDecision: async (node, state) => { + if (node.id === 'decide_account') { + return { accountAction: 'find', accountName: state.accountName }; + } + if (node.id === 'decide_contact') { + return { + contactAction: 'find', + firstName: state.firstName, + lastName: state.lastName, + }; + } + return {}; + }, + }); + + expect(result.status).toBe('waiting'); + expect(result.currentNodeId).toBe('need_pet'); + }); +}); diff --git a/backend/src/ai-processes/ai-processes.compiler.ts b/backend/src/ai-processes/ai-processes.compiler.ts new file mode 100644 index 0000000..b14eaeb --- /dev/null +++ b/backend/src/ai-processes/ai-processes.compiler.ts @@ -0,0 +1,183 @@ +import { apply as applyJsonLogic } from 'json-logic-js'; +import { createAjv } from './ai-processes.schemas'; +import { + CompiledGraph, + ProcessGraphDefinition, + ProcessGraphEdge, + ProcessGraphNode, +} from './ai-processes.types'; +import { ToolRegistry } from './tools/tool-registry'; + +export class GraphValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'GraphValidationError'; + } +} + +export interface CompileOptions { + tenantId: string; + version: number; +} + +export const validateGraphDefinition = ( + graph: ProcessGraphDefinition, + tenantId: string, +) => { + const ajv = createAjv(); + const validate = ajv.getSchema('processGraph'); + if (!validate) { + throw new GraphValidationError('Graph schema is not registered.'); + } + const valid = validate(graph); + if (!valid) { + throw new GraphValidationError( + `Graph schema validation failed: ${ajv.errorsText(validate.errors)}`, + ); + } + + const startNodes = graph.nodes.filter((node) => node.type === 'Start'); + const endNodes = graph.nodes.filter((node) => node.type === 'End'); + + if (startNodes.length !== 1) { + throw new GraphValidationError('Graph must contain exactly one Start node.'); + } + if (endNodes.length < 1) { + throw new GraphValidationError('Graph must contain at least one End node.'); + } + + const nodeIds = new Set(graph.nodes.map((node) => node.id)); + graph.edges.forEach((edge) => { + if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) { + throw new GraphValidationError(`Edge ${edge.id} references unknown nodes.`); + } + }); + + const adjacency = buildAdjacency(graph.edges); + const reachable = new Set(); + const queue = [startNodes[0].id]; + + while (queue.length) { + const current = queue.shift(); + if (!current || reachable.has(current)) continue; + reachable.add(current); + (adjacency[current] || []).forEach((neighbor) => queue.push(neighbor)); + } + + graph.nodes.forEach((node) => { + if (!reachable.has(node.id)) { + throw new GraphValidationError(`Node ${node.id} is not reachable.`); + } + }); + + if (!graph.allowCycles && hasCycle(graph.nodes, graph.edges)) { + throw new GraphValidationError('Graph contains cycles but allowCycles=false.'); + } + + const toolRegistry = new ToolRegistry(); + graph.nodes.forEach((node) => { + if (node.type === 'ToolNode') { + const toolName = (node.data as { toolName?: string }).toolName; + if (!toolName || !toolRegistry.isToolAllowed(tenantId, toolName)) { + throw new GraphValidationError( + `Tool ${toolName ?? 'unknown'} is not allowlisted for tenant.`, + ); + } + } + if (node.type === 'LLMDecisionNode') { + const data = node.data as { + promptTemplate?: string; + inputKeys?: string[]; + outputSchema?: Record; + model?: { name?: string; temperature?: number }; + }; + if (!data.promptTemplate || !data.outputSchema || !data.model?.name) { + throw new GraphValidationError( + `LLMDecisionNode ${node.id} missing required configuration.`, + ); + } + } + if (node.type === 'HumanInputNode') { + const data = node.data as { + requiredFieldsSchema?: Record; + promptToUser?: string; + }; + if (!data.requiredFieldsSchema || !data.promptToUser) { + throw new GraphValidationError( + `HumanInputNode ${node.id} missing required configuration.`, + ); + } + } + }); + + graph.edges.forEach((edge) => { + if (edge.condition) { + try { + applyJsonLogic(edge.condition, {}); + } catch (error) { + throw new GraphValidationError( + `Edge ${edge.id} has invalid json-logic condition.`, + ); + } + } + }); +}; + +export const compileProcessGraph = ( + graph: ProcessGraphDefinition, + options: CompileOptions, +): CompiledGraph => { + validateGraphDefinition(graph, options.tenantId); + + const startNodeId = graph.nodes.find((node) => node.type === 'Start')?.id; + if (!startNodeId) { + throw new GraphValidationError('Start node missing after validation.'); + } + + const endNodeIds = graph.nodes + .filter((node) => node.type === 'End') + .map((node) => node.id); + + return { + graphId: graph.id, + version: options.version, + nodes: graph.nodes, + edges: graph.edges, + startNodeId, + endNodeIds, + adjacency: buildAdjacency(graph.edges), + allowCycles: graph.allowCycles, + maxIterations: graph.maxIterations, + }; +}; + +const buildAdjacency = (edges: ProcessGraphEdge[]) => { + return edges.reduce>((acc, edge) => { + if (!acc[edge.source]) { + acc[edge.source] = []; + } + acc[edge.source].push(edge.target); + return acc; + }, {}); +}; + +const hasCycle = (nodes: ProcessGraphNode[], edges: ProcessGraphEdge[]) => { + const adjacency = buildAdjacency(edges); + const visited = new Set(); + const stack = new Set(); + + const visit = (nodeId: string): boolean => { + if (stack.has(nodeId)) return true; + if (visited.has(nodeId)) return false; + visited.add(nodeId); + stack.add(nodeId); + const neighbors = adjacency[nodeId] || []; + for (const neighbor of neighbors) { + if (visit(neighbor)) return true; + } + stack.delete(nodeId); + return false; + }; + + return nodes.some((node) => visit(node.id)); +}; diff --git a/backend/src/ai-processes/ai-processes.controller.ts b/backend/src/ai-processes/ai-processes.controller.ts new file mode 100644 index 0000000..49b0329 --- /dev/null +++ b/backend/src/ai-processes/ai-processes.controller.ts @@ -0,0 +1,142 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Put, + Query, + Sse, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { TenantId } from '../tenant/tenant.decorator'; +import { AiProcessesService } from './ai-processes.service'; +import { AiProcessesStreamService } from './ai-processes.stream.service'; +import { AiProcessesOrchestratorService } from './ai-processes.orchestrator.service'; +import { CreateAiProcessDto, UpdateAiProcessDto } from './dto/ai-process.dto'; +import { CreateAiRunDto, ResumeAiRunDto } from './dto/ai-run.dto'; +import { CreateChatSessionDto, SendChatMessageDto } from './dto/ai-chat.dto'; + +@Controller('tenants/:tenantId') +@UseGuards(JwtAuthGuard) +export class AiProcessesController { + constructor( + private readonly processesService: AiProcessesService, + private readonly streamService: AiProcessesStreamService, + private readonly orchestratorService: AiProcessesOrchestratorService, + ) {} + + @Get('ai-processes') + async listProcesses(@TenantId() tenantId: string) { + return this.processesService.listProcesses(tenantId); + } + + @Post('ai-processes') + async createProcess( + @TenantId() tenantId: string, + @CurrentUser() user: any, + @Body() payload: CreateAiProcessDto, + ) { + return this.processesService.createProcess( + tenantId, + user.userId, + payload.name, + payload.description, + payload.graph, + ); + } + + @Put('ai-processes/:processId') + async updateProcess( + @TenantId() tenantId: string, + @CurrentUser() user: any, + @Param('processId') processId: string, + @Body() payload: UpdateAiProcessDto, + ) { + return this.processesService.createProcessVersion( + tenantId, + user.userId, + processId, + payload.graph, + ); + } + + @Get('ai-processes/:processId/versions') + async listVersions( + @TenantId() tenantId: string, + @Param('processId') processId: string, + ) { + return this.processesService.listProcessVersions(tenantId, processId); + } + + @Post('ai-processes/:processId/runs') + async createRun( + @TenantId() tenantId: string, + @CurrentUser() user: any, + @Param('processId') processId: string, + @Body() payload: CreateAiRunDto, + ) { + return this.processesService.createRun( + tenantId, + user.userId, + processId, + payload.input, + payload.sessionId, + payload.sessionId + ? (event) => this.streamService.emit(payload.sessionId as string, event) + : undefined, + ); + } + + @Post('ai-runs/:runId/resume') + async resumeRun( + @TenantId() tenantId: string, + @CurrentUser() user: any, + @Param('runId') runId: string, + @Body() payload: ResumeAiRunDto, + ) { + return this.processesService.resumeRun( + tenantId, + user.userId, + runId, + payload.input, + payload.sessionId, + payload.sessionId + ? (event) => this.streamService.emit(payload.sessionId as string, event) + : undefined, + ); + } + + @Post('ai-chat/sessions') + async createSession( + @TenantId() tenantId: string, + @CurrentUser() user: any, + @Body() _payload: CreateChatSessionDto, + ) { + return this.orchestratorService.createSession(tenantId, user.userId); + } + + @Post('ai-chat/messages') + async sendChatMessage( + @TenantId() tenantId: string, + @CurrentUser() user: any, + @Body() payload: SendChatMessageDto, + ) { + return this.orchestratorService.sendMessage( + tenantId, + user.userId, + payload.message, + payload.sessionId, + payload.processId, + payload.history, + payload.context, + ); + } + + @Sse('ai-chat/stream') + streamChat(@Query('sessionId') sessionId: string) { + return this.streamService.getStream(sessionId); + } +} diff --git a/backend/src/ai-processes/ai-processes.module.ts b/backend/src/ai-processes/ai-processes.module.ts new file mode 100644 index 0000000..236916b --- /dev/null +++ b/backend/src/ai-processes/ai-processes.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { TenantModule } from '../tenant/tenant.module'; +import { AiAssistantModule } from '../ai-assistant/ai-assistant.module'; +import { AiProcessesController } from './ai-processes.controller'; +import { AiProcessesService } from './ai-processes.service'; +import { AiProcessesStreamService } from './ai-processes.stream.service'; +import { AiProcessesOrchestratorService } from './ai-processes.orchestrator.service'; + +@Module({ + imports: [TenantModule, AiAssistantModule], + controllers: [AiProcessesController], + providers: [ + AiProcessesService, + AiProcessesStreamService, + AiProcessesOrchestratorService, + ], + exports: [AiProcessesService], +}) +export class AiProcessesModule {} diff --git a/backend/src/ai-processes/ai-processes.orchestrator.service.ts b/backend/src/ai-processes/ai-processes.orchestrator.service.ts new file mode 100644 index 0000000..55b30bd --- /dev/null +++ b/backend/src/ai-processes/ai-processes.orchestrator.service.ts @@ -0,0 +1,120 @@ +import { Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { AiProcessesService } from './ai-processes.service'; +import { AiProcessesStreamService } from './ai-processes.stream.service'; +import { AiAssistantService } from '../ai-assistant/ai-assistant.service'; +import { TenantDatabaseService } from '../tenant/tenant-database.service'; +import { AiChatMessage, AiChatSession } from '../models/ai-chat.model'; + +@Injectable() +export class AiProcessesOrchestratorService { + constructor( + private readonly processesService: AiProcessesService, + private readonly streamService: AiProcessesStreamService, + private readonly tenantDbService: TenantDatabaseService, + private readonly aiAssistantService: AiAssistantService, + ) {} + + private async getTenantContext(tenantId: string) { + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + return { knex, tenantId: resolvedTenantId }; + } + + private async createSessionWithContext( + knex: Knex, + tenantId: string, + userId: string, + ) { + return AiChatSession.query(knex).insert({ + tenantId, + userId, + }); + } + + async createSession(tenantId: string, userId: string) { + const { knex, tenantId: resolvedTenantId } = + await this.getTenantContext(tenantId); + return this.createSessionWithContext(knex, resolvedTenantId, userId); + } + + async sendMessage( + tenantId: string, + userId: string, + message: string, + sessionId?: string, + processId?: string, + history?: { role: string; text: string }[], + context?: Record, + ) { + const { knex, tenantId: resolvedTenantId } = + await this.getTenantContext(tenantId); + + const session = sessionId + ? await AiChatSession.query(knex).findById(sessionId) + : await this.createSessionWithContext(knex, resolvedTenantId, userId); + + if (!session || session.tenantId !== resolvedTenantId) { + throw new Error('Chat session not found.'); + } + + await AiChatMessage.query(knex).insert({ + sessionId: session.id, + role: 'user', + content: message, + }); + + this.streamService.emit(session.id, { type: 'agent_started' }); + + const processes = await this.processesService.listProcesses(resolvedTenantId); + this.streamService.emit(session.id, { + type: 'processes_listed', + data: { count: processes.length }, + }); + + if (!processes.length) { + const response = await this.aiAssistantService.handleChat( + resolvedTenantId, + userId, + message, + history ?? [], + context ?? {}, + ); + this.streamService.emit(session.id, { + type: 'final', + data: { reply: response.reply, action: response.action }, + }); + return { + sessionId: session.id, + reply: response.reply, + action: response.action, + record: response.record, + }; + } + + const selectedProcess = processId + ? processes.find((proc) => proc.id === processId) + : processes[0]; + + if (!selectedProcess) { + throw new Error('Process not found.'); + } + + this.streamService.emit(session.id, { + type: 'process_selected', + processId: selectedProcess.id, + version: selectedProcess.latestVersion, + }); + + const { run, result } = await this.processesService.createRun( + resolvedTenantId, + userId, + selectedProcess.id, + { message }, + session.id, + (payload) => this.streamService.emit(session.id, payload), + ); + + return { sessionId: session.id, runId: run.id, status: result.status }; + } +} diff --git a/backend/src/ai-processes/ai-processes.runner.ts b/backend/src/ai-processes/ai-processes.runner.ts new file mode 100644 index 0000000..7cddf22 --- /dev/null +++ b/backend/src/ai-processes/ai-processes.runner.ts @@ -0,0 +1,208 @@ +import { apply as applyJsonLogic } from 'json-logic-js'; +import Ajv from 'ajv'; +import { ToolRegistry, ToolContext } from './tools/tool-registry'; +import { + AiProcessEventPayload, + CompiledGraph, + ProcessGraphNode, +} from './ai-processes.types'; + +export interface RunOptions { + compiledGraph: CompiledGraph; + input: Record; + toolRegistry: ToolRegistry; + toolContext: ToolContext; + onEvent?: (event: AiProcessEventPayload) => void; + llmDecision: ( + node: ProcessGraphNode, + state: Record, + ) => Promise>; +} + +export interface RunResult { + status: 'running' | 'waiting' | 'completed' | 'error'; + state: Record; + currentNodeId?: string; + output?: Record; + error?: Record; +} + +export const runCompiledGraph = async ( + options: RunOptions, + startNodeId?: string, +): Promise => { + const { + compiledGraph, + input, + toolRegistry, + toolContext, + onEvent, + llmDecision, + } = options; + + const state: Record = { ...input }; + let currentNodeId = startNodeId ?? compiledGraph.startNodeId; + let iterations = 0; + const maxIterations = compiledGraph.maxIterations ?? 50; + + const emit = (payload: AiProcessEventPayload) => { + if (onEvent) { + onEvent(payload); + } + }; + + while (currentNodeId) { + if ( + compiledGraph.nodes.length > 0 && + compiledGraph.endNodeIds.includes(currentNodeId) + ) { + emit({ type: 'node_started', nodeId: currentNodeId }); + emit({ type: 'node_completed', nodeId: currentNodeId }); + emit({ type: 'final', data: { output: state } }); + return { status: 'completed', state, output: state }; + } + + const node = compiledGraph.nodes.find((item) => item.id === currentNodeId); + if (!node) { + return { + status: 'error', + state, + error: { message: `Node ${currentNodeId} not found.` }, + }; + } + + emit({ type: 'node_started', nodeId: node.id }); + + if (node.type === 'LLMDecisionNode') { + const output = await llmDecision(node, state); + validateNodeOutput(node, output); + Object.assign(state, output); + } + + if (node.type === 'ToolNode') { + const toolName = (node.data as { toolName: string }).toolName; + emit({ type: 'tool_called', nodeId: node.id, toolName }); + const tool = toolRegistry.getTool(toolName); + const argsTemplate = (node.data as { argsTemplate: Record }) + .argsTemplate; + const resolvedArgs = resolveTemplate(argsTemplate, state); + const toolResult = await tool(toolContext, { + ...resolvedArgs, + state, + }); + const outputMapping = (node.data as { outputMapping: Record }) + .outputMapping; + Object.entries(outputMapping).forEach(([key, path]) => { + state[path] = toolResult[key]; + }); + } + + if (node.type === 'HumanInputNode') { + const data = node.data as { + requiredFieldsSchema: Record; + promptToUser: string; + }; + emit({ + type: 'need_input', + nodeId: node.id, + data: { + requiredFieldsSchema: data.requiredFieldsSchema, + promptToUser: data.promptToUser, + }, + }); + return { status: 'waiting', state, currentNodeId: node.id }; + } + + emit({ type: 'node_completed', nodeId: node.id }); + + const nextTargets = compiledGraph.edges.filter( + (edge) => edge.source === node.id, + ); + + if (nextTargets.length === 0) { + return { + status: 'error', + state, + error: { message: `No outgoing edges for node ${node.id}.` }, + }; + } + + const selectedEdge = selectEdge(nextTargets, state); + if (!selectedEdge) { + return { + status: 'error', + state, + error: { message: `No edge conditions matched for node ${node.id}.` }, + }; + } + + currentNodeId = selectedEdge.target; + iterations += 1; + + if (!compiledGraph.allowCycles && iterations > compiledGraph.nodes.length) { + return { + status: 'error', + state, + error: { message: 'Cycle detected during execution.' }, + }; + } + + if (compiledGraph.allowCycles && iterations > maxIterations) { + return { + status: 'error', + state, + error: { message: 'Max iterations exceeded.' }, + }; + } + } + + return { status: 'completed', state, output: state }; +}; + +const resolveTemplate = ( + template: Record, + state: Record, +) => { + return Object.entries(template).reduce>( + (acc, [key, value]) => { + if (typeof value === 'string' && value.startsWith('{{state.')) { + const path = value.replace('{{state.', '').replace('}}', ''); + acc[key] = state[path]; + } else { + acc[key] = value; + } + return acc; + }, + {}, + ); +}; + +const selectEdge = ( + edges: { condition?: Record; target: string }[], + state: Record, +) => { + if (edges.length === 1) return edges[0]; + + return edges.find((edge) => { + if (!edge.condition) return true; + try { + return Boolean(applyJsonLogic(edge.condition, state)); + } catch (error) { + return false; + } + }); +}; + +const validateNodeOutput = ( + node: ProcessGraphNode, + output: Record, +) => { + const schema = (node.data as { outputSchema?: Record }) + .outputSchema; + if (!schema) return; + const ajv = new Ajv({ allErrors: true, strict: false }); + const validate = ajv.compile(schema); + if (!validate(output)) { + throw new Error(`LLM output invalid for node ${node.id}.`); + } +}; diff --git a/backend/src/ai-processes/ai-processes.schemas.ts b/backend/src/ai-processes/ai-processes.schemas.ts new file mode 100644 index 0000000..e3ff84f --- /dev/null +++ b/backend/src/ai-processes/ai-processes.schemas.ts @@ -0,0 +1,79 @@ +import Ajv, { JSONSchemaType } from 'ajv'; +import addFormats from 'ajv-formats'; +import { + AiNodeType, + ProcessGraphDefinition, + ProcessGraphEdge, + ProcessGraphNode, +} from './ai-processes.types'; + +const nodeTypes: AiNodeType[] = [ + 'Start', + 'LLMDecisionNode', + 'ToolNode', + 'HumanInputNode', + 'End', +]; + +export const graphSchema: JSONSchemaType = { + type: 'object', + required: ['id', 'name', 'nodes', 'edges'], + additionalProperties: false, + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + description: { type: 'string', nullable: true }, + allowCycles: { type: 'boolean', nullable: true }, + maxIterations: { type: 'number', nullable: true }, + nodes: { + type: 'array', + items: { $ref: '#/definitions/processGraphNode' }, + minItems: 1, + }, + edges: { + type: 'array', + items: { $ref: '#/definitions/processGraphEdge' }, + minItems: 0, + }, + }, + definitions: { + processGraphEdge: { + type: 'object', + required: ['id', 'source', 'target'], + additionalProperties: false, + properties: { + id: { type: 'string' }, + source: { type: 'string' }, + target: { type: 'string' }, + condition: { type: 'object', nullable: true }, + }, + } as JSONSchemaType, + processGraphNode: { + type: 'object', + required: ['id', 'type', 'data'], + additionalProperties: false, + properties: { + id: { type: 'string' }, + type: { type: 'string', enum: nodeTypes }, + position: { + type: 'object', + nullable: true, + required: ['x', 'y'], + additionalProperties: false, + properties: { + x: { type: 'number' }, + y: { type: 'number' }, + }, + }, + data: { type: 'object' }, + }, + } as JSONSchemaType, + }, +}; + +export const createAjv = () => { + const ajv = new Ajv({ allErrors: true, strict: false }); + addFormats(ajv); + ajv.addSchema(graphSchema, 'processGraph'); + return ajv; +}; diff --git a/backend/src/ai-processes/ai-processes.service.ts b/backend/src/ai-processes/ai-processes.service.ts new file mode 100644 index 0000000..c6dde9e --- /dev/null +++ b/backend/src/ai-processes/ai-processes.service.ts @@ -0,0 +1,295 @@ +import { Injectable } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { Knex } from 'knex'; +import { TenantDatabaseService } from '../tenant/tenant-database.service'; +import { + AiAuditEvent, + AiProcess, + AiProcessRun, + AiProcessVersion, +} from '../models/ai-process.model'; +import { compileProcessGraph } from './ai-processes.compiler'; +import { runCompiledGraph } from './ai-processes.runner'; +import { + AiProcessEventPayload, + CompiledGraph, + ProcessGraphDefinition, +} from './ai-processes.types'; +import { ToolRegistry } from './tools/tool-registry'; + +@Injectable() +export class AiProcessesService { + constructor(private readonly tenantDbService: TenantDatabaseService) {} + + private async getTenantContext(tenantId: string) { + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + return { knex, tenantId: resolvedTenantId }; + } + + async listProcesses(tenantId: string) { + const { knex, tenantId: resolvedTenantId } = + await this.getTenantContext(tenantId); + return AiProcess.query(knex) + .where('tenantId', resolvedTenantId) + .withGraphFetched('versions') + .orderBy('createdAt', 'desc'); + } + + async getProcess(tenantId: string, processId: string) { + const { knex } = await this.getTenantContext(tenantId); + return AiProcess.query(knex) + .findById(processId) + .withGraphFetched('versions'); + } + + async createProcess( + tenantId: string, + userId: string, + name: string, + description: string | undefined, + graph: ProcessGraphDefinition, + ) { + const { knex, tenantId: resolvedTenantId } = + await this.getTenantContext(tenantId); + const compiled = compileProcessGraph(graph, { + tenantId: resolvedTenantId, + version: 1, + }); + + return knex.transaction(async (trx) => { + const processId = randomUUID(); + + await AiProcess.query(trx).insert({ + id: processId, + tenantId: resolvedTenantId, + name, + description, + latestVersion: 1, + createdBy: userId, + }); + + await AiProcessVersion.query(trx).insert({ + id: randomUUID(), + tenantId: resolvedTenantId, + processId, + version: 1, + graphJson: graph, + compiledJson: compiled, + createdBy: userId, + }); + + return AiProcess.query(trx) + .findById(processId) + .withGraphFetched('versions'); + }); + } + + async createProcessVersion( + tenantId: string, + userId: string, + processId: string, + graph: ProcessGraphDefinition, + ) { + const { knex, tenantId: resolvedTenantId } = + await this.getTenantContext(tenantId); + + const process = await AiProcess.query(knex).findById(processId); + if (!process || process.tenantId !== resolvedTenantId) { + throw new Error('Process not found.'); + } + + const nextVersion = process.latestVersion + 1; + const compiled = compileProcessGraph(graph, { + tenantId: resolvedTenantId, + version: nextVersion, + }); + + return knex.transaction(async (trx) => { + await AiProcess.query(trx) + .findById(processId) + .patch({ latestVersion: nextVersion }); + + const versionId = randomUUID(); + await AiProcessVersion.query(trx).insert({ + id: versionId, + tenantId: resolvedTenantId, + processId, + version: nextVersion, + graphJson: graph, + compiledJson: compiled, + createdBy: userId, + }); + + return AiProcessVersion.query(trx).findById(versionId); + }); + } + + async listProcessVersions(tenantId: string, processId: string) { + const { knex, tenantId: resolvedTenantId } = + await this.getTenantContext(tenantId); + return AiProcessVersion.query(knex) + .where({ processId, tenantId: resolvedTenantId }) + .orderBy('version', 'desc'); + } + + async createRun( + tenantId: string, + userId: string, + processId: string, + input: Record, + sessionId: string | undefined, + emitEvent?: (payload: AiProcessEventPayload) => void, + ) { + const { knex, tenantId: resolvedTenantId } = + await this.getTenantContext(tenantId); + const process = await AiProcess.query(knex).findById(processId); + if (!process || process.tenantId !== resolvedTenantId) { + throw new Error('Process not found.'); + } + + const versionRecord = await AiProcessVersion.query(knex).findOne({ + processId, + version: process.latestVersion, + }); + + if (!versionRecord) { + throw new Error('Process version not found.'); + } + + const runId = randomUUID(); + await AiProcessRun.query(knex).insert({ + id: runId, + tenantId: resolvedTenantId, + processId, + version: versionRecord.version, + status: 'running', + inputJson: input, + stateJson: input, + currentNodeId: null, + }); + + const run = await AiProcessRun.query(knex).findById(runId); + if (!run) { + throw new Error('Run not created.'); + } + + const compiled = versionRecord.compiledJson as CompiledGraph; + const toolRegistry = new ToolRegistry(); + const emitAndAudit = (event: AiProcessEventPayload) => { + emitEvent?.(event); + void AiAuditEvent.query(knex).insert({ + id: randomUUID(), + tenantId: resolvedTenantId, + runId, + eventType: event.type, + payloadJson: event, + }); + }; + const result = await runCompiledGraph( + { + compiledGraph: compiled, + input, + toolRegistry, + toolContext: { tenantId: resolvedTenantId, userId }, + onEvent: (event) => emitAndAudit({ ...event, runId, sessionId }), + llmDecision: async (node, state) => + this.mockDecision(node.id, state), + }, + run.currentNodeId ?? undefined, + ); + + const updatedRun = await this.persistRunResult(runId, result, knex); + + return { run: updatedRun, result }; + } + + async resumeRun( + tenantId: string, + userId: string, + runId: string, + input: Record, + sessionId: string | undefined, + emitEvent?: (payload: AiProcessEventPayload) => void, + ) { + const { knex, tenantId: resolvedTenantId } = + await this.getTenantContext(tenantId); + const run = await AiProcessRun.query(knex).findById(runId); + if (!run || run.tenantId !== resolvedTenantId) { + throw new Error('Run not found.'); + } + const versionRecord = await AiProcessVersion.query(knex).findOne({ + processId: run.processId, + version: run.version, + }); + if (!versionRecord) { + throw new Error('Process version not found.'); + } + + const compiled = versionRecord.compiledJson as CompiledGraph; + const toolRegistry = new ToolRegistry(); + const mergedState = { ...(run.stateJson || {}), ...input }; + const emitAndAudit = (event: AiProcessEventPayload) => { + emitEvent?.(event); + void AiAuditEvent.query(knex).insert({ + id: randomUUID(), + tenantId: resolvedTenantId, + runId: run.id, + eventType: event.type, + payloadJson: event, + }); + }; + + const result = await runCompiledGraph( + { + compiledGraph: compiled, + input: mergedState, + toolRegistry, + toolContext: { tenantId: resolvedTenantId, userId }, + onEvent: (event) => + emitAndAudit({ ...event, runId: run.id, sessionId }), + llmDecision: async (node, state) => + this.mockDecision(node.id, state), + }, + run.currentNodeId ?? undefined, + ); + + const updatedRun = await this.persistRunResult(run.id, result, knex); + + return { run: updatedRun, result }; + } + + private async persistRunResult(runId: string, result: any, knex: Knex) { + const endedAt = + result.status === 'completed' || result.status === 'error' + ? new Date() + : null; + + return AiProcessRun.query(knex).patchAndFetchById(runId, { + status: result.status, + outputJson: result.output, + errorJson: result.error, + stateJson: result.state, + currentNodeId: result.currentNodeId ?? null, + endedAt, + }); + } + + private async mockDecision( + nodeId: string, + state: Record, + ) { + if (nodeId === 'decide_account') { + const accountName = (state.accountName as string) ?? 'New Account'; + const accountAction = state.accountId ? 'find' : 'create'; + return { accountAction, accountName }; + } + if (nodeId === 'decide_contact') { + const firstName = (state.firstName as string) ?? 'Jane'; + const lastName = (state.lastName as string) ?? 'Doe'; + const contactAction = state.contactId ? 'find' : 'create'; + return { contactAction, firstName, lastName }; + } + return {}; + } +} diff --git a/backend/src/ai-processes/ai-processes.stream.service.ts b/backend/src/ai-processes/ai-processes.stream.service.ts new file mode 100644 index 0000000..f67a59e --- /dev/null +++ b/backend/src/ai-processes/ai-processes.stream.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import { MessageEvent } from '@nestjs/common'; +import { Observable, Subject } from 'rxjs'; +import { AiProcessEventPayload } from './ai-processes.types'; + +@Injectable() +export class AiProcessesStreamService { + private readonly streams = new Map>(); + + getStream(sessionId: string): Observable { + return this.getSubject(sessionId).asObservable(); + } + + emit(sessionId: string, payload: AiProcessEventPayload) { + const subject = this.getSubject(sessionId); + subject.next({ type: payload.type, data: payload }); + } + + close(sessionId: string) { + const subject = this.streams.get(sessionId); + if (subject) { + subject.complete(); + this.streams.delete(sessionId); + } + } + + private getSubject(sessionId: string) { + if (!this.streams.has(sessionId)) { + this.streams.set(sessionId, new Subject()); + } + return this.streams.get(sessionId) as Subject; + } +} diff --git a/backend/src/ai-processes/ai-processes.types.ts b/backend/src/ai-processes/ai-processes.types.ts new file mode 100644 index 0000000..e04562e --- /dev/null +++ b/backend/src/ai-processes/ai-processes.types.ts @@ -0,0 +1,124 @@ +import { JSONSchema7 } from 'json-schema'; + +export type AiNodeType = + | 'Start' + | 'LLMDecisionNode' + | 'ToolNode' + | 'HumanInputNode' + | 'End'; + +export interface ProcessGraphDefinition { + id: string; + name: string; + description?: string; + allowCycles?: boolean; + maxIterations?: number; + nodes: ProcessGraphNode[]; + edges: ProcessGraphEdge[]; +} + +export interface ProcessGraphNode { + id: string; + type: AiNodeType; + position?: { x: number; y: number }; + data: + | StartNodeData + | LLMDecisionNodeData + | ToolNodeData + | HumanInputNodeData + | EndNodeData; +} + +export interface ProcessGraphEdge { + id: string; + source: string; + target: string; + condition?: JsonLogicExpression; +} + +export type JsonLogicExpression = Record; + +export interface StartNodeData { + label?: string; +} + +export interface EndNodeData { + label?: string; +} + +export interface LLMDecisionNodeData { + label?: string; + promptTemplate: string; + inputKeys: string[]; + outputSchema: JSONSchema7; + model: { + name: string; + temperature: number; + }; +} + +export interface ToolNodeData { + label?: string; + toolName: string; + argsTemplate: Record; + outputMapping: Record; +} + +export interface HumanInputNodeData { + label?: string; + requiredFieldsSchema: JSONSchema7; + promptToUser: string; +} + +export interface CompiledGraph { + graphId: string; + version: number; + nodes: ProcessGraphNode[]; + edges: ProcessGraphEdge[]; + startNodeId: string; + endNodeIds: string[]; + adjacency: Record; + allowCycles?: boolean; + maxIterations?: number; +} + +export type AiProcessStatus = 'running' | 'waiting' | 'completed' | 'error'; + +export interface AiProcessRunContext { + state: Record; + currentNodeId?: string; + iterationCount?: number; +} + +export type AiProcessEventType = + | 'agent_started' + | 'processes_listed' + | 'process_selected' + | 'node_started' + | 'tool_called' + | 'node_completed' + | 'need_input' + | 'final' + | 'error'; + +export interface AiProcessEventPayload { + type: AiProcessEventType; + runId?: string; + sessionId?: string; + nodeId?: string; + toolName?: string; + processId?: string; + version?: number; + data?: Record; +} + +export interface NeedInputPayload { + runId: string; + requiredFieldsSchema: JSONSchema7; + promptToUser: string; +} + +export interface ProcessSelection { + processId: string; + version: number; +} diff --git a/backend/src/ai-processes/demo-process.ts b/backend/src/ai-processes/demo-process.ts new file mode 100644 index 0000000..a1a3e96 --- /dev/null +++ b/backend/src/ai-processes/demo-process.ts @@ -0,0 +1,173 @@ +import { ProcessGraphDefinition } from './ai-processes.types'; + +export const demoRegisterNewPetProcess: ProcessGraphDefinition = { + id: 'register_new_pet', + name: 'Register New Pet', + description: 'Resolve account/contact then create pet.', + allowCycles: false, + nodes: [ + { + id: 'start', + type: 'Start', + data: { label: 'Start' }, + }, + { + id: 'decide_account', + type: 'LLMDecisionNode', + data: { + label: 'Decide Account Action', + promptTemplate: + 'Decide whether to find or create an account. Return JSON {"accountAction":"find|create","accountName":"string"}.', + inputKeys: ['accountName'], + outputSchema: { + type: 'object', + required: ['accountAction', 'accountName'], + properties: { + accountAction: { type: 'string', enum: ['find', 'create'] }, + accountName: { type: 'string' }, + }, + additionalProperties: false, + }, + model: { name: 'gpt-4o-mini', temperature: 0 }, + }, + }, + { + id: 'find_account', + type: 'ToolNode', + data: { + label: 'Find Account', + toolName: 'findAccount', + argsTemplate: { accountName: '{{state.accountName}}' }, + outputMapping: { accountId: 'accountId', found: 'accountFound' }, + }, + }, + { + id: 'create_account', + type: 'ToolNode', + data: { + label: 'Create Account', + toolName: 'createAccount', + argsTemplate: { accountName: '{{state.accountName}}' }, + outputMapping: { accountId: 'accountId' }, + }, + }, + { + id: 'decide_contact', + type: 'LLMDecisionNode', + data: { + label: 'Decide Contact Action', + promptTemplate: + 'Decide whether to find or create a contact. Return JSON {"contactAction":"find|create","firstName":"string","lastName":"string"}.', + inputKeys: ['firstName', 'lastName'], + outputSchema: { + type: 'object', + required: ['contactAction', 'firstName', 'lastName'], + properties: { + contactAction: { type: 'string', enum: ['find', 'create'] }, + firstName: { type: 'string' }, + lastName: { type: 'string' }, + }, + additionalProperties: false, + }, + model: { name: 'gpt-4o-mini', temperature: 0 }, + }, + }, + { + id: 'find_contact', + type: 'ToolNode', + data: { + label: 'Find Contact', + toolName: 'findContact', + argsTemplate: { + accountId: '{{state.accountId}}', + firstName: '{{state.firstName}}', + lastName: '{{state.lastName}}', + }, + outputMapping: { contactId: 'contactId', found: 'contactFound' }, + }, + }, + { + id: 'create_contact', + type: 'ToolNode', + data: { + label: 'Create Contact', + toolName: 'createContact', + argsTemplate: { + accountId: '{{state.accountId}}', + firstName: '{{state.firstName}}', + lastName: '{{state.lastName}}', + }, + outputMapping: { contactId: 'contactId' }, + }, + }, + { + id: 'need_pet', + type: 'HumanInputNode', + data: { + label: 'Collect Pet Info', + promptToUser: 'What is the pet name and type?', + requiredFieldsSchema: { + type: 'object', + required: ['petName', 'petType'], + properties: { + petName: { type: 'string' }, + petType: { type: 'string' }, + }, + additionalProperties: false, + }, + }, + }, + { + id: 'create_pet', + type: 'ToolNode', + data: { + label: 'Create Pet', + toolName: 'createPet', + argsTemplate: { + contactId: '{{state.contactId}}', + petName: '{{state.petName}}', + petType: '{{state.petType}}', + }, + outputMapping: { petId: 'petId' }, + }, + }, + { + id: 'end', + type: 'End', + data: { label: 'End' }, + }, + ], + edges: [ + { id: 'e_start_account', source: 'start', target: 'decide_account' }, + { + id: 'e_account_find', + source: 'decide_account', + target: 'find_account', + condition: { '==': [{ var: 'accountAction' }, 'find'] }, + }, + { + id: 'e_account_create', + source: 'decide_account', + target: 'create_account', + condition: { '==': [{ var: 'accountAction' }, 'create'] }, + }, + { id: 'e_account_to_contact', source: 'find_account', target: 'decide_contact' }, + { id: 'e_create_account_to_contact', source: 'create_account', target: 'decide_contact' }, + { + id: 'e_contact_find', + source: 'decide_contact', + target: 'find_contact', + condition: { '==': [{ var: 'contactAction' }, 'find'] }, + }, + { + id: 'e_contact_create', + source: 'decide_contact', + target: 'create_contact', + condition: { '==': [{ var: 'contactAction' }, 'create'] }, + }, + { id: 'e_contact_to_pet', source: 'find_contact', target: 'need_pet' }, + { id: 'e_create_contact_to_pet', source: 'create_contact', target: 'need_pet' }, + { id: 'e_need_pet_to_create', source: 'need_pet', target: 'create_pet' }, + { id: 'e_pet_to_end', source: 'create_pet', target: 'end' }, + ], +}; diff --git a/backend/src/ai-processes/dto/ai-chat.dto.ts b/backend/src/ai-processes/dto/ai-chat.dto.ts new file mode 100644 index 0000000..a145eac --- /dev/null +++ b/backend/src/ai-processes/dto/ai-chat.dto.ts @@ -0,0 +1,28 @@ +import { IsArray, IsObject, IsOptional, IsString } from 'class-validator'; + +export class CreateChatSessionDto { + @IsOptional() + @IsString() + context?: string; +} + +export class SendChatMessageDto { + @IsString() + message!: string; + + @IsOptional() + @IsArray() + history?: { role: string; text: string }[]; + + @IsOptional() + @IsObject() + context?: Record; + + @IsOptional() + @IsString() + sessionId?: string; + + @IsOptional() + @IsString() + processId?: string; +} diff --git a/backend/src/ai-processes/dto/ai-process.dto.ts b/backend/src/ai-processes/dto/ai-process.dto.ts new file mode 100644 index 0000000..2e0cf78 --- /dev/null +++ b/backend/src/ai-processes/dto/ai-process.dto.ts @@ -0,0 +1,24 @@ +import { IsArray, IsObject, IsOptional, IsString } from 'class-validator'; +import { ProcessGraphDefinition } from '../ai-processes.types'; + +export class CreateAiProcessDto { + @IsString() + name!: string; + + @IsOptional() + @IsString() + description?: string; + + @IsObject() + graph!: ProcessGraphDefinition; +} + +export class UpdateAiProcessDto { + @IsObject() + graph!: ProcessGraphDefinition; +} + +export class AiProcessListResponseDto { + @IsArray() + items!: Record[]; +} diff --git a/backend/src/ai-processes/dto/ai-run.dto.ts b/backend/src/ai-processes/dto/ai-run.dto.ts new file mode 100644 index 0000000..df64538 --- /dev/null +++ b/backend/src/ai-processes/dto/ai-run.dto.ts @@ -0,0 +1,19 @@ +import { IsObject, IsOptional, IsString } from 'class-validator'; + +export class CreateAiRunDto { + @IsObject() + input!: Record; + + @IsOptional() + @IsString() + sessionId?: string; +} + +export class ResumeAiRunDto { + @IsObject() + input!: Record; + + @IsOptional() + @IsString() + sessionId?: string; +} diff --git a/backend/src/ai-processes/tools/tool-registry.ts b/backend/src/ai-processes/tools/tool-registry.ts new file mode 100644 index 0000000..66c8ab9 --- /dev/null +++ b/backend/src/ai-processes/tools/tool-registry.ts @@ -0,0 +1,48 @@ +export interface ToolContext { + tenantId: string; + userId: string; + authScopes?: string[]; +} + +export type ToolHandler = ( + ctx: ToolContext, + args: Record, +) => Promise>; + +const defaultTools: Record = { + findAccount: async () => ({ accountId: null, found: false }), + createAccount: async (_ctx, args) => ({ accountId: `acc_${Date.now()}`, args }), + findContact: async () => ({ contactId: null, found: false }), + createContact: async (_ctx, args) => ({ contactId: `con_${Date.now()}`, args }), + createPet: async (_ctx, args) => ({ petId: `pet_${Date.now()}`, args }), +}; + +const tenantAllowlist: Record = { + default: Object.keys(defaultTools), +}; + +export class ToolRegistry { + private tools: Record; + private allowlist: Record; + + constructor( + tools: Record = defaultTools, + allowlist: Record = tenantAllowlist, + ) { + this.tools = tools; + this.allowlist = allowlist; + } + + isToolAllowed(tenantId: string, toolName: string) { + const allowed = this.allowlist[tenantId] || this.allowlist.default || []; + return allowed.includes(toolName); + } + + getTool(toolName: string): ToolHandler { + const tool = this.tools[toolName]; + if (!tool) { + throw new Error(`Tool ${toolName} is not registered.`); + } + return tool; + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 00dcef8..d2059d3 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -9,6 +9,7 @@ import { AppBuilderModule } from './app-builder/app-builder.module'; import { PageLayoutModule } from './page-layout/page-layout.module'; import { VoiceModule } from './voice/voice.module'; import { AiAssistantModule } from './ai-assistant/ai-assistant.module'; +import { AiProcessesModule } from './ai-processes/ai-processes.module'; @Module({ imports: [ @@ -24,6 +25,7 @@ import { AiAssistantModule } from './ai-assistant/ai-assistant.module'; PageLayoutModule, VoiceModule, AiAssistantModule, + AiProcessesModule, ], }) export class AppModule {} diff --git a/backend/src/models/ai-chat.model.ts b/backend/src/models/ai-chat.model.ts new file mode 100644 index 0000000..94facca --- /dev/null +++ b/backend/src/models/ai-chat.model.ts @@ -0,0 +1,64 @@ +import { randomUUID } from 'crypto'; +import { snakeCaseMappers } from 'objection'; +import { BaseModel } from './base.model'; + +export class AiChatSession extends BaseModel { + static tableName = 'ai_chat_sessions'; + static columnNameMappers = snakeCaseMappers(); + + id!: string; + tenantId!: string; + userId!: string; + createdAt!: Date; + + $beforeInsert() { + this.id = this.id || randomUUID(); + this.createdAt = this.createdAt || new Date(); + } + + $beforeUpdate() {} + + static get relationMappings() { + return { + messages: { + relation: BaseModel.HasManyRelation, + modelClass: AiChatMessage, + join: { + from: 'ai_chat_sessions.id', + to: 'ai_chat_messages.sessionId', + }, + }, + }; + } +} + +export class AiChatMessage extends BaseModel { + static tableName = 'ai_chat_messages'; + static columnNameMappers = snakeCaseMappers(); + + id!: string; + sessionId!: string; + role!: string; + content!: string; + createdAt!: Date; + + $beforeInsert() { + this.id = this.id || randomUUID(); + this.createdAt = this.createdAt || new Date(); + } + + $beforeUpdate() {} + + static get relationMappings() { + return { + session: { + relation: BaseModel.BelongsToOneRelation, + modelClass: AiChatSession, + join: { + from: 'ai_chat_messages.sessionId', + to: 'ai_chat_sessions.id', + }, + }, + }; + } +} diff --git a/backend/src/models/ai-process.model.ts b/backend/src/models/ai-process.model.ts new file mode 100644 index 0000000..9220e85 --- /dev/null +++ b/backend/src/models/ai-process.model.ts @@ -0,0 +1,147 @@ +import { randomUUID } from 'crypto'; +import { QueryContext, snakeCaseMappers } from 'objection'; +import { BaseModel } from './base.model'; + +export class AiProcess extends BaseModel { + static tableName = 'ai_processes'; + static columnNameMappers = snakeCaseMappers(); + + id!: string; + tenantId!: string; + name!: string; + description?: string; + latestVersion!: number; + createdBy!: string; + createdAt!: Date; + updatedAt!: Date; + + $beforeInsert(queryContext: QueryContext) { + this.id = this.id || randomUUID(); + super.$beforeInsert(queryContext); + } + + static get relationMappings() { + return { + versions: { + relation: BaseModel.HasManyRelation, + modelClass: AiProcessVersion, + join: { + from: 'ai_processes.id', + to: 'ai_process_versions.processId', + }, + }, + runs: { + relation: BaseModel.HasManyRelation, + modelClass: AiProcessRun, + join: { + from: 'ai_processes.id', + to: 'ai_process_runs.processId', + }, + }, + }; + } +} + +export class AiProcessVersion extends BaseModel { + static tableName = 'ai_process_versions'; + static columnNameMappers = snakeCaseMappers(); + + id!: string; + tenantId!: string; + processId!: string; + version!: number; + graphJson!: Record; + compiledJson!: Record; + createdBy!: string; + createdAt!: Date; + + $beforeInsert() { + this.id = this.id || randomUUID(); + this.createdAt = this.createdAt || new Date(); + } + + $beforeUpdate() {} + + static get relationMappings() { + return { + process: { + relation: BaseModel.BelongsToOneRelation, + modelClass: AiProcess, + join: { + from: 'ai_process_versions.processId', + to: 'ai_processes.id', + }, + }, + }; + } +} + +export class AiProcessRun extends BaseModel { + static tableName = 'ai_process_runs'; + static columnNameMappers = snakeCaseMappers(); + + id!: string; + tenantId!: string; + processId!: string; + version!: number; + status!: string; + inputJson!: Record; + outputJson?: Record | null; + errorJson?: Record | null; + stateJson?: Record; + currentNodeId?: string | null; + startedAt?: Date; + endedAt?: Date | null; + + $beforeInsert() { + this.id = this.id || randomUUID(); + this.startedAt = this.startedAt || new Date(); + } + + $beforeUpdate() {} + + static get relationMappings() { + return { + process: { + relation: BaseModel.BelongsToOneRelation, + modelClass: AiProcess, + join: { + from: 'ai_process_runs.processId', + to: 'ai_processes.id', + }, + }, + }; + } +} + +export class AiAuditEvent extends BaseModel { + static tableName = 'ai_audit_events'; + static columnNameMappers = snakeCaseMappers(); + + id!: string; + tenantId!: string; + runId!: string; + eventType!: string; + payloadJson!: Record; + createdAt!: Date; + + $beforeInsert() { + this.id = this.id || randomUUID(); + this.createdAt = this.createdAt || new Date(); + } + + $beforeUpdate() {} + + static get relationMappings() { + return { + run: { + relation: BaseModel.BelongsToOneRelation, + modelClass: AiProcessRun, + join: { + from: 'ai_audit_events.runId', + to: 'ai_process_runs.id', + }, + }, + }; + } +} diff --git a/frontend/ai-processes-editor/index.html b/frontend/ai-processes-editor/index.html new file mode 100644 index 0000000..7ea68ce --- /dev/null +++ b/frontend/ai-processes-editor/index.html @@ -0,0 +1,12 @@ + + + + + + AI Process Builder + + +
+ + + diff --git a/frontend/ai-processes-editor/package.json b/frontend/ai-processes-editor/package.json new file mode 100644 index 0000000..618eb5e --- /dev/null +++ b/frontend/ai-processes-editor/package.json @@ -0,0 +1,23 @@ +{ + "name": "ai-processes-editor", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --port 5174", + "build": "vite build", + "preview": "vite preview --port 5174" + }, + "dependencies": { + "@xyflow/react": "^12.0.4", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.4", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.6.2", + "vite": "^5.4.2" + } +} diff --git a/frontend/ai-processes-editor/src/App.tsx b/frontend/ai-processes-editor/src/App.tsx new file mode 100644 index 0000000..6a64b0a --- /dev/null +++ b/frontend/ai-processes-editor/src/App.tsx @@ -0,0 +1,43 @@ +import { Background, Controls, MiniMap, ReactFlow, useEdgesState, useNodesState } from '@xyflow/react' +import '@xyflow/react/dist/style.css' +import './styles.css' + +const initialNodes = [ + { id: 'start', data: { label: 'Start' }, position: { x: 0, y: 0 } }, + { id: 'llm', data: { label: 'LLM Decision' }, position: { x: 220, y: 0 } }, + { id: 'tool', data: { label: 'Tool Node' }, position: { x: 440, y: 0 } }, + { id: 'end', data: { label: 'End' }, position: { x: 660, y: 0 } }, +] + +const initialEdges = [ + { id: 'e1-2', source: 'start', target: 'llm' }, + { id: 'e2-3', source: 'llm', target: 'tool' }, + { id: 'e3-4', source: 'tool', target: 'end' }, +] + +export const App = () => { + const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes) + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges) + + return ( +
+
+

AI Process Builder

+

Design tenant workflows with deterministic execution.

+
+
+ + + + + +
+
+ ) +} diff --git a/frontend/ai-processes-editor/src/main.tsx b/frontend/ai-processes-editor/src/main.tsx new file mode 100644 index 0000000..a059e67 --- /dev/null +++ b/frontend/ai-processes-editor/src/main.tsx @@ -0,0 +1,7 @@ +import { createRoot } from 'react-dom/client' +import { App } from './App' + +const root = document.getElementById('root') +if (root) { + createRoot(root).render() +} diff --git a/frontend/ai-processes-editor/src/styles.css b/frontend/ai-processes-editor/src/styles.css new file mode 100644 index 0000000..3cf95dc --- /dev/null +++ b/frontend/ai-processes-editor/src/styles.css @@ -0,0 +1,33 @@ +body { + margin: 0; + font-family: 'Inter', sans-serif; + color: #0f172a; +} + +.editor-shell { + display: flex; + flex-direction: column; + height: 100vh; +} + +.editor-header { + padding: 16px 20px; + border-bottom: 1px solid #e2e8f0; + background: #fff; +} + +.editor-header h1 { + margin: 0 0 4px; + font-size: 18px; +} + +.editor-header p { + margin: 0; + font-size: 12px; + color: #64748b; +} + +.editor-canvas { + flex: 1; + background: #f8fafc; +} diff --git a/frontend/ai-processes-editor/vite.config.ts b/frontend/ai-processes-editor/vite.config.ts new file mode 100644 index 0000000..83a1d0e --- /dev/null +++ b/frontend/ai-processes-editor/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 5174, + }, +}) diff --git a/frontend/components/AIChatBar.vue b/frontend/components/AIChatBar.vue index 0699a76..462f3b3 100644 --- a/frontend/components/AIChatBar.vue +++ b/frontend/components/AIChatBar.vue @@ -16,6 +16,12 @@ const messages = ref<{ role: 'user' | 'assistant'; text: string }[]>([]) const sending = ref(false) const route = useRoute() const { api } = useApi() +const sessionId = ref(null) + +const getTenantId = () => { + if (!import.meta.client) return 'tenant1' + return localStorage.getItem('tenantId') || 'tenant1' +} const buildContext = () => { const recordId = route.params.recordId ? String(route.params.recordId) : undefined @@ -43,27 +49,39 @@ const handleSend = async () => { try { const history = messages.value.slice(0, -1).slice(-6) - const response = await api.post('/ai/chat', { + const response = await api.post(`/tenants/${getTenantId()}/ai-chat/messages`, { message, history, context: buildContext(), + sessionId: sessionId.value || undefined, }) + if (response.sessionId) { + sessionId.value = response.sessionId + } + + if (response.reply) { + messages.value.push({ + role: 'assistant', + text: response.reply, + }) + if (response.action === 'create_record') { + window.dispatchEvent( + new CustomEvent('ai-record-created', { + detail: { + objectApiName: buildContext().objectApiName, + record: response.record, + }, + }), + ) + } + return + } + messages.value.push({ role: 'assistant', - text: response.reply || 'Let me know what else you need.', + text: 'Process started. I will post updates as soon as they are ready.', }) - - if (response.action === 'create_record') { - window.dispatchEvent( - new CustomEvent('ai-record-created', { - detail: { - objectApiName: buildContext().objectApiName, - record: response.record, - }, - }), - ) - } } catch (error: any) { console.error('Failed to send AI chat message:', error) messages.value.push({ diff --git a/frontend/components/ai-processes/NeedInputForm.vue b/frontend/components/ai-processes/NeedInputForm.vue new file mode 100644 index 0000000..c8e28f4 --- /dev/null +++ b/frontend/components/ai-processes/NeedInputForm.vue @@ -0,0 +1,52 @@ + + + diff --git a/frontend/components/ai-processes/ReactFlowIframe.vue b/frontend/components/ai-processes/ReactFlowIframe.vue new file mode 100644 index 0000000..6e07f93 --- /dev/null +++ b/frontend/components/ai-processes/ReactFlowIframe.vue @@ -0,0 +1,19 @@ +