Compare commits

..

2 Commits

Author SHA1 Message Date
Francisco Gaona
de65aa4025 WIP - using deep agent to create dog using workflow 2026-01-17 22:51:53 +01:00
Francisco Gaona
ded413b99b WIP - ai process builder codex attempt 2026-01-17 20:16:04 +01:00
68 changed files with 4360 additions and 2033 deletions

View File

@@ -0,0 +1,324 @@
# AI Process Builder + Chat Orchestrator
A complete implementation of tenant-scoped AI process automation where admins design LangGraph-compiled workflows via React Flow UI, and end-users execute them through a Deep Agent chat orchestrator with deterministic, audited execution.
## Architecture Overview
### Backend Components
#### 1. **Deep Agent Orchestrator** ([deep-agent.orchestrator.ts](backend/src/ai-processes/deep-agent.orchestrator.ts))
- Uses LangChain/OpenAI to intelligently select processes
- Extracts structured inputs from natural language
- Generates friendly confirmation messages
- Three-step workflow: discover → select → extract → execute
#### 2. **Graph Compiler** ([ai-processes.compiler.ts](backend/src/ai-processes/ai-processes.compiler.ts))
- Validates ReactFlow JSON graphs (Start/End nodes, reachability, cycles)
- Compiles to LangGraph-compatible state machines
- Validates tool allowlist and JSON schemas (Ajv)
- Persists compiled artifact for versioned execution
#### 3. **Runtime Executor** ([ai-processes.runner.ts](backend/src/ai-processes/ai-processes.runner.ts))
- Executes compiled graphs deterministically
- Implements 4 node types: LLMDecisionNode, ToolNode, HumanInputNode, End
- Handles conditional edges via jsonlogic
- Emits real-time events for streaming updates
#### 4. **Tool Registry** ([tools/tool-registry.ts](backend/src/ai-processes/tools/tool-registry.ts))
- Tenant-scoped tool allowlist (database-backed via AiToolConfig)
- Demo tools wrapping ObjectService (findAccount, createAccount, etc.)
- Context injection (tenantId, userId, knex) for secure execution
#### 5. **Orchestrator Service** ([ai-processes.orchestrator.service.ts](backend/src/ai-processes/ai-processes.orchestrator.service.ts))
- Integrates Deep Agent for process selection
- Falls back to standard AI assistant when no processes configured
- Manages chat sessions and message history
- Streams execution events via SSE
### Frontend Components
#### 1. **AIChatBar** ([components/AIChatBar.vue](frontend/components/AIChatBar.vue))
- Updated to call `/ai-processes/chat/messages` endpoint
- SSE event stream consumer for real-time updates
- Displays process selection, node execution, tool calls
- Handles NEED_INPUT events for human-in-the-loop
#### 2. **Process Management UI** ([pages/ai-processes/](frontend/pages/ai-processes/))
- List view: displays all processes with versions
- Editor view: React Flow integration via iframe + postMessage
- Test runner for quick validation
#### 3. **React Flow Editor** ([ai-processes-editor/src/App.tsx](frontend/ai-processes-editor/src/App.tsx))
- Node palette: Start, LLMDecisionNode, ToolNode, HumanInputNode, End
- Visual graph designer with drag-drop
- Auto-saves to parent window via postMessage
- Loads existing graphs for editing
### Data Models (Objection.js)
```typescript
AiProcess
├── id, tenantId, name, description, latestVersion
└── relations: versions[], runs[]
AiProcessVersion
├── id, tenantId, processId, version
├── graphJson (ReactFlow definition)
└── compiledJson (LangGraph artifact)
AiProcessRun
├── id, tenantId, processId, version, status
├── inputJson, outputJson, errorJson, stateJson
└── currentNodeId (for resume)
AiChatSession
├── id, tenantId, userId
└── relations: messages[]
AiChatMessage
├── id, sessionId, role, content
└── timestamps
AiAuditEvent
├── id, tenantId, runId, eventType
└── payloadJson (full event data)
AiToolConfig
├── id, tenantId, toolName, enabled
└── configJson (tool-specific settings)
```
## Demo Process: Register New Pet
A complete workflow demonstrating conditional logic and tool orchestration:
1. **Extract Info** (LLMDecisionNode)
- Parses user message for pet + owner details
- Outputs structured JSON with validation
2. **Find/Create Account** (Conditional)
- Searches for existing account by name/email
- Creates new account if not found
- Merges results into state
3. **Find/Create Contact** (Conditional)
- Searches for existing contact under account
- Creates new contact if not found
4. **Create Pet** (ToolNode)
- Inserts pet record linked to contact
- Returns pet ID
### Seed the Demo Process
```bash
cd backend
npm run migrate:tenant -- <tenant-slug>
npm run seed:demo-process -- <tenant-slug>
```
### Test the Demo Process
1. Navigate to `/ai-processes` in your tenant subdomain
2. Open "Register New Pet" process
3. Click "Test Run" or use the chat bar:
```
User: "Register a dog named Max, breed Golden Retriever, age 3,
owned by John Smith, email john@example.com"
Agent: 🔄 Selected process: Register New Pet
I'll register Max (Golden Retriever, 3 years old) for John Smith.
⚙️ Executing step: Extract Info
✓ Extracted pet details
🔧 Using tool: findAccount
Account not found, creating new account
🔧 Using tool: createAccount
✓ Created account for John Smith
🔧 Using tool: findContact
Contact not found, creating new contact
🔧 Using tool: createContact
✓ Created contact: John Smith
🔧 Using tool: createPet
✓ Created pet: Max (ID: pet_1234567890)
✅ Process completed successfully!
```
## API Endpoints
### Process Management (Admin)
```typescript
GET /tenants/:tenantId/ai-processes
POST /tenants/:tenantId/ai-processes
GET /tenants/:tenantId/ai-processes/:id
POST /tenants/:tenantId/ai-processes/:id/versions
GET /tenants/:tenantId/ai-processes/:id/versions
POST /tenants/:tenantId/ai-processes/:id/runs
POST /tenants/:tenantId/ai-processes/runs/:runId/resume
```
### Chat Orchestrator (End User)
```typescript
POST /tenants/:tenantId/ai-processes/chat/messages
SSE /tenants/:tenantId/ai-processes/stream?sessionId=xxx
```
## Event Stream Types
```typescript
type StreamEvent =
| { type: 'agent_started' }
| { type: 'processes_listed', data: { count: number } }
| { type: 'process_selected', processId: string, version: number }
| { type: 'agent_message', data: { message: string } }
| { type: 'node_started', nodeId: string }
| { type: 'node_completed', nodeId: string }
| { type: 'tool_called', toolName: string, nodeId: string }
| { type: 'llm_decision', nodeId: string, data: any }
| { type: 'need_input', data: { prompt: string, schema: JSONSchema } }
| { type: 'final', data: { output: any } }
| { type: 'error', data: { error: string } }
```
## Security & Guardrails
### 1. **Tenancy Isolation**
- All queries filtered by `tenantId` (enforced in Objection models)
- Tool context includes tenant scope
- Database-per-tenant architecture (inherited from platform)
### 2. **Tool Allowlist**
- Two-level validation:
- Tenant-level: `AiToolConfig` table (enabled tools per tenant)
- Compile-time: validates toolName exists in registry
- Runtime check before tool execution
### 3. **Schema Validation**
- LLMDecisionNode output validated against JSON Schema (Ajv)
- HumanInputNode input validated before resume
- Graph structure validated at compile time
### 4. **Audit Trail**
- Every node execution logged to `ai_audit_events`
- Includes: tool calls, LLM decisions, state mutations, errors
- Queryable for compliance dashboards
### 5. **Versioning**
- Immutable process versions (create-only)
- Runs reference specific version number
- Graph definition + compiled artifact stored together
## Running the System
### 1. **Run Migrations**
```bash
cd backend
npm run migrate:tenant -- tenant1
```
### 2. **Seed Demo Data**
```bash
npm run seed:demo-process -- tenant1
```
### 3. **Start Backend**
```bash
npm run start:dev
```
### 4. **Build Editor (if needed)**
```bash
cd frontend/ai-processes-editor
npm install
npm run build
```
### 5. **Start Frontend**
```bash
cd frontend
npm run dev
```
### 6. **Access UI**
- Admin UI: `http://tenant1.localhost:3001/ai-processes`
- Chat UI: Available in bottom drawer on any page (⌘K to toggle)
## Extension Points
### Adding New Node Types
1. Define type in [ai-processes.types.ts](backend/src/ai-processes/ai-processes.types.ts)
2. Add schema validation in [ai-processes.schemas.ts](backend/src/ai-processes/ai-processes.schemas.ts)
3. Implement executor in [ai-processes.runner.ts](backend/src/ai-processes/ai-processes.runner.ts)
4. Add UI component in React Flow editor
### Adding New Tools
1. Implement handler in [tools/demo-tools.ts](backend/src/ai-processes/tools/demo-tools.ts)
2. Register in `demoTools` export
3. Add to tenant allowlist via UI or seed script
4. Document input/output schema
### Custom LLM Decision Logic
Override `llmDecision` callback in [ai-processes.service.ts](backend/src/ai-processes/ai-processes.service.ts):
```typescript
llmDecision: async (node, state) => {
const prompt = renderTemplate(node.data.promptTemplate, state);
const response = await callOpenAI(prompt, node.data.model);
return validateAgainstSchema(response, node.data.outputSchema);
}
```
## Troubleshooting
### Process not appearing in chat
- Check: `npm run seed:demo-process` completed successfully
- Verify: Process exists in database (`select * from ai_processes`)
- Check: Tools enabled (`select * from ai_tool_configs`)
### Graph validation errors
- Ensure exactly one Start node
- Ensure at least one End node
- Check all edges reference valid node IDs
- Verify tool names match registered tools
### SSE stream not working
- Check CORS settings for subdomain routing
- Verify `sessionId` returned from initial message
- Check browser console for connection errors
- Fallback: use polling endpoint (TODO: implement)
## Next Steps
1. **Enhanced Input Extraction**: Use Deep Agent to extract required fields per process
2. **Visual Schema Builder**: UI for JSON Schema creation (drag-drop fields)
3. **Conditional Edge Builder**: Visual jsonlogic editor
4. **Process Analytics**: Dashboard showing run success rates, avg duration
5. **Human-in-Loop UI**: Dynamic form renderer for HumanInputNode
6. **Process Marketplace**: Share processes across tenants (with permissions)
7. **Python Microservice**: Optional Python runtime for native LangGraph support
## License
MIT

View File

@@ -0,0 +1,115 @@
-- Insert demo AI process directly
SET @process_id = '2d883482-4df0-44d7-b6cf-8541b482afe4';
SET @version_id = '437b1e72-405e-4862-a8bc-f368e554b482';
SET @user_id = 'system';
-- Insert process
INSERT INTO ai_processes (id, name, created_by)
VALUES (@process_id, 'Register New Pet', @user_id);
-- Insert process version with compiled graph
INSERT INTO ai_process_versions (id, process_id, version, graph_json, compiled_json, created_by)
VALUES (
@version_id,
@process_id,
1,
'{}',
JSON_OBJECT(
'id', 'register_new_pet',
'name', 'Register New Pet',
'description', 'Complete pet registration workflow',
'allowCycles', false,
'startNodeId', 'start',
'endNodeIds', JSON_ARRAY('end'),
'maxIterations', 50,
'nodes', JSON_ARRAY(
JSON_OBJECT('id', 'start', 'type', 'Start', 'data', JSON_OBJECT('label', 'Start')),
JSON_OBJECT('id', 'extract_info', 'type', 'LLMDecisionNode', 'data', JSON_OBJECT(
'label', 'Extract Info',
'promptTemplate', 'Extract: petName, species, ownerFirstName, ownerLastName, ownerEmail, accountName from: {{state.message}}',
'inputKeys', JSON_ARRAY('message'),
'outputSchema', JSON_OBJECT(
'type', 'object',
'properties', JSON_OBJECT(
'petName', JSON_OBJECT('type', 'string'),
'species', JSON_OBJECT('type', 'string'),
'ownerFirstName', JSON_OBJECT('type', 'string'),
'ownerLastName', JSON_OBJECT('type', 'string'),
'ownerEmail', JSON_OBJECT('type', 'string'),
'accountName', JSON_OBJECT('type', 'string')
),
'required', JSON_ARRAY('petName', 'species', 'ownerFirstName', 'ownerLastName')
)
)),
JSON_OBJECT('id', 'find_account', 'type', 'ToolNode', 'data', JSON_OBJECT(
'label', 'Find Account',
'toolName', 'findAccount',
'argsTemplate', JSON_OBJECT('name', '{{state.accountName}}', 'email', '{{state.ownerEmail}}'),
'outputMapping', JSON_OBJECT('found', 'accountFound', 'accountId', 'accountId')
)),
JSON_OBJECT('id', 'create_account', 'type', 'ToolNode', 'data', JSON_OBJECT(
'label', 'Create Account',
'toolName', 'createAccount',
'argsTemplate', JSON_OBJECT('name', '{{state.accountName}}', 'email', '{{state.ownerEmail}}'),
'outputMapping', JSON_OBJECT('accountId', 'accountId')
)),
JSON_OBJECT('id', 'find_contact', 'type', 'ToolNode', 'data', JSON_OBJECT(
'label', 'Find Contact',
'toolName', 'findContact',
'argsTemplate', JSON_OBJECT(
'firstName', '{{state.ownerFirstName}}',
'lastName', '{{state.ownerLastName}}',
'email', '{{state.ownerEmail}}',
'accountId', '{{state.accountId}}'
),
'outputMapping', JSON_OBJECT('found', 'contactFound', 'contactId', 'contactId')
)),
JSON_OBJECT('id', 'create_contact', 'type', 'ToolNode', 'data', JSON_OBJECT(
'label', 'Create Contact',
'toolName', 'createContact',
'argsTemplate', JSON_OBJECT(
'firstName', '{{state.ownerFirstName}}',
'lastName', '{{state.ownerLastName}}',
'email', '{{state.ownerEmail}}',
'accountId', '{{state.accountId}}'
),
'outputMapping', JSON_OBJECT('contactId', 'contactId')
)),
JSON_OBJECT('id', 'create_pet', 'type', 'ToolNode', 'data', JSON_OBJECT(
'label', 'Create Pet',
'toolName', 'createPet',
'argsTemplate', JSON_OBJECT(
'name', '{{state.petName}}',
'species', '{{state.species}}',
'ownerId', '{{state.contactId}}'
),
'outputMapping', JSON_OBJECT('petId', 'petId')
)),
JSON_OBJECT('id', 'end', 'type', 'End', 'data', JSON_OBJECT('label', 'End'))
),
'edges', JSON_ARRAY(
JSON_OBJECT('id', 'e1', 'source', 'start', 'target', 'extract_info'),
JSON_OBJECT('id', 'e2', 'source', 'extract_info', 'target', 'find_account'),
JSON_OBJECT('id', 'e3', 'source', 'find_account', 'target', 'find_contact', 'condition', JSON_OBJECT('==', JSON_ARRAY(JSON_OBJECT('var', 'accountFound'), true))),
JSON_OBJECT('id', 'e4', 'source', 'find_account', 'target', 'create_account', 'condition', JSON_OBJECT('==', JSON_ARRAY(JSON_OBJECT('var', 'accountFound'), false))),
JSON_OBJECT('id', 'e5', 'source', 'create_account', 'target', 'find_contact'),
JSON_OBJECT('id', 'e6', 'source', 'find_contact', 'target', 'create_pet', 'condition', JSON_OBJECT('==', JSON_ARRAY(JSON_OBJECT('var', 'contactFound'), true))),
JSON_OBJECT('id', 'e7', 'source', 'find_contact', 'target', 'create_contact', 'condition', JSON_OBJECT('==', JSON_ARRAY(JSON_OBJECT('var', 'contactFound'), false))),
JSON_OBJECT('id', 'e8', 'source', 'create_contact', 'target', 'create_pet'),
JSON_OBJECT('id', 'e9', 'source', 'create_pet', 'target', 'end')
)
),
@user_id
);
-- Insert tool allowlist
INSERT INTO ai_tool_configs (id, tool_name, enabled)
VALUES
(UUID(), 'findAccount', true),
(UUID(), 'createAccount', true),
(UUID(), 'findContact', true),
(UUID(), 'createContact', true),
(UUID(), 'createPet', true)
ON DUPLICATE KEY UPDATE enabled = true;
SELECT 'Demo process inserted successfully!' as result;

View File

@@ -0,0 +1,72 @@
exports.up = async function (knex) {
await knex.schema.createTable('ai_processes', (table) => {
table.uuid('id').primary();
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());
});
await knex.schema.createTable('ai_process_versions', (table) => {
table.uuid('id').primary();
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(['process_id']);
});
await knex.schema.createTable('ai_process_runs', (table) => {
table.uuid('id').primary();
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(['process_id']);
});
await knex.schema.createTable('ai_chat_sessions', (table) => {
table.uuid('id').primary();
table.string('user_id').notNullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
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.uuid('run_id').notNullable();
table.string('event_type').notNullable();
table.json('payload_json').notNullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
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');
};

View File

@@ -0,0 +1,14 @@
exports.up = async function (knex) {
await knex.schema.createTable('ai_tool_configs', (table) => {
table.uuid('id').primary();
table.string('tool_name').notNullable().unique();
table.boolean('enabled').notNullable().defaultTo(true);
table.json('config_json');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
});
};
exports.down = async function (knex) {
await knex.schema.dropTableIfExists('ai_tool_configs');
};

View File

@@ -1,190 +0,0 @@
exports.up = async function (knex) {
await knex.schema.createTable('approval_definitions', (table) => {
table.string('id', 191).primary().defaultTo(knex.raw('(UUID())'));
table.string('name', 191).notNullable();
table.text('description');
table.string('triggerType', 191).notNullable();
table.string('targetObjectType', 191);
table.json('entryCriteria');
table.json('steps');
table.json('votingPolicy');
table.string('rejectionRule', 191);
table.string('materialChangePolicy', 191);
table.integer('version').notNullable().defaultTo(1);
table.boolean('isActive').notNullable().defaultTo(true);
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now());
table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now());
});
await knex.schema.createTable('approval_requests', (table) => {
table.string('id', 191).primary().defaultTo(knex.raw('(UUID())'));
table.string('definitionId', 191).notNullable();
table.string('status', 191).notNullable().defaultTo('pending');
table.string('targetObjectType', 191).notNullable();
table.string('targetObjectId', 191).notNullable();
table.string('action', 191);
table.string('stateFrom', 191);
table.string('stateTo', 191);
table.json('fieldChanges');
table.json('snapshot');
table.string('versionHash', 191);
table.string('submittedById', 191);
table.timestamp('submittedAt');
table.string('currentStepKey', 191);
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now());
table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now());
table.index(['definitionId']);
table.index(['targetObjectType', 'targetObjectId']);
table.index(['submittedById']);
table
.foreign('definitionId')
.references('id')
.inTable('approval_definitions')
.onDelete('CASCADE')
.onUpdate('CASCADE');
table
.foreign('submittedById')
.references('id')
.inTable('users')
.onDelete('SET NULL')
.onUpdate('CASCADE');
});
await knex.schema.createTable('approval_steps', (table) => {
table.string('id', 191).primary().defaultTo(knex.raw('(UUID())'));
table.string('requestId', 191).notNullable();
table.string('stepKey', 191).notNullable();
table.string('name', 191).notNullable();
table.integer('stepOrder').notNullable();
table.string('status', 191).notNullable().defaultTo('pending');
table.json('routing');
table.json('voting');
table.timestamp('dueAt');
table.timestamp('completedAt');
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now());
table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now());
table.index(['requestId']);
table.index(['status']);
table
.foreign('requestId')
.references('id')
.inTable('approval_requests')
.onDelete('CASCADE')
.onUpdate('CASCADE');
});
await knex.schema.createTable('approval_assignments', (table) => {
table.string('id', 191).primary().defaultTo(knex.raw('(UUID())'));
table.string('stepId', 191).notNullable();
table.string('assigneeId', 191).notNullable();
table.string('status', 191).notNullable().defaultTo('pending');
table.text('response');
table.timestamp('respondedAt');
table.timestamp('dueAt');
table.string('reassignedFromId', 191);
table.string('delegatedById', 191);
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now());
table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now());
table.index(['stepId']);
table.index(['assigneeId']);
table.index(['status']);
table
.foreign('stepId')
.references('id')
.inTable('approval_steps')
.onDelete('CASCADE')
.onUpdate('CASCADE');
table
.foreign('assigneeId')
.references('id')
.inTable('users')
.onDelete('RESTRICT')
.onUpdate('CASCADE');
table
.foreign('reassignedFromId')
.references('id')
.inTable('users')
.onDelete('SET NULL')
.onUpdate('CASCADE');
table
.foreign('delegatedById')
.references('id')
.inTable('users')
.onDelete('SET NULL')
.onUpdate('CASCADE');
});
await knex.schema.createTable('approval_effect_logs', (table) => {
table.string('id', 191).primary().defaultTo(knex.raw('(UUID())'));
table.string('requestId', 191).notNullable();
table.string('effectKey', 191).notNullable();
table.string('status', 191).notNullable().defaultTo('success');
table.json('response');
table.timestamp('executedAt').notNullable().defaultTo(knex.fn.now());
table.unique(['requestId', 'effectKey']);
table.index(['requestId']);
table
.foreign('requestId')
.references('id')
.inTable('approval_requests')
.onDelete('CASCADE')
.onUpdate('CASCADE');
});
await knex.schema.createTable('tasks', (table) => {
table.string('id', 191).primary().defaultTo(knex.raw('(UUID())'));
table.string('title', 191).notNullable();
table.text('description');
table.string('status', 191).notNullable().defaultTo('open');
table.string('priority', 191);
table.timestamp('dueAt');
table.string('assignedToId', 191);
table.string('relatedObjectType', 191);
table.string('relatedObjectId', 191);
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now());
table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now());
table.index(['assignedToId']);
table.index(['relatedObjectType', 'relatedObjectId']);
table
.foreign('assignedToId')
.references('id')
.inTable('users')
.onDelete('SET NULL')
.onUpdate('CASCADE');
});
await knex.schema.createTable('activity_logs', (table) => {
table.string('id', 191).primary().defaultTo(knex.raw('(UUID())'));
table.string('action', 191).notNullable();
table.string('subjectType', 191).notNullable();
table.string('subjectId', 191).notNullable();
table.text('description');
table.json('properties');
table.string('causerId', 191);
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now());
table.index(['subjectType', 'subjectId']);
table.index(['causerId']);
table
.foreign('causerId')
.references('id')
.inTable('users')
.onDelete('SET NULL')
.onUpdate('CASCADE');
});
};
exports.down = async function (knex) {
await knex.schema.dropTableIfExists('activity_logs');
await knex.schema.dropTableIfExists('tasks');
await knex.schema.dropTableIfExists('approval_effect_logs');
await knex.schema.dropTableIfExists('approval_assignments');
await knex.schema.dropTableIfExists('approval_steps');
await knex.schema.dropTableIfExists('approval_requests');
await knex.schema.dropTableIfExists('approval_definitions');
};

View File

@@ -25,11 +25,15 @@
"@nestjs/serve-static": "^4.0.2", "@nestjs/serve-static": "^4.0.2",
"@nestjs/websockets": "^10.4.20", "@nestjs/websockets": "^10.4.20",
"@prisma/client": "^5.8.0", "@prisma/client": "^5.8.0",
"@types/json-logic-js": "^2.0.8",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"bullmq": "^5.1.0", "bullmq": "^5.1.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"json-logic-js": "^2.0.5",
"knex": "^3.1.0", "knex": "^3.1.0",
"langchain": "^1.2.7", "langchain": "^1.2.7",
"mysql2": "^3.15.3", "mysql2": "^3.15.3",
@@ -96,6 +100,41 @@
} }
} }
}, },
"node_modules/@angular-devkit/core/node_modules/ajv": {
"version": "8.12.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@angular-devkit/core/node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/@angular-devkit/core/node_modules/rxjs": { "node_modules/@angular-devkit/core/node_modules/rxjs": {
"version": "7.8.1", "version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
@@ -929,6 +968,23 @@
"fast-uri": "^2.0.0" "fast-uri": "^2.0.0"
} }
}, },
"node_modules/@fastify/ajv-compiler/node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/@fastify/cors": { "node_modules/@fastify/cors": {
"version": "9.0.1", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-9.0.1.tgz", "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-9.0.1.tgz",
@@ -2917,6 +2973,12 @@
"pretty-format": "^29.0.0" "pretty-format": "^29.0.0"
} }
}, },
"node_modules/@types/json-logic-js": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@types/json-logic-js/-/json-logic-js-2.0.8.tgz",
"integrity": "sha512-WgNsDPuTPKYXl0Jh0IfoCoJoAGGYZt5qzpmjuLSEg7r0cKp/kWtWp0HAsVepyPSPyXiHo6uXp/B/kW/2J1fa2Q==",
"license": "MIT"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -3574,15 +3636,15 @@
} }
}, },
"node_modules/ajv": { "node_modules/ajv": {
"version": "8.12.0", "version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0", "json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2", "require-from-string": "^2.0.2"
"uri-js": "^4.2.2"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@@ -3590,9 +3652,9 @@
} }
}, },
"node_modules/ajv-formats": { "node_modules/ajv-formats": {
"version": "2.1.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ajv": "^8.0.0" "ajv": "^8.0.0"
@@ -3619,6 +3681,22 @@
"ajv": "^8.8.2" "ajv": "^8.8.2"
} }
}, },
"node_modules/ajv/node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ansi-colors": { "node_modules/ansi-colors": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
@@ -5549,23 +5627,6 @@
"rfdc": "^1.2.0" "rfdc": "^1.2.0"
} }
}, },
"node_modules/fast-json-stringify/node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/fast-levenshtein": { "node_modules/fast-levenshtein": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
@@ -7593,6 +7654,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-logic-js": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/json-logic-js/-/json-logic-js-2.0.5.tgz",
"integrity": "sha512-rTT2+lqcuUmj4DgWfmzupZqQDA64AdmYqizzMPWj3DxGdfFNsxPpcNVSaTj4l8W2tG/+hg7/mQhxjU3aPacO6g==",
"license": "MIT"
},
"node_modules/json-parse-even-better-errors": { "node_modules/json-parse-even-better-errors": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
@@ -8655,37 +8722,22 @@
"knex": ">=1.0.1" "knex": ">=1.0.1"
} }
}, },
"node_modules/objection/node_modules/ajv": { "node_modules/objection/node_modules/ajv-formats": {
"version": "8.17.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "ajv": "^8.0.0"
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
}, },
"funding": { "peerDependencies": {
"type": "github", "ajv": "^8.0.0"
"url": "https://github.com/sponsors/epoberezkin" },
"peerDependenciesMeta": {
"ajv": {
"optional": true
} }
},
"node_modules/objection/node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
} }
],
"license": "BSD-3-Clause"
}, },
"node_modules/obliterator": { "node_modules/obliterator": {
"version": "2.0.5", "version": "2.0.5",
@@ -9347,6 +9399,7 @@
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -10482,6 +10535,24 @@
} }
} }
}, },
"node_modules/terser-webpack-plugin/node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/terser-webpack-plugin/node_modules/jest-worker": { "node_modules/terser-webpack-plugin/node_modules/jest-worker": {
"version": "27.5.1", "version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
@@ -11057,6 +11128,7 @@
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"punycode": "^2.1.0" "punycode": "^2.1.0"
@@ -11240,6 +11312,25 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/webpack/node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/webpack/node_modules/es-module-lexer": { "node_modules/webpack/node_modules/es-module-lexer": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",

View File

@@ -23,7 +23,8 @@
"migrate:rollback": "knex migrate:rollback --knexfile=knexfile.js", "migrate:rollback": "knex migrate:rollback --knexfile=knexfile.js",
"migrate:status": "ts-node -r tsconfig-paths/register scripts/check-migration-status.ts", "migrate:status": "ts-node -r tsconfig-paths/register scripts/check-migration-status.ts",
"migrate:tenant": "ts-node -r tsconfig-paths/register scripts/migrate-tenant.ts", "migrate:tenant": "ts-node -r tsconfig-paths/register scripts/migrate-tenant.ts",
"migrate:all-tenants": "ts-node -r tsconfig-paths/register scripts/migrate-all-tenants.ts" "migrate:all-tenants": "ts-node -r tsconfig-paths/register scripts/migrate-all-tenants.ts",
"seed:demo-process": "ts-node -r tsconfig-paths/register scripts/seed-demo-process.ts"
}, },
"dependencies": { "dependencies": {
"@casl/ability": "^6.7.5", "@casl/ability": "^6.7.5",
@@ -42,11 +43,15 @@
"@nestjs/serve-static": "^4.0.2", "@nestjs/serve-static": "^4.0.2",
"@nestjs/websockets": "^10.4.20", "@nestjs/websockets": "^10.4.20",
"@prisma/client": "^5.8.0", "@prisma/client": "^5.8.0",
"@types/json-logic-js": "^2.0.8",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"bullmq": "^5.1.0", "bullmq": "^5.1.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"json-logic-js": "^2.0.5",
"knex": "^3.1.0", "knex": "^3.1.0",
"langchain": "^1.2.7", "langchain": "^1.2.7",
"mysql2": "^3.15.3", "mysql2": "^3.15.3",

View File

@@ -181,6 +181,101 @@ model ContactDetail {
@@map("contact_details") @@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 // Application Builder
model App { model App {
id String @id @default(uuid()) id String @id @default(uuid())

View File

@@ -0,0 +1,332 @@
import { randomUUID } from 'crypto';
import { AiProcess, AiProcessVersion, AiToolConfig } from '../src/models/ai-process.model';
// Bootstrap NestJS to get proper services
async function getTenantContext(tenantSlugOrId: string) {
const { NestFactory } = await import('@nestjs/core');
const { AppModule } = await import('../src/app.module');
const { TenantDatabaseService } = await import('../src/tenant/tenant-database.service');
// Create app context (without listening)
const app = await NestFactory.createApplicationContext(AppModule, {
logger: false,
});
const tenantDbService = app.get(TenantDatabaseService);
// Resolve tenant ID
const tenantId = await tenantDbService.resolveTenantId(tenantSlugOrId);
// Get proper Knex connection
const knex = await tenantDbService.getTenantKnexById(tenantId);
return { tenantId, knex, app };
}
/**
* Seed script for demo AI Process: Register New Pet
*
* This process demonstrates:
* - Conditional logic (find or create account/contact)
* - Tool usage (findAccount, createAccount, findContact, createContact, createPet)
* - Sequential execution
* - LLM decision nodes with structured JSON output
*
* Usage:
* npm run seed:demo-process -- <tenant-slug-or-id>
*/
const demoProcessGraph = {
id: 'register_new_pet',
name: 'Register New Pet',
description: 'Complete pet registration workflow with account and contact resolution',
allowCycles: false,
nodes: [
{
id: 'start',
type: 'Start',
position: { x: 250, y: 50 },
data: { label: 'Start' },
},
{
id: 'extract_info',
type: 'LLMDecisionNode',
position: { x: 250, y: 150 },
data: {
label: 'Extract Pet Info',
promptTemplate: `Extract pet registration information from the user message.
User message: {{state.message}}
Extract:
- Pet name (required)
- Pet species (required, e.g., "dog", "cat", "bird")
- Pet breed (optional)
- Pet age (optional, as number)
- Owner first name (required)
- Owner last name (required)
- Owner email (optional)
- Owner phone (optional)
- Account/Company name (optional, defaults to owner's full name)
Return JSON with these exact fields.`,
inputKeys: ['message'],
outputSchema: {
type: 'object',
properties: {
petName: { type: 'string' },
species: { type: 'string' },
breed: { type: 'string' },
age: { type: 'number' },
ownerFirstName: { type: 'string' },
ownerLastName: { type: 'string' },
ownerEmail: { type: 'string' },
ownerPhone: { type: 'string' },
accountName: { type: 'string' },
},
required: ['petName', 'species', 'ownerFirstName', 'ownerLastName'],
},
model: { name: 'gpt-4o', temperature: 0 },
},
},
{
id: 'find_account',
type: 'ToolNode',
position: { x: 250, y: 280 },
data: {
label: 'Find Account',
toolName: 'findAccount',
argsTemplate: {
name: '{{state.accountName}}',
email: '{{state.ownerEmail}}',
},
outputMapping: {
found: 'accountFound',
accountId: 'accountId',
},
},
},
{
id: 'create_account',
type: 'ToolNode',
position: { x: 450, y: 380 },
data: {
label: 'Create Account',
toolName: 'createAccount',
argsTemplate: {
name: '{{state.accountName}}',
email: '{{state.ownerEmail}}',
phone: '{{state.ownerPhone}}',
},
outputMapping: {
accountId: 'accountId',
},
},
},
{
id: 'find_contact',
type: 'ToolNode',
position: { x: 250, y: 480 },
data: {
label: 'Find Contact',
toolName: 'findContact',
argsTemplate: {
firstName: '{{state.ownerFirstName}}',
lastName: '{{state.ownerLastName}}',
email: '{{state.ownerEmail}}',
accountId: '{{state.accountId}}',
},
outputMapping: {
found: 'contactFound',
contactId: 'contactId',
},
},
},
{
id: 'create_contact',
type: 'ToolNode',
position: { x: 450, y: 580 },
data: {
label: 'Create Contact',
toolName: 'createContact',
argsTemplate: {
firstName: '{{state.ownerFirstName}}',
lastName: '{{state.ownerLastName}}',
email: '{{state.ownerEmail}}',
phone: '{{state.ownerPhone}}',
accountId: '{{state.accountId}}',
},
outputMapping: {
contactId: 'contactId',
},
},
},
{
id: 'create_pet',
type: 'ToolNode',
position: { x: 250, y: 680 },
data: {
label: 'Create Pet Record',
toolName: 'createPet',
argsTemplate: {
name: '{{state.petName}}',
species: '{{state.species}}',
breed: '{{state.breed}}',
age: '{{state.age}}',
ownerId: '{{state.contactId}}',
},
outputMapping: {
petId: 'petId',
},
},
},
{
id: 'end',
type: 'End',
position: { x: 250, y: 780 },
data: { label: 'End' },
},
],
edges: [
{ id: 'e1', source: 'start', target: 'extract_info' },
{ id: 'e2', source: 'extract_info', target: 'find_account' },
{
id: 'e3',
source: 'find_account',
target: 'find_contact',
condition: { '==': [{ var: 'accountFound' }, true] },
},
{
id: 'e4',
source: 'find_account',
target: 'create_account',
condition: { '==': [{ var: 'accountFound' }, false] },
},
{ id: 'e5', source: 'create_account', target: 'find_contact' },
{
id: 'e6',
source: 'find_contact',
target: 'create_pet',
condition: { '==': [{ var: 'contactFound' }, true] },
},
{
id: 'e7',
source: 'find_contact',
target: 'create_contact',
condition: { '==': [{ var: 'contactFound' }, false] },
},
{ id: 'e8', source: 'create_contact', target: 'create_pet' },
{ id: 'e9', source: 'create_pet', target: 'end' },
],
};
const demoTools = [
'findAccount',
'createAccount',
'findContact',
'createContact',
'createPet',
];
async function seedDemoProcess(tenantSlugOrId: string) {
let app;
try {
console.log(`\n🌱 Seeding demo AI process for tenant: ${tenantSlugOrId}\n`);
const context = await getTenantContext(tenantSlugOrId);
const { tenantId, knex, app: nestApp } = context;
app = nestApp;
console.log(`✓ Resolved tenant ID: ${tenantId}`);
console.log(`✓ Connected to tenant database`);
// Check if process already exists
const existing = await AiProcess.query(knex)
.where('name', demoProcessGraph.name)
.first();
if (existing) {
console.log(`⚠ Process "${demoProcessGraph.name}" already exists (ID: ${existing.id})`);
console.log(` To create a new version, update via the UI.`);
return;
}
// Create process in transaction
await knex.transaction(async (trx) => {
const processId = randomUUID();
const userId = 'system'; // System user for seed data
// Create process
await AiProcess.query(trx).insert({
id: processId,
name: demoProcessGraph.name,
description: demoProcessGraph.description,
latestVersion: 1,
createdBy: userId,
});
console.log(`✓ Created process: ${demoProcessGraph.name} (${processId})`);
// Create initial version
// Note: In production, this would call the compiler service
// For seed, we're storing a simplified version
await AiProcessVersion.query(trx).insert({
id: randomUUID(),
processId,
version: 1,
graphJson: demoProcessGraph,
compiledJson: {
graphId: demoProcessGraph.id,
version: 1,
nodes: demoProcessGraph.nodes,
edges: demoProcessGraph.edges,
startNodeId: 'start',
endNodeIds: ['end'],
adjacency: {},
},
createdBy: userId,
});
console.log(`✓ Created process version 1`);
// Enable demo tools for tenant
for (const toolName of demoTools) {
const existingTool = await AiToolConfig.query(trx)
.where('tool_name', toolName)
.first();
if (!existingTool) {
await AiToolConfig.query(trx).insert({
id: randomUUID(),
toolName,
enabled: true,
});
console.log(`✓ Enabled tool: ${toolName}`);
}
}
});
console.log(`\n✅ Demo process seeded successfully!\n`);
console.log(`Next steps:`);
console.log(` 1. Navigate to /ai-processes in your frontend`);
console.log(` 2. Open the "${demoProcessGraph.name}" process`);
console.log(` 3. Test it by sending a message like:`);
console.log(` "Register a dog named Max, owned by John Smith (john@email.com)"`);
console.log();
if (app) await app.close();
process.exit(0);
} catch (error) {
console.error('❌ Seed failed:', error);
if (app) await app.close();
process.exit(1);
}
}
// Get tenant from command line args
const tenantSlugOrId = process.argv[2];
if (!tenantSlugOrId) {
console.error('Usage: npm run seed:demo-process -- <tenant-slug-or-id>');
process.exit(1);
}
seedDemoProcess(tenantSlugOrId);

View File

@@ -1,42 +0,0 @@
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
import { ActivityLogService } from './activity-log.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TenantId } from '../tenant/tenant.decorator';
@Controller('activity-log')
@UseGuards(JwtAuthGuard)
export class ActivityLogController {
constructor(private activityLogService: ActivityLogService) {}
@Get()
async listActivities(
@TenantId() tenantId: string,
@Query('subjectType') subjectType?: string,
@Query('subjectId') subjectId?: string,
@Query('causerId') causerId?: string,
@Query('action') action?: string,
) {
return this.activityLogService.listActivities(tenantId, {
subjectType,
subjectId,
causerId,
action,
});
}
@Post()
async createActivity(
@TenantId() tenantId: string,
@Body()
body: {
action: string;
subjectType: string;
subjectId: string;
description?: string;
properties?: Record<string, any>;
causerId?: string;
},
) {
return this.activityLogService.logActivity(tenantId, body);
}
}

View File

@@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { ActivityLogController } from './activity-log.controller';
import { ActivityLogService } from './activity-log.service';
import { TenantModule } from '../tenant/tenant.module';
@Module({
imports: [TenantModule],
controllers: [ActivityLogController],
providers: [ActivityLogService],
exports: [ActivityLogService],
})
export class ActivityLogModule {}

View File

@@ -1,54 +0,0 @@
import { Injectable } from '@nestjs/common';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { ActivityLog } from '../models/activity-log.model';
export interface ActivityLogInput {
action: string;
subjectType: string;
subjectId: string;
description?: string;
properties?: Record<string, any>;
causerId?: string | null;
}
@Injectable()
export class ActivityLogService {
constructor(private tenantDbService: TenantDatabaseService) {}
private async getKnex(tenantId: string) {
const resolved = await this.tenantDbService.resolveTenantId(tenantId);
return this.tenantDbService.getTenantKnexById(resolved);
}
async logActivity(tenantId: string, input: ActivityLogInput) {
const knex = await this.getKnex(tenantId);
return ActivityLog.query(knex).insert({
action: input.action,
subjectType: input.subjectType,
subjectId: input.subjectId,
description: input.description,
properties: input.properties ?? null,
causerId: input.causerId ?? null,
});
}
async listActivities(
tenantId: string,
filters: {
subjectType?: string;
subjectId?: string;
causerId?: string;
action?: string;
},
) {
const knex = await this.getKnex(tenantId);
return ActivityLog.query(knex)
.modify((qb) => {
if (filters.subjectType) qb.where('subjectType', filters.subjectType);
if (filters.subjectId) qb.where('subjectId', filters.subjectId);
if (filters.causerId) qb.where('causerId', filters.causerId);
if (filters.action) qb.where('action', filters.action);
})
.orderBy('createdAt', 'desc');
}
}

View File

@@ -10,5 +10,6 @@ import { MeilisearchModule } from '../search/meilisearch.module';
imports: [ObjectModule, PageLayoutModule, TenantModule, MeilisearchModule], imports: [ObjectModule, PageLayoutModule, TenantModule, MeilisearchModule],
controllers: [AiAssistantController], controllers: [AiAssistantController],
providers: [AiAssistantService], providers: [AiAssistantService],
exports: [AiAssistantService],
}) })
export class AiAssistantModule {} export class AiAssistantModule {}

View File

@@ -986,7 +986,7 @@ export class AiAssistantService {
].includes(apiName); ].includes(apiName);
} }
private async getOpenAiConfig(tenantId: string): Promise<OpenAIConfig | null> { async getOpenAiConfig(tenantId: string): Promise<OpenAIConfig | null> {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const centralPrisma = getCentralPrisma(); const centralPrisma = getCentralPrisma();
const tenant = await centralPrisma.tenant.findUnique({ const tenant = await centralPrisma.tenant.findUnique({

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -0,0 +1,191 @@
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<ProcessGraphDefinition>('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<string>();
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();
const allToolNames = toolRegistry.getAllToolNames();
graph.nodes.forEach((node) => {
if (node.type === 'ToolNode') {
const toolName = (node.data as { toolName?: string }).toolName;
if (!toolName) {
throw new GraphValidationError(
`ToolNode ${node.id} missing toolName configuration.`,
);
}
// Validate tool exists in registry (allowlist check happens at runtime)
if (!allToolNames.includes(toolName)) {
throw new GraphValidationError(
`Tool ${toolName} is not registered in the tool registry.`,
);
}
}
if (node.type === 'LLMDecisionNode') {
const data = node.data as {
promptTemplate?: string;
inputKeys?: string[];
outputSchema?: Record<string, unknown>;
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<string, unknown>;
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<Record<string, string[]>>((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<string>();
const stack = new Set<string>();
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));
};

View File

@@ -0,0 +1,144 @@
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')
@Post('ai-processes/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')
@Sse('ai-processes/stream')
streamChat(@Query('sessionId') sessionId: string) {
return this.streamService.getStream(sessionId);
}
}

View File

@@ -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 {}

View File

@@ -0,0 +1,212 @@
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';
import { DeepAgentOrchestrator } from './deep-agent.orchestrator';
@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({
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<string, unknown>,
) {
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) {
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 no processes configured, fallback to standard AI assistant
if (!processes.length) {
const response = await this.aiAssistantService.handleChat(
resolvedTenantId,
userId,
message,
(history ?? []) as any,
context ?? {},
);
this.streamService.emit(session.id, {
type: 'final',
data: { reply: response.reply, action: response.action },
});
await AiChatMessage.query(knex).insert({
sessionId: session.id,
role: 'assistant',
content: response.reply,
});
return {
sessionId: session.id,
reply: response.reply,
action: response.action,
record: response.record,
};
}
// Get OpenAI credentials from tenant integrations
const credentials = await this.aiAssistantService.getOpenAiConfig(resolvedTenantId);
if (!credentials?.apiKey) {
throw new Error('OpenAI credentials not configured for this tenant');
}
// Create Deep Agent with tenant's credentials
const deepAgent = new DeepAgentOrchestrator(credentials.apiKey, credentials.model);
// Use Deep Agent to select the best process
const processInfos = processes.map((p) => ({
id: p.id,
name: p.name,
description: p.description || undefined,
}));
const selection = await deepAgent.selectProcess(
message,
processInfos,
history as any,
);
// If we need more information or no match, respond with question
if (selection.action === 'need_more_info' || selection.action === 'no_match') {
const reply = selection.question || selection.reasoning ||
'I\'m not sure which process to use. Could you provide more details?';
this.streamService.emit(session.id, {
type: 'final',
data: { reply, needsMoreInfo: true },
});
await AiChatMessage.query(knex).insert({
sessionId: session.id,
role: 'assistant',
content: reply,
});
return { sessionId: session.id, reply, needsMoreInfo: true };
}
// Process selected - find it and execute
const selectedProcess = processes.find((p) => p.id === selection.processId);
if (!selectedProcess) {
throw new Error('Selected process not found.');
}
this.streamService.emit(session.id, {
type: 'process_selected',
processId: selectedProcess.id,
version: selectedProcess.latestVersion,
data: { processName: selectedProcess.name, reasoning: selection.reasoning },
});
// Extract inputs from the message
// For now, we'll use a simple approach - just pass the message as input
// In a more sophisticated implementation, we'd use the deep agent to extract structured inputs
const startMessage = await deepAgent.generateStartMessage(
selectedProcess.name,
{ message },
);
this.streamService.emit(session.id, {
type: 'agent_message',
data: { message: startMessage },
});
await AiChatMessage.query(knex).insert({
sessionId: session.id,
role: 'assistant',
content: startMessage,
});
const { run, result } = await this.processesService.createRun(
resolvedTenantId,
userId,
selectedProcess.id,
{ message, context: context || {} },
session.id,
(payload) => this.streamService.emit(session.id, payload),
);
// Emit final event
this.streamService.emit(session.id, {
type: 'final',
data: {
runId: run.id,
status: result.status,
output: result.output,
message: result.status === 'completed'
? '✅ Workflow completed successfully!'
: result.status === 'error'
? `❌ Workflow failed: ${result.error?.message || 'Unknown error'}`
: '⏸️ Workflow paused',
},
});
await AiChatMessage.query(knex).insert({
sessionId: session.id,
role: 'assistant',
content: result.status === 'completed'
? '✅ Workflow completed successfully!'
: result.status === 'error'
? `❌ Workflow failed: ${result.error?.message || 'Unknown error'}`
: '⏸️ Workflow paused',
});
return { sessionId: session.id, runId: run.id, status: result.status };
}
}

View File

@@ -0,0 +1,222 @@
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<string, unknown>;
toolRegistry: ToolRegistry;
toolContext: ToolContext;
onEvent?: (event: AiProcessEventPayload) => void;
llmDecision: (
node: ProcessGraphNode,
state: Record<string, unknown>,
) => Promise<Record<string, unknown>>;
}
export interface RunResult {
status: 'running' | 'waiting' | 'completed' | 'error';
state: Record<string, unknown>;
currentNodeId?: string;
output?: Record<string, unknown>;
error?: Record<string, unknown>;
}
export const runCompiledGraph = async (
options: RunOptions,
startNodeId?: string,
): Promise<RunResult> => {
const {
compiledGraph,
input,
toolRegistry,
toolContext,
onEvent,
llmDecision,
} = options;
const state: Record<string, unknown> = { ...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<string, unknown> })
.argsTemplate;
const resolvedArgs = resolveTemplate(argsTemplate, state);
// Debug logging
console.log(`[ToolNode ${node.id}] Tool: ${toolName}`);
console.log(`[ToolNode ${node.id}] State keys:`, Object.keys(state));
console.log(`[ToolNode ${node.id}] ArgsTemplate:`, JSON.stringify(argsTemplate));
console.log(`[ToolNode ${node.id}] ResolvedArgs:`, JSON.stringify(resolvedArgs));
const toolResult = await tool(toolContext, {
...resolvedArgs,
state,
});
console.log(`[ToolNode ${node.id}] ToolResult:`, JSON.stringify(toolResult));
const outputMapping = (node.data as { outputMapping: Record<string, string> })
.outputMapping;
Object.entries(outputMapping).forEach(([key, path]) => {
console.log(`[ToolNode ${node.id}] Mapping: toolResult['${key}'] = ${toolResult[key]} -> state['${path}']`);
state[path] = toolResult[key];
});
}
if (node.type === 'HumanInputNode') {
const data = node.data as {
requiredFieldsSchema: Record<string, unknown>;
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<string, unknown>,
state: Record<string, unknown>,
) => {
return Object.entries(template).reduce<Record<string, unknown>>(
(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<string, unknown>; target: string }[],
state: Record<string, unknown>,
) => {
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<string, unknown>,
) => {
const schema = (node.data as { outputSchema?: Record<string, unknown> })
.outputSchema;
if (!schema) return;
const ajv = new Ajv({ allErrors: true, strict: false });
const validate = ajv.compile(schema);
if (!validate(output)) {
const errors = validate.errors?.map(e => `${e.instancePath} ${e.message}`).join(', ');
throw new Error(
`LLM output invalid for node ${node.id}. Errors: ${errors}. Output: ${JSON.stringify(output)}`
);
}
};

View File

@@ -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: any = {
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 },
},
},
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' },
},
},
},
};
export const createAjv = () => {
const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);
ajv.addSchema(graphSchema, 'processGraph');
return ajv;
};

View File

@@ -0,0 +1,319 @@
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';
import { demoTools } from './tools/demo-tools';
@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)
.withGraphFetched('versions')
.orderBy('created_at', '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,
name,
description,
latestVersion: 1,
createdBy: userId,
});
await trx('ai_process_versions').insert({
id: randomUUID(),
process_id: processId,
version: 1,
graph_json: JSON.stringify(graph),
compiled_json: JSON.stringify(compiled),
created_by: userId,
created_at: new Date(),
});
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) {
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 trx('ai_process_versions').insert({
id: versionId,
process_id: processId,
version: nextVersion,
graph_json: JSON.stringify(graph),
compiled_json: JSON.stringify(compiled),
created_by: userId,
created_at: new Date(),
});
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({ process_id: processId })
.orderBy('version', 'desc');
}
async createRun(
tenantId: string,
userId: string,
processId: string,
input: Record<string, unknown>,
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) {
throw new Error('Process not found.');
}
const versionRecord = await AiProcessVersion.query(knex).findOne({
process_id: processId,
version: process.latestVersion,
});
if (!versionRecord) {
throw new Error('Process version not found.');
}
const runId = randomUUID();
await AiProcessRun.query(knex).insert({
id: runId,
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 unknown as CompiledGraph;
const toolRegistry = new ToolRegistry(demoTools);
await toolRegistry.loadTenantAllowlist(resolvedTenantId, knex);
const emitAndAudit = (event: AiProcessEventPayload) => {
emitEvent?.(event);
void AiAuditEvent.query(knex).insert({
id: randomUUID(),
runId,
eventType: event.type,
payloadJson: event as any,
});
};
const result = await runCompiledGraph(
{
compiledGraph: compiled,
input,
toolRegistry,
toolContext: { tenantId: resolvedTenantId, userId, knex },
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<string, unknown>,
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) {
throw new Error('Run not found.');
}
const versionRecord = await AiProcessVersion.query(knex).findOne({
process_id: run.processId,
version: run.version,
});
if (!versionRecord) {
throw new Error('Process version not found.');
}
const compiled = versionRecord.compiledJson as unknown as CompiledGraph;
const toolRegistry = new ToolRegistry(demoTools);
await toolRegistry.loadTenantAllowlist(resolvedTenantId, knex);
const mergedState = { ...(run.stateJson || {}), ...input };
const emitAndAudit = (event: AiProcessEventPayload) => {
emitEvent?.(event);
void AiAuditEvent.query(knex).insert({
id: randomUUID(),
runId: run.id,
eventType: event.type,
payloadJson: event as any,
});
};
const result = await runCompiledGraph(
{
compiledGraph: compiled,
input: mergedState,
toolRegistry,
toolContext: { tenantId: resolvedTenantId, userId, knex },
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<string, unknown>,
) {
if (nodeId === 'extract_info') {
// Extract pet registration info from the message
const message = (state.message as string) || '';
// Simple extraction (in production, this would use an LLM)
const petNameMatch = message.match(/(?:dog|cat|pet)\s+named\s+(\w+)/i);
const petTypeMatch = message.match(/(dog|cat)/i);
const ownerNameMatch = message.match(/owned\s+by\s+([\w\s]+?)(?:\s*\(|$)/i);
const emailMatch = message.match(/\(?([\w\.-]+@[\w\.-]+\.\w+)\)?/i);
const ownerName = ownerNameMatch?.[1]?.trim() || 'Unknown Owner';
const nameParts = ownerName.split(/\s+/);
const firstName = nameParts[0] || 'Unknown';
const lastName = nameParts.slice(1).join(' ') || 'Owner';
return {
petName: petNameMatch?.[1] || 'Unknown Pet',
species: petTypeMatch?.[1]?.toLowerCase() || 'dog',
ownerFirstName: firstName,
ownerLastName: lastName,
ownerEmail: emailMatch?.[1] || null,
accountName: `${firstName} ${lastName}`,
};
}
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 {};
}
}

View File

@@ -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<string, Subject<MessageEvent>>();
getStream(sessionId: string): Observable<MessageEvent> {
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<MessageEvent>());
}
return this.streams.get(sessionId) as Subject<MessageEvent>;
}
}

View File

@@ -0,0 +1,125 @@
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<string, unknown>;
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<string, unknown>;
outputMapping: Record<string, string>;
}
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<string, string[]>;
allowCycles?: boolean;
maxIterations?: number;
}
export type AiProcessStatus = 'running' | 'waiting' | 'completed' | 'error';
export interface AiProcessRunContext {
state: Record<string, unknown>;
currentNodeId?: string;
iterationCount?: number;
}
export type AiProcessEventType =
| 'agent_started'
| 'processes_listed'
| 'process_selected'
| 'agent_message'
| '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<string, unknown>;
}
export interface NeedInputPayload {
runId: string;
requiredFieldsSchema: JSONSchema7;
promptToUser: string;
}
export interface ProcessSelection {
processId: string;
version: number;
}

View File

@@ -0,0 +1,202 @@
import { ChatOpenAI } from '@langchain/openai';
import { JsonOutputParser } from '@langchain/core/output_parsers';
import { SystemMessage, HumanMessage } from '@langchain/core/messages';
export interface ProcessInfo {
id: string;
name: string;
description?: string;
}
export interface ProcessSelectionResult {
action: 'select_process' | 'need_more_info' | 'no_match';
processId?: string;
question?: string;
reasoning?: string;
}
export interface InputExtractionResult {
hasAllInputs: boolean;
extractedInputs: Record<string, unknown>;
missingFields?: string[];
question?: string;
}
export class DeepAgentOrchestrator {
private model: ChatOpenAI;
constructor(
apiKey: string,
modelName: string = 'gpt-4o',
temperature: number = 0,
) {
this.model = new ChatOpenAI({
apiKey,
modelName,
temperature,
});
}
/**
* Step 1: Select the best matching process from available processes
*/
async selectProcess(
userMessage: string,
availableProcesses: ProcessInfo[],
conversationHistory?: { role: string; text: string }[],
): Promise<ProcessSelectionResult> {
const processList = availableProcesses
.map((p) => `- ${p.name} (ID: ${p.id}): ${p.description || 'No description'}`)
.join('\n');
const historyContext =
conversationHistory && conversationHistory.length > 0
? `\n\nConversation history:\n${conversationHistory
.map((msg) => `${msg.role}: ${msg.text}`)
.join('\n')}`
: '';
const systemPrompt = `You are an intelligent process orchestrator. Your task is to select the most appropriate business process based on the user's request.
Available processes:
${processList}
Rules:
1. Select exactly ONE process that best matches the user's intent
2. If the request is ambiguous or matches multiple processes, ask for clarification
3. If no process matches, indicate no match
4. Always provide reasoning for your decision
Respond with JSON:
{
"action": "select_process" | "need_more_info" | "no_match",
"processId": "selected process ID or null",
"question": "clarifying question if needed",
"reasoning": "brief explanation of decision"
}`;
const userPrompt = `User request: ${userMessage}${historyContext}`;
try {
const response = await this.model.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(userPrompt),
]);
const parser = new JsonOutputParser<ProcessSelectionResult>();
const content = response.content as string;
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return await parser.parse(jsonMatch[0]);
}
return {
action: 'no_match',
reasoning: 'Failed to parse LLM response',
};
} catch (error: any) {
console.error('Process selection error:', error);
return {
action: 'no_match',
reasoning: `Error: ${error.message}`,
};
}
}
/**
* Step 2: Extract required inputs from user message
*/
async extractInputs(
userMessage: string,
requiredFields: { name: string; description: string; required: boolean }[],
conversationHistory?: { role: string; text: string }[],
context?: Record<string, unknown>,
): Promise<InputExtractionResult> {
const fieldsList = requiredFields
.map((f) => `- ${f.name} (${f.required ? 'required' : 'optional'}): ${f.description}`)
.join('\n');
const historyContext =
conversationHistory && conversationHistory.length > 0
? `\n\nConversation history:\n${conversationHistory
.map((msg) => `${msg.role}: ${msg.text}`)
.join('\n')}`
: '';
const contextInfo = context ? `\n\nAvailable context: ${JSON.stringify(context)}` : '';
const systemPrompt = `You are an input extraction assistant. Extract structured data from the user's message and conversation history.
Required fields for this process:
${fieldsList}${contextInfo}
Rules:
1. Extract as many fields as possible from the message and context
2. Only mark hasAllInputs=true if ALL required fields are present
3. If required fields are missing, generate a natural question to ask the user
4. Use context data when available (e.g., current page context)
Respond with JSON:
{
"hasAllInputs": true | false,
"extractedInputs": { "field1": "value1", ... },
"missingFields": ["field1", "field2"] or undefined,
"question": "natural language question" or undefined
}`;
const userPrompt = `User message: ${userMessage}${historyContext}`;
try {
const response = await this.model.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(userPrompt),
]);
const parser = new JsonOutputParser<InputExtractionResult>();
const content = response.content as string;
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return await parser.parse(jsonMatch[0]);
}
return {
hasAllInputs: false,
extractedInputs: {},
missingFields: requiredFields.filter((f) => f.required).map((f) => f.name),
question: 'I need more information to proceed. Could you provide additional details?',
};
} catch (error: any) {
console.error('Input extraction error:', error);
return {
hasAllInputs: false,
extractedInputs: {},
question: 'I encountered an error processing your request. Please try again.',
};
}
}
/**
* Step 3: Generate a friendly response explaining what will happen
*/
async generateStartMessage(
processName: string,
extractedInputs: Record<string, unknown>,
): Promise<string> {
const systemPrompt = `You are a friendly assistant explaining what process will be executed. Be concise and clear.`;
const userPrompt = `Generate a brief message (1-2 sentences) confirming that you will execute the "${processName}" process with these inputs: ${JSON.stringify(extractedInputs)}`;
try {
const response = await this.model.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(userPrompt),
]);
return (response.content as string).trim();
} catch (error) {
return `I'll execute the ${processName} process with your provided information.`;
}
}
}

View File

@@ -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' },
],
};

View File

@@ -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<string, unknown>;
@IsOptional()
@IsString()
sessionId?: string;
@IsOptional()
@IsString()
processId?: string;
}

View File

@@ -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<string, unknown>[];
}

View File

@@ -0,0 +1,19 @@
import { IsObject, IsOptional, IsString } from 'class-validator';
export class CreateAiRunDto {
@IsObject()
input!: Record<string, unknown>;
@IsOptional()
@IsString()
sessionId?: string;
}
export class ResumeAiRunDto {
@IsObject()
input!: Record<string, unknown>;
@IsOptional()
@IsString()
sessionId?: string;
}

View File

@@ -0,0 +1,226 @@
import { ToolContext, ToolHandler } from './tool-registry';
import { Account } from '../../models/account.model';
import { Contact } from '../../models/contact.model';
import { randomUUID } from 'crypto';
/**
* Demo tools that wrap ObjectService operations
* These tools provide structured access to CRM entities
*/
export const findAccount: ToolHandler = async (ctx, args) => {
if (!ctx.knex) {
throw new Error('Knex connection required for findAccount');
}
const { name } = args as { name?: string };
if (!name) {
return { found: false, accountId: null, message: 'Name required' };
}
try {
const query = Account.query(ctx.knex).where('name', 'like', `%${name}%`);
const account = await query.first();
if (account) {
return {
found: true,
accountId: account.id,
account: {
id: account.id,
name: account.name,
},
};
}
return { found: false, accountId: null };
} catch (error: any) {
return { found: false, error: error.message };
}
};
export const createAccount: ToolHandler = async (ctx, args) => {
if (!ctx.knex) {
throw new Error('Knex connection required for createAccount');
}
const { name, email, phone, industry } = args as {
name: string;
email?: string;
phone?: string;
industry?: string;
};
if (!name) {
throw new Error('Account name is required');
}
try {
const accountId = randomUUID();
await ctx.knex('accounts').insert({
id: accountId,
name,
phone,
industry,
ownerId: ctx.userId,
});
return {
success: true,
accountId,
account: {
id: accountId,
name,
},
};
} catch (error: any) {
return { success: false, error: error.message };
}
};
export const findContact: ToolHandler = async (ctx, args) => {
if (!ctx.knex) {
throw new Error('Knex connection required for findContact');
}
const { firstName, lastName, accountId } = args as {
firstName?: string;
lastName?: string;
accountId?: string;
};
if (!firstName && !lastName) {
return {
found: false,
contactId: null,
message: 'First name or last name required',
};
}
try {
let query = Contact.query(ctx.knex);
if (firstName) {
query = query.where('firstName', 'like', `%${firstName}%`);
}
if (lastName) {
query = query.where('lastName', 'like', `%${lastName}%`);
}
if (accountId) {
query = query.where('accountId', accountId);
}
const contact = await query.first();
if (contact) {
return {
found: true,
contactId: contact.id,
contact: {
id: contact.id,
firstName: contact.firstName,
lastName: contact.lastName,
accountId: contact.accountId,
},
};
}
return { found: false, contactId: null };
} catch (error: any) {
return { found: false, error: error.message };
}
};
export const createContact: ToolHandler = async (ctx, args) => {
if (!ctx.knex) {
throw new Error('Knex connection required for createContact');
}
const { firstName, lastName, email, phone, accountId } = args as {
firstName: string;
lastName: string;
email?: string;
phone?: string;
accountId?: string;
};
if (!firstName || !lastName) {
throw new Error('First name and last name are required');
}
try {
const contactId = randomUUID();
await ctx.knex('contacts').insert({
id: contactId,
firstName,
lastName,
accountId,
ownerId: ctx.userId,
});
return {
success: true,
contactId,
contact: {
id: contactId,
firstName,
lastName,
accountId,
},
};
} catch (error: any) {
return { success: false, error: error.message };
}
};
export const createPet: ToolHandler = async (ctx, args) => {
if (!ctx.knex) {
throw new Error('Knex connection required for createPet');
}
const { name, species, breed, age, ownerId } = args as {
name: string;
species: string;
breed?: string;
age?: number;
ownerId: string; // Contact ID
};
if (!name || !ownerId) {
throw new Error('Pet name and owner (contact) are required');
}
try {
const petId = randomUUID();
// Get the accountId from the contact
const contact = await ctx.knex('contacts').where('id', ownerId).first();
// Insert into dogs table
await ctx.knex('dogs').insert({
id: petId,
name,
ownerId,
accountId: contact?.accountId,
});
return {
success: true,
petId,
pet: { id: petId, name, ownerId, accountId: contact?.accountId },
};
} catch (error: any) {
return { success: false, error: error.message };
}
};
// Export all demo tools
export const demoTools = {
findAccount,
createAccount,
findContact,
createContact,
createPet,
};

View File

@@ -0,0 +1,89 @@
import { Knex } from 'knex';
import { AiToolConfig } from '../../models/ai-process.model';
export interface ToolContext {
tenantId: string;
userId: string;
knex?: Knex;
authScopes?: string[];
}
export type ToolHandler = (
ctx: ToolContext,
args: Record<string, unknown>,
) => Promise<Record<string, unknown>>;
export interface ToolDefinition {
name: string;
description: string;
handler: ToolHandler;
inputSchema?: Record<string, unknown>;
}
const defaultTools: Record<string, ToolHandler> = {
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<string, string[]> = {
default: Object.keys(defaultTools),
};
export class ToolRegistry {
private tools: Record<string, ToolHandler>;
private allowlist: Record<string, string[]>;
private dbAllowlistCache: Map<string, Set<string>> = new Map();
constructor(
tools: Record<string, ToolHandler> = defaultTools,
allowlist: Record<string, string[]> = tenantAllowlist,
) {
this.tools = tools;
this.allowlist = allowlist;
}
registerTool(name: string, handler: ToolHandler) {
this.tools[name] = handler;
}
async loadTenantAllowlist(tenantId: string, knex: Knex) {
const configs = await AiToolConfig.query(knex)
.where('enabled', true);
const allowed = new Set(configs.map((c) => c.toolName));
this.dbAllowlistCache.set(tenantId, allowed);
return allowed;
}
async isToolAllowed(tenantId: string, toolName: string, knex?: Knex) {
// Check database cache first
if (this.dbAllowlistCache.has(tenantId)) {
return this.dbAllowlistCache.get(tenantId)!.has(toolName);
}
// Load from database if knex provided
if (knex) {
const allowed = await this.loadTenantAllowlist(tenantId, knex);
return allowed.has(toolName);
}
// Fallback to static allowlist
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;
}
getAllToolNames(): string[] {
return Object.keys(this.tools);
}
}

View File

@@ -9,9 +9,7 @@ import { AppBuilderModule } from './app-builder/app-builder.module';
import { PageLayoutModule } from './page-layout/page-layout.module'; import { PageLayoutModule } from './page-layout/page-layout.module';
import { VoiceModule } from './voice/voice.module'; import { VoiceModule } from './voice/voice.module';
import { AiAssistantModule } from './ai-assistant/ai-assistant.module'; import { AiAssistantModule } from './ai-assistant/ai-assistant.module';
import { ActivityLogModule } from './activity-log/activity-log.module'; import { AiProcessesModule } from './ai-processes/ai-processes.module';
import { TaskModule } from './task/task.module';
import { ApprovalModule } from './approval/approval.module';
@Module({ @Module({
imports: [ imports: [
@@ -27,9 +25,7 @@ import { ApprovalModule } from './approval/approval.module';
PageLayoutModule, PageLayoutModule,
VoiceModule, VoiceModule,
AiAssistantModule, AiAssistantModule,
ActivityLogModule, AiProcessesModule,
TaskModule,
ApprovalModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -1,54 +0,0 @@
import { Body, Controller, Get, Patch, Param, Post, UseGuards } from '@nestjs/common';
import { ApprovalService } from './approval.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TenantId } from '../tenant/tenant.decorator';
@Controller('approvals')
@UseGuards(JwtAuthGuard)
export class ApprovalRequestController {
constructor(private approvalService: ApprovalService) {}
@Get()
async listRequests(@TenantId() tenantId: string) {
return this.approvalService.listRequests(tenantId);
}
@Post()
async createRequest(
@TenantId() tenantId: string,
@Body()
body: {
definitionId: string;
targetObjectType: string;
targetObjectId: string;
action?: string;
stateFrom?: string;
stateTo?: string;
fieldChanges?: Record<string, any>;
snapshot?: Record<string, any>;
submittedById?: string;
},
) {
return this.approvalService.createRequest(tenantId, body);
}
@Patch('assignments/:assignmentId')
async updateAssignmentStatus(
@TenantId() tenantId: string,
@Param('assignmentId') assignmentId: string,
@Body()
body: {
status: 'approved' | 'rejected';
response?: string;
actedById?: string;
},
) {
return this.approvalService.updateAssignmentStatus(
tenantId,
assignmentId,
body.status,
body.response,
body.actedById,
);
}
}

View File

@@ -1,56 +0,0 @@
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { ApprovalService } from './approval.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TenantId } from '../tenant/tenant.decorator';
@Controller('setup/approvals')
@UseGuards(JwtAuthGuard)
export class ApprovalSetupController {
constructor(private approvalService: ApprovalService) {}
@Get()
async listDefinitions(@TenantId() tenantId: string) {
return this.approvalService.listDefinitions(tenantId);
}
@Post()
async createDefinition(
@TenantId() tenantId: string,
@Body()
body: {
name: string;
description?: string;
triggerType: string;
targetObjectType?: string;
entryCriteria?: Record<string, any>;
steps?: Array<Record<string, any>>;
votingPolicy?: Record<string, any>;
rejectionRule?: string;
materialChangePolicy?: string;
isActive?: boolean;
},
) {
return this.approvalService.createDefinition(tenantId, body);
}
@Patch(':definitionId')
async updateDefinition(
@TenantId() tenantId: string,
@Param('definitionId') definitionId: string,
@Body()
body: {
name?: string;
description?: string;
triggerType?: string;
targetObjectType?: string;
entryCriteria?: Record<string, any>;
steps?: Array<Record<string, any>>;
votingPolicy?: Record<string, any>;
rejectionRule?: string;
materialChangePolicy?: string;
isActive?: boolean;
},
) {
return this.approvalService.updateDefinition(tenantId, definitionId, body);
}
}

View File

@@ -1,14 +0,0 @@
import { Module } from '@nestjs/common';
import { ApprovalService } from './approval.service';
import { ApprovalSetupController } from './approval-setup.controller';
import { ApprovalRequestController } from './approval-request.controller';
import { ActivityLogModule } from '../activity-log/activity-log.module';
import { TaskModule } from '../task/task.module';
import { TenantModule } from '../tenant/tenant.module';
@Module({
imports: [TenantModule, ActivityLogModule, TaskModule],
controllers: [ApprovalSetupController, ApprovalRequestController],
providers: [ApprovalService],
})
export class ApprovalModule {}

View File

@@ -1,402 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { createHash } from 'crypto';
import { ActivityLogService } from '../activity-log/activity-log.service';
import { TaskService } from '../task/task.service';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { ApprovalDefinition } from '../models/approval-definition.model';
import { ApprovalRequest } from '../models/approval-request.model';
import { ApprovalStep } from '../models/approval-step.model';
import { ApprovalAssignment } from '../models/approval-assignment.model';
import { ApprovalEffectLog } from '../models/approval-effect-log.model';
interface ApprovalDefinitionInput {
name: string;
description?: string;
triggerType: string;
targetObjectType?: string;
entryCriteria?: Record<string, any> | null;
steps?: Array<Record<string, any>> | null;
votingPolicy?: Record<string, any> | null;
rejectionRule?: string | null;
materialChangePolicy?: string | null;
isActive?: boolean;
}
interface ApprovalRequestInput {
definitionId: string;
targetObjectType: string;
targetObjectId: string;
action?: string;
stateFrom?: string;
stateTo?: string;
fieldChanges?: Record<string, any> | null;
snapshot?: Record<string, any> | null;
submittedById?: string | null;
}
@Injectable()
export class ApprovalService {
constructor(
private tenantDbService: TenantDatabaseService,
private activityLogService: ActivityLogService,
private taskService: TaskService,
) {}
private async getKnex(tenantId: string) {
const resolved = await this.tenantDbService.resolveTenantId(tenantId);
return this.tenantDbService.getTenantKnexById(resolved);
}
async listDefinitions(tenantId: string) {
const knex = await this.getKnex(tenantId);
return ApprovalDefinition.query(knex).orderBy('createdAt', 'desc');
}
async createDefinition(tenantId: string, input: ApprovalDefinitionInput) {
const knex = await this.getKnex(tenantId);
const definition = await ApprovalDefinition.query(knex).insert({
name: input.name,
description: input.description,
triggerType: input.triggerType,
targetObjectType: input.targetObjectType,
entryCriteria: input.entryCriteria ?? null,
steps: input.steps ?? null,
votingPolicy: input.votingPolicy ?? null,
rejectionRule: input.rejectionRule ?? null,
materialChangePolicy: input.materialChangePolicy ?? null,
isActive: input.isActive ?? true,
version: 1,
});
await this.activityLogService.logActivity(tenantId, {
action: 'approval_definition.created',
subjectType: 'ApprovalDefinition',
subjectId: definition.id,
description: `Created approval definition ${definition.name}`,
});
return definition;
}
async updateDefinition(
tenantId: string,
definitionId: string,
input: Partial<ApprovalDefinitionInput>,
) {
const knex = await this.getKnex(tenantId);
const existing = await ApprovalDefinition.query(knex).findById(definitionId);
if (!existing) {
throw new NotFoundException('Approval definition not found');
}
const needsVersionBump =
input.entryCriteria !== undefined ||
input.steps !== undefined ||
input.votingPolicy !== undefined ||
input.rejectionRule !== undefined ||
input.materialChangePolicy !== undefined;
const definition = await ApprovalDefinition.query(knex).patchAndFetchById(definitionId, {
name: input.name,
description: input.description,
triggerType: input.triggerType,
targetObjectType: input.targetObjectType,
entryCriteria: input.entryCriteria ?? undefined,
steps: input.steps ?? undefined,
votingPolicy: input.votingPolicy ?? undefined,
rejectionRule: input.rejectionRule ?? undefined,
materialChangePolicy: input.materialChangePolicy ?? undefined,
isActive: input.isActive,
version: needsVersionBump ? existing.version + 1 : existing.version,
});
await this.activityLogService.logActivity(tenantId, {
action: 'approval_definition.updated',
subjectType: 'ApprovalDefinition',
subjectId: definition.id,
description: `Updated approval definition ${definition.name}`,
});
return definition;
}
async listRequests(tenantId: string) {
const knex = await this.getKnex(tenantId);
return ApprovalRequest.query(knex)
.withGraphFetched('[steps.assignments,definition]')
.orderBy('createdAt', 'desc');
}
async createRequest(tenantId: string, input: ApprovalRequestInput) {
const knex = await this.getKnex(tenantId);
const definition = await ApprovalDefinition.query(knex).findById(input.definitionId);
if (!definition) {
throw new NotFoundException('Approval definition not found');
}
const versionHash = this.createVersionHash({
snapshot: input.snapshot ?? {},
fieldChanges: input.fieldChanges ?? {},
definitionVersion: definition.version,
});
const request = await ApprovalRequest.query(knex).insertAndFetch({
definitionId: definition.id,
status: 'pending',
targetObjectType: input.targetObjectType,
targetObjectId: input.targetObjectId,
action: input.action,
stateFrom: input.stateFrom,
stateTo: input.stateTo,
fieldChanges: input.fieldChanges ?? null,
snapshot: input.snapshot ?? null,
versionHash,
submittedById: input.submittedById ?? null,
submittedAt: new Date(),
});
const stepConfigs = Array.isArray(definition.steps) ? definition.steps : [];
for (const [index, stepConfig] of stepConfigs.entries()) {
const stepKey = stepConfig.key || `step-${index + 1}`;
const step = await ApprovalStep.query(knex).insertAndFetch({
requestId: request.id,
stepKey,
name: stepConfig.name || `Step ${index + 1}`,
stepOrder: stepConfig.order ?? index + 1,
status: 'pending',
routing: stepConfig.routing ?? null,
voting: stepConfig.voting ?? null,
dueAt: stepConfig.dueAt ? new Date(stepConfig.dueAt) : null,
});
const assignees: string[] = Array.isArray(stepConfig.assignees)
? stepConfig.assignees
: [];
for (const assigneeId of assignees) {
const assignment = await ApprovalAssignment.query(knex).insertAndFetch({
stepId: step.id,
assigneeId,
status: 'pending',
dueAt: stepConfig.dueAt ? new Date(stepConfig.dueAt) : null,
});
await this.taskService.createTask(tenantId, {
title: `Approval needed: ${definition.name}`,
description: `Approval step ${step.name} requires your review.`,
dueAt: stepConfig.dueAt ?? undefined,
assignedToId: assigneeId,
relatedObjectType: 'ApprovalAssignment',
relatedObjectId: assignment.id,
});
await this.activityLogService.logActivity(tenantId, {
action: 'approval_assignment.created',
subjectType: 'ApprovalAssignment',
subjectId: assignment.id,
description: `Assignment created for approval step ${step.name}`,
causerId: input.submittedById ?? undefined,
properties: {
requestId: request.id,
stepId: step.id,
},
});
}
}
await this.activityLogService.logActivity(tenantId, {
action: 'approval_request.created',
subjectType: 'ApprovalRequest',
subjectId: request.id,
description: `Created approval request for ${input.targetObjectType}`,
causerId: input.submittedById ?? undefined,
properties: {
definitionId: definition.id,
targetObjectId: input.targetObjectId,
},
});
return request;
}
async updateAssignmentStatus(
tenantId: string,
assignmentId: string,
status: 'approved' | 'rejected',
response?: string,
actedById?: string,
) {
const knex = await this.getKnex(tenantId);
const assignment = await ApprovalAssignment.query(knex)
.findById(assignmentId)
.withGraphFetched('step.request');
if (!assignment) {
throw new NotFoundException('Approval assignment not found');
}
const updatedAssignment = await ApprovalAssignment.query(knex).patchAndFetchById(
assignmentId,
{
status,
response,
respondedAt: new Date(),
},
);
await this.taskService.updateTaskByRelated(
tenantId,
'ApprovalAssignment',
assignmentId,
{
status: 'completed',
},
);
await this.activityLogService.logActivity(tenantId, {
action: `approval_assignment.${status}`,
subjectType: 'ApprovalAssignment',
subjectId: assignmentId,
description: `Assignment ${status}`,
causerId: actedById ?? assignment.assigneeId,
properties: {
stepId: assignment.stepId,
requestId: assignment.step?.requestId,
},
});
await this.evaluateStepCompletion(
tenantId,
assignment.stepId,
actedById ?? assignment.assigneeId,
);
return updatedAssignment;
}
private async evaluateStepCompletion(tenantId: string, stepId: string, actedById?: string) {
const knex = await this.getKnex(tenantId);
const step = await ApprovalStep.query(knex)
.findById(stepId)
.withGraphFetched('[assignments, request.[definition,steps]]');
if (!step) {
return;
}
const voting = (step.voting as Record<string, any>) || {};
const rule = voting.type || 'unanimous';
const rejectionRule = voting.rejectionRule || step.request?.definition?.rejectionRule || 'any';
const approvals = (step.assignments || []).filter((assignment) => assignment.status === 'approved');
const rejections = (step.assignments || []).filter((assignment) => assignment.status === 'rejected');
const totalAssignments = step.assignments?.length || 1;
if (rejections.length > 0 && rejectionRule === 'any') {
await this.completeStep(tenantId, step, 'rejected', actedById);
await this.completeRequestIfNeeded(tenantId, step.requestId, 'rejected', actedById);
return;
}
const approved = this.checkApprovalRule(rule, approvals.length, totalAssignments, voting.threshold);
if (approved) {
await this.completeStep(tenantId, step, 'approved', actedById);
await this.completeRequestIfNeeded(tenantId, step.requestId, 'approved', actedById);
}
}
private checkApprovalRule(rule: string, approvals: number, total: number, threshold?: number) {
if (rule === 'majority') {
return approvals > total / 2;
}
if (rule === 'k-of-n') {
const required = threshold ?? total;
return approvals >= required;
}
return approvals === total;
}
private async completeStep(
tenantId: string,
step: { id: string; status: string; requestId: string; name: string },
status: string,
actedById?: string,
) {
const knex = await this.getKnex(tenantId);
if (step.status === status) {
return;
}
await ApprovalStep.query(knex).patchAndFetchById(step.id, {
status,
completedAt: new Date(),
});
await this.activityLogService.logActivity(tenantId, {
action: `approval_step.${status}`,
subjectType: 'ApprovalStep',
subjectId: step.id,
description: `Step ${step.name} marked ${status}`,
causerId: actedById,
properties: { requestId: step.requestId },
});
}
private async completeRequestIfNeeded(
tenantId: string,
requestId: string,
status: 'approved' | 'rejected',
actedById?: string,
) {
const knex = await this.getKnex(tenantId);
const request = await ApprovalRequest.query(knex)
.findById(requestId)
.withGraphFetched('steps');
if (!request) {
return;
}
const allApproved = (request.steps || []).every((step) => step.status === 'approved');
if (status === 'rejected' || allApproved) {
const finalStatus = status === 'rejected' ? 'rejected' : 'approved';
await ApprovalRequest.query(knex).patchAndFetchById(requestId, {
status: finalStatus,
});
await this.activityLogService.logActivity(tenantId, {
action: `approval_request.${finalStatus}`,
subjectType: 'ApprovalRequest',
subjectId: requestId,
description: `Request ${finalStatus}`,
causerId: actedById,
});
await this.logEffectExecution(tenantId, requestId, `on_${finalStatus}`);
}
}
private async logEffectExecution(tenantId: string, requestId: string, effectKey: string) {
const knex = await this.getKnex(tenantId);
const existing = await ApprovalEffectLog.query(knex)
.where({ requestId, effectKey })
.first();
if (existing) {
return existing;
}
return ApprovalEffectLog.query(knex).insert({
requestId,
effectKey,
status: 'success',
});
}
private createVersionHash(payload: Record<string, any>) {
return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
}
}

View File

@@ -1,14 +0,0 @@
import { BaseModel } from './base.model';
export class ActivityLog extends BaseModel {
static tableName = 'activity_logs';
id!: string;
action!: string;
subjectType!: string;
subjectId!: string;
description?: string;
properties?: Record<string, any>;
causerId?: string | null;
createdAt!: Date;
}

View File

@@ -0,0 +1,63 @@
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;
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.session_id',
},
},
};
}
}
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.session_id',
to: 'ai_chat_sessions.id',
},
},
};
}
}

View File

@@ -0,0 +1,164 @@
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;
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.process_id',
},
},
runs: {
relation: BaseModel.HasManyRelation,
modelClass: AiProcessRun,
join: {
from: 'ai_processes.id',
to: 'ai_process_runs.process_id',
},
},
};
}
}
export class AiProcessVersion extends BaseModel {
static tableName = 'ai_process_versions';
static columnNameMappers = snakeCaseMappers();
static jsonAttributes = ['graphJson', 'compiledJson'];
id!: string;
processId!: string;
version!: number;
graphJson!: Record<string, unknown>;
compiledJson!: Record<string, unknown>;
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.process_id',
to: 'ai_processes.id',
},
},
};
}
}
export class AiProcessRun extends BaseModel {
static tableName = 'ai_process_runs';
static columnNameMappers = snakeCaseMappers();
static jsonAttributes = ['inputJson', 'outputJson', 'errorJson', 'stateJson'];
id!: string;
processId!: string;
version!: number;
status!: string;
inputJson!: Record<string, unknown>;
outputJson?: Record<string, unknown> | null;
errorJson?: Record<string, unknown> | null;
stateJson?: Record<string, unknown>;
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.process_id',
to: 'ai_processes.id',
},
},
};
}
}
export class AiAuditEvent extends BaseModel {
static tableName = 'ai_audit_events';
static columnNameMappers = snakeCaseMappers();
static jsonAttributes = ['payloadJson'];
id!: string;
runId!: string;
eventType!: string;
payloadJson!: Record<string, unknown>;
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.run_id',
to: 'ai_process_runs.id',
},
},
};
}
}
export class AiToolConfig extends BaseModel {
static tableName = 'ai_tool_configs';
static columnNameMappers = snakeCaseMappers();
static jsonAttributes = ['configJson'];
id!: string;
toolName!: string;
enabled!: boolean;
configJson?: Record<string, unknown>;
createdAt!: Date;
updatedAt!: Date;
$beforeInsert(queryContext: QueryContext) {
this.id = this.id || randomUUID();
super.$beforeInsert(queryContext);
}
}

View File

@@ -1,30 +0,0 @@
import { BaseModel } from './base.model';
import { ApprovalStep } from './approval-step.model';
export class ApprovalAssignment extends BaseModel {
static tableName = 'approval_assignments';
id!: string;
stepId!: string;
assigneeId!: string;
status!: string;
response?: string | null;
respondedAt?: Date | null;
dueAt?: Date | null;
reassignedFromId?: string | null;
delegatedById?: string | null;
createdAt!: Date;
updatedAt!: Date;
step?: ApprovalStep;
static relationMappings = () => ({
step: {
relation: BaseModel.BelongsToOneRelation,
modelClass: 'approval-step.model',
join: {
from: 'approval_assignments.stepId',
to: 'approval_steps.id',
},
},
});
}

View File

@@ -1,31 +0,0 @@
import { BaseModel } from './base.model';
export class ApprovalDefinition extends BaseModel {
static tableName = 'approval_definitions';
id!: string;
name!: string;
description?: string;
triggerType!: string;
targetObjectType?: string;
entryCriteria?: Record<string, any> | null;
steps?: any[] | null;
votingPolicy?: Record<string, any> | null;
rejectionRule?: string | null;
materialChangePolicy?: string | null;
version!: number;
isActive!: boolean;
createdAt!: Date;
updatedAt!: Date;
static relationMappings = () => ({
requests: {
relation: BaseModel.HasManyRelation,
modelClass: 'approval-request.model',
join: {
from: 'approval_definitions.id',
to: 'approval_requests.definitionId',
},
},
});
}

View File

@@ -1,23 +0,0 @@
import { BaseModel } from './base.model';
export class ApprovalEffectLog extends BaseModel {
static tableName = 'approval_effect_logs';
id!: string;
requestId!: string;
effectKey!: string;
status!: string;
response?: Record<string, any> | null;
executedAt!: Date;
static relationMappings = () => ({
request: {
relation: BaseModel.BelongsToOneRelation,
modelClass: 'approval-request.model',
join: {
from: 'approval_effect_logs.requestId',
to: 'approval_requests.id',
},
},
});
}

View File

@@ -1,55 +0,0 @@
import { BaseModel } from './base.model';
import { ApprovalDefinition } from './approval-definition.model';
import { ApprovalStep } from './approval-step.model';
import { ApprovalEffectLog } from './approval-effect-log.model';
export class ApprovalRequest extends BaseModel {
static tableName = 'approval_requests';
id!: string;
definitionId!: string;
status!: string;
targetObjectType!: string;
targetObjectId!: string;
action?: string;
stateFrom?: string;
stateTo?: string;
fieldChanges?: Record<string, any> | null;
snapshot?: Record<string, any> | null;
versionHash?: string | null;
submittedById?: string | null;
submittedAt?: Date | null;
currentStepKey?: string | null;
createdAt!: Date;
updatedAt!: Date;
definition?: ApprovalDefinition;
steps?: ApprovalStep[];
effectLogs?: ApprovalEffectLog[];
static relationMappings = () => ({
definition: {
relation: BaseModel.BelongsToOneRelation,
modelClass: 'approval-definition.model',
join: {
from: 'approval_requests.definitionId',
to: 'approval_definitions.id',
},
},
steps: {
relation: BaseModel.HasManyRelation,
modelClass: 'approval-step.model',
join: {
from: 'approval_requests.id',
to: 'approval_steps.requestId',
},
},
effectLogs: {
relation: BaseModel.HasManyRelation,
modelClass: 'approval-effect-log.model',
join: {
from: 'approval_requests.id',
to: 'approval_effect_logs.requestId',
},
},
});
}

View File

@@ -1,41 +0,0 @@
import { BaseModel } from './base.model';
import { ApprovalRequest } from './approval-request.model';
import { ApprovalAssignment } from './approval-assignment.model';
export class ApprovalStep extends BaseModel {
static tableName = 'approval_steps';
id!: string;
requestId!: string;
stepKey!: string;
name!: string;
stepOrder!: number;
status!: string;
routing?: Record<string, any> | null;
voting?: Record<string, any> | null;
dueAt?: Date | null;
completedAt?: Date | null;
createdAt!: Date;
updatedAt!: Date;
request?: ApprovalRequest;
assignments?: ApprovalAssignment[];
static relationMappings = () => ({
request: {
relation: BaseModel.BelongsToOneRelation,
modelClass: 'approval-request.model',
join: {
from: 'approval_steps.requestId',
to: 'approval_requests.id',
},
},
assignments: {
relation: BaseModel.HasManyRelation,
modelClass: 'approval-assignment.model',
join: {
from: 'approval_steps.id',
to: 'approval_assignments.stepId',
},
},
});
}

View File

@@ -1,15 +0,0 @@
import { BaseModel } from './base.model';
export class Task extends BaseModel {
static tableName = 'tasks';
id!: string;
title!: string;
description?: string;
status!: string;
priority?: string;
dueAt?: Date;
assignedToId?: string | null;
relatedObjectType?: string | null;
relatedObjectId?: string | null;
}

View File

@@ -1,63 +0,0 @@
import { Controller, Get, Post, Patch, Body, Param, Query, UseGuards } from '@nestjs/common';
import { TaskService } from './task.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TenantId } from '../tenant/tenant.decorator';
@Controller('tasks')
@UseGuards(JwtAuthGuard)
export class TaskController {
constructor(private taskService: TaskService) {}
@Get()
async listTasks(
@TenantId() tenantId: string,
@Query('status') status?: string,
@Query('assignedToId') assignedToId?: string,
@Query('relatedObjectType') relatedObjectType?: string,
@Query('relatedObjectId') relatedObjectId?: string,
) {
return this.taskService.listTasks(tenantId, {
status,
assignedToId,
relatedObjectType,
relatedObjectId,
});
}
@Post()
async createTask(
@TenantId() tenantId: string,
@Body()
body: {
title: string;
description?: string;
status?: string;
priority?: string;
dueAt?: string;
assignedToId?: string;
relatedObjectType?: string;
relatedObjectId?: string;
},
) {
return this.taskService.createTask(tenantId, body);
}
@Patch(':taskId')
async updateTask(
@TenantId() tenantId: string,
@Param('taskId') taskId: string,
@Body()
body: {
title?: string;
description?: string;
status?: string;
priority?: string;
dueAt?: string;
assignedToId?: string;
relatedObjectType?: string;
relatedObjectId?: string;
},
) {
return this.taskService.updateTask(tenantId, taskId, body);
}
}

View File

@@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { TaskController } from './task.controller';
import { TaskService } from './task.service';
import { TenantModule } from '../tenant/tenant.module';
@Module({
imports: [TenantModule],
controllers: [TaskController],
providers: [TaskService],
exports: [TaskService],
})
export class TaskModule {}

View File

@@ -1,91 +0,0 @@
import { Injectable } from '@nestjs/common';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { Task } from '../models/task.model';
export interface TaskInput {
title: string;
description?: string;
status?: string;
priority?: string;
dueAt?: string | Date | null;
assignedToId?: string | null;
relatedObjectType?: string | null;
relatedObjectId?: string | null;
}
@Injectable()
export class TaskService {
constructor(private tenantDbService: TenantDatabaseService) {}
private async getKnex(tenantId: string) {
const resolved = await this.tenantDbService.resolveTenantId(tenantId);
return this.tenantDbService.getTenantKnexById(resolved);
}
async listTasks(
tenantId: string,
filters: {
status?: string;
assignedToId?: string;
relatedObjectType?: string;
relatedObjectId?: string;
},
) {
const knex = await this.getKnex(tenantId);
return Task.query(knex)
.modify((qb) => {
if (filters.status) qb.where('status', filters.status);
if (filters.assignedToId) qb.where('assignedToId', filters.assignedToId);
if (filters.relatedObjectType)
qb.where('relatedObjectType', filters.relatedObjectType);
if (filters.relatedObjectId) qb.where('relatedObjectId', filters.relatedObjectId);
})
.orderBy('createdAt', 'desc');
}
async createTask(tenantId: string, input: TaskInput) {
const knex = await this.getKnex(tenantId);
return Task.query(knex).insert({
title: input.title,
description: input.description,
status: input.status ?? 'open',
priority: input.priority,
dueAt: input.dueAt ? new Date(input.dueAt) : null,
assignedToId: input.assignedToId ?? null,
relatedObjectType: input.relatedObjectType ?? null,
relatedObjectId: input.relatedObjectId ?? null,
});
}
async updateTask(tenantId: string, taskId: string, input: Partial<TaskInput>) {
const knex = await this.getKnex(tenantId);
return Task.query(knex).patchAndFetchById(taskId, {
title: input.title,
description: input.description,
status: input.status,
priority: input.priority,
dueAt: input.dueAt ? new Date(input.dueAt) : undefined,
assignedToId: input.assignedToId ?? undefined,
relatedObjectType: input.relatedObjectType ?? undefined,
relatedObjectId: input.relatedObjectId ?? undefined,
});
}
async updateTaskByRelated(
tenantId: string,
relatedObjectType: string,
relatedObjectId: string,
input: Partial<TaskInput>,
) {
const knex = await this.getKnex(tenantId);
return Task.query(knex)
.patch({
status: input.status,
dueAt: input.dueAt ? new Date(input.dueAt) : undefined,
})
.where({
relatedObjectType,
relatedObjectId,
});
}
}

View File

@@ -110,8 +110,9 @@ export class TenantDatabaseService {
* @deprecated Use getTenantKnexByDomain or getTenantKnexById instead * @deprecated Use getTenantKnexByDomain or getTenantKnexById instead
*/ */
async getTenantKnex(tenantIdOrSlug: string): Promise<Knex> { async getTenantKnex(tenantIdOrSlug: string): Promise<Knex> {
// Assume it's a domain if it contains a dot // Resolve tenant ID first, then get connection by ID
return this.getTenantKnexByDomain(tenantIdOrSlug); const tenantId = await this.resolveTenantId(tenantIdOrSlug);
return this.getTenantKnexById(tenantId);
} }
/** /**

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Process Builder</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -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"
}
}

View File

@@ -0,0 +1,153 @@
import { useCallback, useEffect } from 'react'
import {
Background,
Controls,
MiniMap,
ReactFlow,
useEdgesState,
useNodesState,
addEdge,
Connection,
Edge,
Node,
Panel,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import './styles.css'
const nodeTypes = {
Start: { style: { background: '#22c55e', color: 'white', padding: 10, borderRadius: 5 } },
LLMDecisionNode: { style: { background: '#3b82f6', color: 'white', padding: 10, borderRadius: 5 } },
ToolNode: { style: { background: '#f59e0b', color: 'white', padding: 10, borderRadius: 5 } },
HumanInputNode: { style: { background: '#8b5cf6', color: 'white', padding: 10, borderRadius: 5 } },
End: { style: { background: '#ef4444', color: 'white', padding: 10, borderRadius: 5 } },
}
const initialNodes: Node[] = [
{
id: 'start-1',
type: 'default',
data: { label: '🟢 Start', type: 'Start' },
position: { x: 250, y: 50 },
style: nodeTypes.Start.style,
},
{
id: 'end-1',
type: 'default',
data: { label: '🔴 End', type: 'End' },
position: { x: 250, y: 400 },
style: nodeTypes.End.style,
},
]
const initialEdges: Edge[] = []
export const App = () => {
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
const onConnect = useCallback(
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
[setEdges]
)
// Send graph updates to parent window
const notifyParent = useCallback(() => {
const graphData = {
id: 'process-graph',
name: 'Process',
nodes: nodes.map((node) => ({
id: node.id,
type: node.data.type || 'Start',
position: node.position,
data: node.data,
})),
edges: edges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
condition: edge.data?.condition,
})),
}
window.parent.postMessage(
{
type: 'GRAPH_UPDATED',
payload: graphData,
},
'*'
)
}, [nodes, edges])
// Listen for graph load from parent
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data.type === 'LOAD_GRAPH') {
const graph = event.data.payload
if (graph && graph.nodes && graph.edges) {
setNodes(
graph.nodes.map((node: any) => ({
id: node.id,
type: 'default',
data: { label: node.data.label || node.type, ...node.data },
position: node.position,
style: nodeTypes[node.type as keyof typeof nodeTypes]?.style || {},
}))
)
setEdges(
graph.edges.map((edge: any) => ({
id: edge.id,
source: edge.source,
target: edge.target,
data: edge.condition ? { condition: edge.condition } : undefined,
}))
)
}
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [setNodes, setEdges])
// Notify parent on changes
useEffect(() => {
notifyParent()
}, [nodes, edges, notifyParent])
const addNode = (type: string) => {
const newNode: Node = {
id: `${type.toLowerCase()}-${Date.now()}`,
type: 'default',
data: { label: `${type}`, type },
position: { x: Math.random() * 400 + 50, y: Math.random() * 300 + 100 },
style: nodeTypes[type as keyof typeof nodeTypes]?.style || {},
}
setNodes((nds) => nds.concat(newNode))
}
return (
<div className="editor-shell">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
fitView
>
<Panel position="top-left" className="node-palette">
<h3>Node Palette</h3>
<button onClick={() => addNode('LLMDecisionNode')}>🔵 LLM Decision</button>
<button onClick={() => addNode('ToolNode')}>🟠 Tool</button>
<button onClick={() => addNode('HumanInputNode')}>🟣 Human Input</button>
<button onClick={() => addNode('End')}>🔴 End</button>
</Panel>
<MiniMap />
<Controls />
<Background />
</ReactFlow>
</div>
)
}

View File

@@ -0,0 +1,7 @@
import { createRoot } from 'react-dom/client'
import { App } from './App'
const root = document.getElementById('root')
if (root) {
createRoot(root).render(<App />)
}

View File

@@ -0,0 +1,71 @@
body {
margin: 0;
font-family: 'Inter', sans-serif;
color: #0f172a;
}
#root {
width: 100%;
height: 100vh;
}
.editor-shell {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
}
.node-palette {
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 150px;
}
.node-palette h3 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
.node-palette button {
display: block;
width: 100%;
padding: 8px 12px;
margin-bottom: 6px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
text-align: left;
transition: all 0.2s;
}
.node-palette button:hover {
background: #f1f5f9;
border-color: #cbd5e1;
}
.node-palette button:active {
background: #e2e8f0;
}
.react-flow__node {
font-size: 12px;
font-weight: 500;
}
.react-flow__edge-path {
stroke: #64748b;
stroke-width: 2;
}
.react-flow__edge.selected .react-flow__edge-path {
stroke: #3b82f6;
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5174,
},
})

View File

@@ -7,15 +7,36 @@ import {
InputGroupText, InputGroupText,
} from '@/components/ui/input-group' } from '@/components/ui/input-group'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { ArrowUp } from 'lucide-vue-next' import { ArrowUp, Loader2 } from 'lucide-vue-next'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
interface ChatMessage {
role: 'user' | 'assistant' | 'system';
text: string;
isStreaming?: boolean;
}
interface StreamEvent {
type: string;
data?: any;
processId?: string;
nodeId?: string;
toolName?: string;
}
const chatInput = ref('') const chatInput = ref('')
const messages = ref<{ role: 'user' | 'assistant'; text: string }[]>([]) const messages = ref<ChatMessage[]>([])
const sending = ref(false) const sending = ref(false)
const route = useRoute() const route = useRoute()
const { api } = useApi() const { api } = useApi()
const sessionId = ref<string | null>(null)
const eventSource = ref<EventSource | null>(null)
const getTenantId = () => {
if (!import.meta.client) return 'tenant1'
return localStorage.getItem('tenantId') || 'tenant1'
}
const buildContext = () => { const buildContext = () => {
const recordId = route.params.recordId ? String(route.params.recordId) : undefined const recordId = route.params.recordId ? String(route.params.recordId) : undefined
@@ -33,6 +54,97 @@ const buildContext = () => {
} }
} }
const connectToStream = (sessionIdValue: string) => {
if (eventSource.value) {
eventSource.value.close()
}
const baseUrl = window.location.hostname === 'localhost'
? 'http://localhost:3000'
: `https://${window.location.hostname}`
eventSource.value = new EventSource(
`${baseUrl}/tenants/${getTenantId()}/ai-chat/stream?sessionId=${sessionIdValue}`
)
eventSource.value.onmessage = (event) => {
try {
const payload: StreamEvent = JSON.parse(event.data)
handleStreamEvent(payload)
} catch (error) {
console.error('Failed to parse stream event:', error)
}
}
eventSource.value.onerror = () => {
eventSource.value?.close()
eventSource.value = null
}
}
const handleStreamEvent = (event: StreamEvent) => {
switch (event.type) {
case 'agent_started':
// Agent is thinking
break
case 'processes_listed':
// Processes discovered
break
case 'process_selected':
messages.value.push({
role: 'system',
text: `🔄 Selected process: ${event.data?.processName || 'Process'}`,
})
break
case 'agent_message':
messages.value.push({
role: 'assistant',
text: event.data?.message || '',
})
break
case 'node_started':
const lastMsg = messages.value[messages.value.length - 1]
if (lastMsg?.isStreaming) {
lastMsg.text += `\n⚙ Executing step...`
}
break
case 'tool_called':
const lastToolMsg = messages.value[messages.value.length - 1]
if (lastToolMsg?.isStreaming) {
lastToolMsg.text += `\n🔧 Using tool: ${event.toolName}`
}
break
case 'need_input':
messages.value.push({
role: 'assistant',
text: event.data?.prompt || 'I need some additional information from you.',
})
sending.value = false
break
case 'final':
if (event.data?.output) {
messages.value.push({
role: 'assistant',
text: event.data.message || '✅ Process completed successfully!',
})
} else if (event.data?.reply) {
messages.value.push({
role: 'assistant',
text: event.data.reply,
})
}
sending.value = false
break
case 'error':
messages.value.push({
role: 'assistant',
text: `❌ Error: ${event.data?.error || 'An error occurred'}`,
})
sending.value = false
break
}
}
const handleSend = async () => { const handleSend = async () => {
if (!chatInput.value.trim()) return if (!chatInput.value.trim()) return
@@ -41,31 +153,53 @@ const handleSend = async () => {
chatInput.value = '' chatInput.value = ''
sending.value = true sending.value = true
// Add a streaming message placeholder
messages.value.push({
role: 'assistant',
text: '🤔 Thinking...',
isStreaming: true
})
try { try {
const history = messages.value.slice(0, -1).slice(-6) const history = messages.value
const response = await api.post('/ai/chat', { .filter(m => m.role !== 'system' && !m.isStreaming)
.slice(0, -1)
.slice(-6)
.map(m => ({ role: m.role, text: m.text }))
const response = await api.post(`/tenants/${getTenantId()}/ai-chat/messages`, {
message, message,
history, history,
context: buildContext(), context: buildContext(),
sessionId: sessionId.value || undefined,
}) })
if (response.sessionId && !sessionId.value) {
sessionId.value = response.sessionId
connectToStream(response.sessionId)
}
// Remove streaming placeholder and add response
messages.value = messages.value.filter(m => !m.isStreaming)
if (response.reply) {
messages.value.push({ messages.value.push({
role: 'assistant', role: 'assistant',
text: response.reply || 'Let me know what else you need.', text: response.reply,
}) })
}
if (response.action === 'create_record') { // If process is running, stream will handle updates
window.dispatchEvent( if (response.runId) {
new CustomEvent('ai-record-created', { messages.value.push({
detail: { role: 'assistant',
objectApiName: buildContext().objectApiName, text: '⏳ Processing workflow...',
record: response.record, isStreaming: true,
}, })
}),
)
} }
} catch (error: any) { } catch (error: any) {
console.error('Failed to send AI chat message:', error) console.error('Failed to send AI chat message:', error)
messages.value = messages.value.filter(m => !m.isStreaming)
messages.value.push({ messages.value.push({
role: 'assistant', role: 'assistant',
text: error.message || 'Sorry, I ran into an error. Please try again.', text: error.message || 'Sorry, I ran into an error. Please try again.',
@@ -74,11 +208,17 @@ const handleSend = async () => {
sending.value = false sending.value = false
} }
} }
onUnmounted(() => {
if (eventSource.value) {
eventSource.value.close()
}
})
</script> </script>
<template> <template>
<div class="ai-chat-area w-full border-t border-border p-4 bg-neutral-50"> <div class="ai-chat-area w-full border-t border-border p-4 bg-neutral-50">
<div class="ai-chat-messages mb-4 space-y-3"> <div class="ai-chat-messages mb-4 space-y-3 max-h-[400px] overflow-y-auto">
<div <div
v-for="(message, index) in messages" v-for="(message, index) in messages"
:key="`${message.role}-${index}`" :key="`${message.role}-${index}`"
@@ -86,14 +226,19 @@ const handleSend = async () => {
:class="message.role === 'user' ? 'justify-end' : 'justify-start'" :class="message.role === 'user' ? 'justify-end' : 'justify-start'"
> >
<div <div
class="max-w-[80%] rounded-lg px-3 py-2 text-sm" class="max-w-[80%] rounded-lg px-3 py-2 text-sm whitespace-pre-line"
:class="message.role === 'user' ? 'bg-primary text-primary-foreground' : 'bg-white border border-border text-foreground'" :class="{
'bg-primary text-primary-foreground': message.role === 'user',
'bg-white border border-border text-foreground': message.role === 'assistant',
'bg-blue-50 border border-blue-200 text-blue-900 text-xs': message.role === 'system',
}"
> >
<Loader2 v-if="message.isStreaming" class="inline-block size-3 animate-spin mr-1" />
{{ message.text }} {{ message.text }}
</div> </div>
</div> </div>
<p v-if="messages.length === 0" class="text-sm text-muted-foreground"> <p v-if="messages.length === 0" class="text-sm text-muted-foreground">
Ask the assistant to add records, filter lists, or summarize the page. Ask the assistant to execute business processes, add records, or answer questions.
</p> </p>
</div> </div>
<InputGroup> <InputGroup>

View File

@@ -17,7 +17,7 @@ import {
SidebarRail, SidebarRail,
} from '@/components/ui/sidebar' } from '@/components/ui/sidebar'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { LayoutGrid, Boxes, Settings, Home, ChevronRight, Database, Layers, LogOut, Users, Globe, Building, ClipboardCheck, ListChecks, ClipboardList, Phone } from 'lucide-vue-next' import { LayoutGrid, Boxes, Settings, Home, ChevronRight, Database, Layers, LogOut, Users, Globe, Building, Phone } from 'lucide-vue-next'
import { useSoftphone } from '~/composables/useSoftphone' import { useSoftphone } from '~/composables/useSoftphone'
const { logout } = useAuth() const { logout } = useAuth()
@@ -100,21 +100,6 @@ const staticMenuItems = [
url: '/', url: '/',
icon: Home, icon: Home,
}, },
{
title: 'Approvals',
url: '/approvals',
icon: ClipboardCheck,
},
{
title: 'Tasks',
url: '/tasks',
icon: ListChecks,
},
{
title: 'Activity Log',
url: '/activity-log',
icon: ClipboardList,
},
{ {
title: 'Setup', title: 'Setup',
icon: Settings, icon: Settings,
@@ -139,11 +124,6 @@ const staticMenuItems = [
url: '/setup/roles', url: '/setup/roles',
icon: Layers, icon: Layers,
}, },
{
title: 'Approvals',
url: '/setup/approvals',
icon: ClipboardCheck,
},
{ {
title: 'Integrations', title: 'Integrations',
url: '/settings/integrations', url: '/settings/integrations',

View File

@@ -0,0 +1,52 @@
<template>
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
<p class="mb-3 text-sm font-semibold text-amber-900">{{ prompt }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div v-for="field in fields" :key="field.name" class="space-y-1">
<label class="text-xs font-medium text-slate-600">
{{ field.label }}
</label>
<input
v-model="form[field.name]"
class="w-full rounded border border-slate-300 px-3 py-2 text-sm"
:type="field.type === 'number' ? 'number' : 'text'"
:required="field.required"
/>
</div>
<button
type="submit"
class="rounded bg-slate-900 px-4 py-2 text-sm font-semibold text-white"
>
Submit
</button>
</form>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
schema: Record<string, any>
prompt: string
}>()
const emit = defineEmits<{
(event: 'submit', payload: Record<string, unknown>): void
}>()
const form = reactive<Record<string, string>>({})
const fields = computed(() => {
const properties = props.schema?.properties || {}
const required = props.schema?.required || []
return Object.entries(properties).map(([name, config]: [string, any]) => ({
name,
label: config.title || name,
type: config.type || 'string',
required: required.includes(name),
}))
})
const submit = () => {
emit('submit', { ...form })
}
</script>

View File

@@ -0,0 +1,19 @@
<template>
<div class="rounded-lg border border-slate-200 bg-white shadow">
<div class="border-b border-slate-200 px-4 py-3 text-sm font-semibold text-slate-700">
Process Graph Editor
</div>
<iframe
class="h-[640px] w-full"
:src="editorUrl"
title="AI Process Builder"
/>
</div>
</template>
<script setup lang="ts">
const config = useRuntimeConfig()
const editorUrl = computed(() =>
config.public.aiProcessEditorUrl || 'http://localhost:5174'
)
</script>

View File

@@ -26,6 +26,8 @@ export default defineNuxtConfig({
runtimeConfig: { runtimeConfig: {
public: { public: {
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || 'http://localhost:3000',
aiProcessEditorUrl:
process.env.NUXT_PUBLIC_AI_PROCESS_EDITOR_URL || 'http://localhost:5174',
}, },
}, },

View File

@@ -1,70 +0,0 @@
<template>
<div class="min-h-screen bg-background">
<NuxtLayout name="default">
<main class="container mx-auto px-4 py-8">
<div v-if="loading" class="text-center py-12">Loading...</div>
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
<div v-else>
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Activity Log</h1>
<button
@click="fetchActivities"
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90"
>
Refresh
</button>
</div>
<div class="grid gap-4">
<div
v-for="activity in activities"
:key="activity.id"
class="p-6 border rounded-lg bg-card"
>
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold">{{ activity.action }}</h3>
<span class="text-xs text-muted-foreground">{{ formatDate(activity.createdAt) }}</span>
</div>
<p class="text-sm text-muted-foreground mb-2">
{{ activity.description || 'No description' }}
</p>
<div class="text-sm text-muted-foreground">
<div>Subject: {{ activity.subjectType }} {{ activity.subjectId }}</div>
<div>Causer: {{ activity.causerId || 'System' }}</div>
</div>
</div>
</div>
</div>
</main>
</NuxtLayout>
</div>
</template>
<script setup lang="ts">
const { api } = useApi()
const activities = ref<any[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const formatDate = (value?: string) => {
if (!value) return '—'
const date = new Date(value)
return date.toLocaleString()
}
const fetchActivities = async () => {
try {
loading.value = true
activities.value = await api.get('/activity-log')
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
onMounted(() => {
fetchActivities()
})
</script>

View File

@@ -0,0 +1,14 @@
<template>
<div class="space-y-6 p-6">
<div>
<h1 class="text-2xl font-semibold text-slate-900">
AI Process Builder
</h1>
<p class="text-sm text-slate-600">
Define tenant-scoped process graphs and publish versions for orchestration.
</p>
</div>
<ReactFlowIframe />
</div>
</template>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
import { Save, Play, ArrowLeft } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { useApi } from '@/composables/useApi'
import { useRouter, useRoute } from 'vue-router'
interface ProcessGraph {
id: string
name: string
description?: string
nodes: any[]
edges: any[]
}
const { api } = useApi()
const router = useRouter()
const route = useRoute()
const processId = computed(() => route.params.id === 'new' ? null : String(route.params.id))
const processName = ref('')
const processDescription = ref('')
const graphDefinition = ref<ProcessGraph | null>(null)
const saving = ref(false)
const loading = ref(true)
const getTenantId = () => {
if (!import.meta.client) return 'tenant1'
return localStorage.getItem('tenantId') || 'tenant1'
}
const loadProcess = async () => {
if (!processId.value) {
loading.value = false
return
}
loading.value = true
try {
const data = await api.get(`/tenants/${getTenantId()}/ai-processes/${processId.value}`)
processName.value = data.name
processDescription.value = data.description || ''
if (data.versions && data.versions.length > 0) {
const latestVersion = data.versions[data.versions.length - 1]
graphDefinition.value = latestVersion.graphJson
// Send graph to iframe
sendGraphToEditor(latestVersion.graphJson)
}
} catch (error) {
console.error('Failed to load process:', error)
alert('Failed to load process')
} finally {
loading.value = false
}
}
const sendGraphToEditor = (graph: any) => {
const iframe = document.getElementById('process-editor-iframe') as HTMLIFrameElement
if (iframe && iframe.contentWindow) {
iframe.contentWindow.postMessage({
type: 'LOAD_GRAPH',
payload: graph
}, '*')
}
}
const saveProcess = async () => {
if (!processName.value.trim()) {
alert('Process name is required')
return
}
if (!graphDefinition.value) {
alert('Please design the process graph first')
return
}
saving.value = true
try {
if (processId.value) {
// Update existing process (create new version)
await api.post(`/tenants/${getTenantId()}/ai-processes/${processId.value}/versions`, {
graph: graphDefinition.value
})
alert('Process version saved successfully!')
} else {
// Create new process
const result = await api.post(`/tenants/${getTenantId()}/ai-processes`, {
name: processName.value,
description: processDescription.value,
graph: graphDefinition.value
})
alert('Process created successfully!')
router.push(`/ai-processes/${result.id}/edit`)
}
} catch (error: any) {
console.error('Failed to save process:', error)
alert(`Save failed: ${error.message || 'Unknown error'}`)
} finally {
saving.value = false
}
}
const testRun = async () => {
if (!processId.value) {
alert('Please save the process first')
return
}
try {
const result = await api.post(`/tenants/${getTenantId()}/ai-processes/${processId.value}/runs`, {
input: { test: true }
})
console.log('Test run result:', result)
alert('Test run started! Check the console for details.')
} catch (error: any) {
alert(`Test failed: ${error.message}`)
}
}
const handleEditorMessage = (event: MessageEvent) => {
if (event.data.type === 'GRAPH_UPDATED') {
graphDefinition.value = event.data.payload
}
}
onMounted(() => {
loadProcess()
window.addEventListener('message', handleEditorMessage)
})
onUnmounted(() => {
window.removeEventListener('message', handleEditorMessage)
})
</script>
<template>
<div class="h-screen flex flex-col">
<!-- Header -->
<div class="border-b bg-background px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4 flex-1">
<Button variant="ghost" size="icon" @click="router.push('/ai-processes')">
<ArrowLeft class="h-4 w-4" />
</Button>
<div class="flex-1 max-w-md">
<Input
v-model="processName"
placeholder="Process Name"
class="font-semibold"
/>
</div>
</div>
<div class="flex items-center gap-2">
<Button variant="outline" @click="testRun" :disabled="!processId">
<Play class="h-4 w-4 mr-2" />
Test Run
</Button>
<Button @click="saveProcess" :disabled="saving">
<Save class="h-4 w-4 mr-2" />
{{ saving ? 'Saving...' : 'Save' }}
</Button>
</div>
</div>
<!-- Process Description -->
<div class="border-b bg-background px-6 py-2">
<Textarea
v-model="processDescription"
placeholder="Process description (optional)"
class="resize-none text-sm"
rows="2"
/>
</div>
<!-- Editor Iframe -->
<div class="flex-1 relative">
<div v-if="loading" class="absolute inset-0 flex items-center justify-center bg-background/80">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<iframe
id="process-editor-iframe"
src="/ai-processes-editor/index.html"
class="w-full h-full border-0"
title="Process Editor"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,133 @@
<script setup lang="ts">
import { Plus, Workflow, Play } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { useApi } from '@/composables/useApi'
import { useRouter } from 'vue-router'
interface Process {
id: string
name: string
description?: string
latestVersion: number
createdAt: string
versions?: any[]
}
const { api } = useApi()
const router = useRouter()
const processes = ref<Process[]>([])
const loading = ref(true)
const getTenantId = () => {
if (!import.meta.client) return 'tenant1'
return localStorage.getItem('tenantId') || 'tenant1'
}
const loadProcesses = async () => {
loading.value = true
try {
const data = await api.get(`/tenants/${getTenantId()}/ai-processes`)
processes.value = data || []
} catch (error) {
console.error('Failed to load processes:', error)
} finally {
loading.value = false
}
}
const createNewProcess = () => {
router.push('/ai-processes/new')
}
const editProcess = (processId: string) => {
router.push(`/ai-processes/${processId}/edit`)
}
const testProcess = async (processId: string) => {
try {
const result = await api.post(`/tenants/${getTenantId()}/ai-processes/${processId}/runs`, {
input: { test: true },
})
console.log('Test run result:', result)
alert('Process test started! Check console for details.')
} catch (error: any) {
alert(`Test failed: ${error.message}`)
}
}
onMounted(() => {
loadProcesses()
})
</script>
<template>
<div class="container mx-auto p-6 space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">AI Process Builder</h1>
<p class="text-muted-foreground mt-1">
Create and manage intelligent business process workflows
</p>
</div>
<Button @click="createNewProcess">
<Plus class="mr-2 h-4 w-4" />
New Process
</Button>
</div>
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<div v-else-if="processes.length === 0" class="text-center py-12">
<Workflow class="mx-auto h-12 w-12 text-muted-foreground/50" />
<h3 class="mt-4 text-lg font-semibold">No processes yet</h3>
<p class="text-muted-foreground mt-1">
Get started by creating your first AI-powered business process
</p>
<Button @click="createNewProcess" class="mt-4">
<Plus class="mr-2 h-4 w-4" />
Create Process
</Button>
</div>
<div v-else class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card
v-for="process in processes"
:key="process.id"
class="hover:shadow-lg transition-shadow cursor-pointer"
@click="editProcess(process.id)"
>
<CardHeader>
<div class="flex items-start justify-between">
<Workflow class="h-5 w-5 text-primary" />
<Badge variant="secondary">v{{ process.latestVersion }}</Badge>
</div>
<CardTitle class="mt-2">{{ process.name }}</CardTitle>
<CardDescription>{{ process.description || 'No description' }}</CardDescription>
</CardHeader>
<CardContent>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
@click.stop="editProcess(process.id)"
>
Edit
</Button>
<Button
size="sm"
variant="ghost"
@click.stop="testProcess(process.id)"
>
<Play class="h-3 w-3 mr-1" />
Test
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</template>

View File

@@ -1,232 +0,0 @@
<template>
<div class="min-h-screen bg-background">
<NuxtLayout name="default">
<main class="container mx-auto px-4 py-8">
<div v-if="loading" class="text-center py-12">Loading...</div>
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
<div v-else>
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Approval Requests</h1>
<button
@click="showCreateForm = true"
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
New Request
</button>
</div>
<div v-if="showCreateForm" class="mb-6 p-6 border rounded-lg bg-card">
<h2 class="text-xl font-semibold mb-4">Create Approval Request</h2>
<form @submit.prevent="createRequest" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2">Definition</label>
<select
v-model="newRequest.definitionId"
required
class="w-full px-3 py-2 border rounded-md bg-background"
>
<option value="" disabled>Select a definition</option>
<option v-for="definition in definitions" :key="definition.id" :value="definition.id">
{{ definition.name }}
</option>
</select>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-2">Target Object Type</label>
<input
v-model="newRequest.targetObjectType"
type="text"
required
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="Expense"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Target Object ID</label>
<input
v-model="newRequest.targetObjectId"
type="text"
required
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="record-id"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-2">Action</label>
<input
v-model="newRequest.action"
type="text"
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="submit"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Submitted By (User ID)</label>
<input
v-model="newRequest.submittedById"
type="text"
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="user-id"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium mb-2">Snapshot (JSON)</label>
<textarea
v-model="newRequest.snapshot"
class="w-full px-3 py-2 border rounded-md bg-background font-mono text-sm"
rows="3"
placeholder='{"amount": 1200, "currency": "USD"}'
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Field Changes (JSON)</label>
<textarea
v-model="newRequest.fieldChanges"
class="w-full px-3 py-2 border rounded-md bg-background font-mono text-sm"
rows="3"
placeholder='{"amount": {"from": 500, "to": 1200}}'
/>
</div>
<div class="flex gap-2">
<button
type="submit"
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
Create
</button>
<button
type="button"
@click="showCreateForm = false"
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90"
>
Cancel
</button>
</div>
</form>
</div>
<div class="grid gap-4">
<div
v-for="request in requests"
:key="request.id"
class="p-6 border rounded-lg bg-card"
>
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-lg font-semibold">{{ request.definition?.name || 'Approval Request' }}</h3>
<p class="text-sm text-muted-foreground">
{{ request.targetObjectType }} {{ request.targetObjectId }}
</p>
</div>
<span
class="text-xs px-2 py-1 rounded"
:class="statusClass(request.status)"
>
{{ request.status }}
</span>
</div>
<div class="text-sm text-muted-foreground">
<div>Steps: {{ request.steps?.length || 0 }}</div>
<div>Submitted: {{ formatDate(request.submittedAt) }}</div>
</div>
</div>
</div>
</div>
</main>
</NuxtLayout>
</div>
</template>
<script setup lang="ts">
const { api } = useApi()
const requests = ref<any[]>([])
const definitions = ref<any[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const showCreateForm = ref(false)
const newRequest = ref({
definitionId: '',
targetObjectType: '',
targetObjectId: '',
action: '',
submittedById: '',
snapshot: '',
fieldChanges: '',
})
const parseJson = (value: string) => {
if (!value) return undefined
try {
return JSON.parse(value)
} catch (err) {
return undefined
}
}
const statusClass = (status: string) => {
if (status === 'approved') return 'bg-primary/10 text-primary'
if (status === 'rejected') return 'bg-destructive/10 text-destructive'
return 'bg-muted text-muted-foreground'
}
const formatDate = (value?: string) => {
if (!value) return '—'
const date = new Date(value)
return date.toLocaleString()
}
const fetchRequests = async () => {
try {
loading.value = true
requests.value = await api.get('/approvals')
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
const fetchDefinitions = async () => {
try {
definitions.value = await api.get('/setup/approvals')
} catch (e) {
definitions.value = []
}
}
const createRequest = async () => {
try {
await api.post('/approvals', {
definitionId: newRequest.value.definitionId,
targetObjectType: newRequest.value.targetObjectType,
targetObjectId: newRequest.value.targetObjectId,
action: newRequest.value.action || undefined,
submittedById: newRequest.value.submittedById || undefined,
snapshot: parseJson(newRequest.value.snapshot),
fieldChanges: parseJson(newRequest.value.fieldChanges),
})
showCreateForm.value = false
newRequest.value = {
definitionId: '',
targetObjectType: '',
targetObjectId: '',
action: '',
submittedById: '',
snapshot: '',
fieldChanges: '',
}
await fetchRequests()
} catch (e: any) {
alert('Error creating approval request: ' + e.message)
}
}
onMounted(async () => {
await Promise.all([fetchRequests(), fetchDefinitions()])
})
</script>

View File

@@ -1,241 +0,0 @@
<template>
<div class="min-h-screen bg-background">
<NuxtLayout name="default">
<main class="container mx-auto px-4 py-8">
<div v-if="loading" class="text-center py-12">Loading...</div>
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
<div v-else>
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Approval Definitions</h1>
<button
@click="showCreateForm = true"
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
New Definition
</button>
</div>
<div v-if="showCreateForm" class="mb-6 p-6 border rounded-lg bg-card">
<h2 class="text-xl font-semibold mb-4">Create Approval Definition</h2>
<form @submit.prevent="createDefinition" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2">Name</label>
<input
v-model="newDefinition.name"
type="text"
required
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="Expense Approval"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Trigger Type</label>
<select
v-model="newDefinition.triggerType"
required
class="w-full px-3 py-2 border rounded-md bg-background"
>
<option value="action">Action</option>
<option value="state_transition">State Transition</option>
<option value="field_change">Field Change</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-2">Target Object Type</label>
<input
v-model="newDefinition.targetObjectType"
type="text"
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="Expense"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Description</label>
<textarea
v-model="newDefinition.description"
class="w-full px-3 py-2 border rounded-md bg-background"
rows="2"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Entry Criteria (JSON)</label>
<textarea
v-model="newDefinition.entryCriteria"
class="w-full px-3 py-2 border rounded-md bg-background font-mono text-sm"
rows="3"
placeholder='{"amount": {"gte": 500}}'
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Steps (JSON Array)</label>
<textarea
v-model="newDefinition.steps"
class="w-full px-3 py-2 border rounded-md bg-background font-mono text-sm"
rows="4"
placeholder='[{"key":"manager","name":"Manager Review","assignees":[]}]'
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Voting Policy (JSON)</label>
<textarea
v-model="newDefinition.votingPolicy"
class="w-full px-3 py-2 border rounded-md bg-background font-mono text-sm"
rows="3"
placeholder='{"type":"majority"}'
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-2">Rejection Rule</label>
<input
v-model="newDefinition.rejectionRule"
type="text"
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="any"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Material Change Policy</label>
<input
v-model="newDefinition.materialChangePolicy"
type="text"
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="invalidate"
/>
</div>
</div>
<div class="flex items-center gap-2">
<input id="isActive" v-model="newDefinition.isActive" type="checkbox" />
<label for="isActive" class="text-sm">Active</label>
</div>
<div class="flex gap-2">
<button
type="submit"
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
Create
</button>
<button
type="button"
@click="showCreateForm = false"
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90"
>
Cancel
</button>
</div>
</form>
</div>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div
v-for="definition in definitions"
:key="definition.id"
class="p-6 border rounded-lg bg-card"
>
<div class="flex items-start justify-between mb-2">
<h3 class="text-xl font-semibold">{{ definition.name }}</h3>
<span
class="text-xs px-2 py-1 rounded"
:class="definition.isActive ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'"
>
{{ definition.isActive ? 'Active' : 'Inactive' }}
</span>
</div>
<p class="text-sm text-muted-foreground mb-4">
{{ definition.description || 'No description' }}
</p>
<div class="text-sm space-y-1">
<div><span class="text-muted-foreground">Trigger:</span> {{ definition.triggerType }}</div>
<div>
<span class="text-muted-foreground">Target:</span>
{{ definition.targetObjectType || 'Any' }}
</div>
<div>
<span class="text-muted-foreground">Version:</span> {{ definition.version }}
</div>
</div>
</div>
</div>
</div>
</main>
</NuxtLayout>
</div>
</template>
<script setup lang="ts">
const { api } = useApi()
const definitions = ref<any[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const showCreateForm = ref(false)
const newDefinition = ref({
name: '',
triggerType: 'action',
targetObjectType: '',
description: '',
entryCriteria: '',
steps: '',
votingPolicy: '',
rejectionRule: 'any',
materialChangePolicy: 'invalidate',
isActive: true,
})
const parseJson = (value: string) => {
if (!value) return undefined
try {
return JSON.parse(value)
} catch (err) {
return undefined
}
}
const fetchDefinitions = async () => {
try {
loading.value = true
definitions.value = await api.get('/setup/approvals')
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
const createDefinition = async () => {
try {
await api.post('/setup/approvals', {
name: newDefinition.value.name,
triggerType: newDefinition.value.triggerType,
targetObjectType: newDefinition.value.targetObjectType || undefined,
description: newDefinition.value.description || undefined,
entryCriteria: parseJson(newDefinition.value.entryCriteria),
steps: parseJson(newDefinition.value.steps),
votingPolicy: parseJson(newDefinition.value.votingPolicy),
rejectionRule: newDefinition.value.rejectionRule || undefined,
materialChangePolicy: newDefinition.value.materialChangePolicy || undefined,
isActive: newDefinition.value.isActive,
})
showCreateForm.value = false
newDefinition.value = {
name: '',
triggerType: 'action',
targetObjectType: '',
description: '',
entryCriteria: '',
steps: '',
votingPolicy: '',
rejectionRule: 'any',
materialChangePolicy: 'invalidate',
isActive: true,
}
await fetchDefinitions()
} catch (e: any) {
alert('Error creating approval definition: ' + e.message)
}
}
onMounted(() => {
fetchDefinitions()
})
</script>

View File

@@ -1,186 +0,0 @@
<template>
<div class="min-h-screen bg-background">
<NuxtLayout name="default">
<main class="container mx-auto px-4 py-8">
<div v-if="loading" class="text-center py-12">Loading...</div>
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
<div v-else>
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Tasks</h1>
<button
@click="showCreateForm = true"
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
New Task
</button>
</div>
<div v-if="showCreateForm" class="mb-6 p-6 border rounded-lg bg-card">
<h2 class="text-xl font-semibold mb-4">Create Task</h2>
<form @submit.prevent="createTask" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2">Title</label>
<input
v-model="newTask.title"
type="text"
required
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="Follow up on approval"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Description</label>
<textarea
v-model="newTask.description"
class="w-full px-3 py-2 border rounded-md bg-background"
rows="2"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-2">Assigned To (User ID)</label>
<input
v-model="newTask.assignedToId"
type="text"
class="w-full px-3 py-2 border rounded-md bg-background"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Due At</label>
<input
v-model="newTask.dueAt"
type="datetime-local"
class="w-full px-3 py-2 border rounded-md bg-background"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-2">Related Object Type</label>
<input
v-model="newTask.relatedObjectType"
type="text"
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="ApprovalAssignment"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Related Object ID</label>
<input
v-model="newTask.relatedObjectId"
type="text"
class="w-full px-3 py-2 border rounded-md bg-background"
/>
</div>
</div>
<div class="flex gap-2">
<button
type="submit"
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
Create
</button>
<button
type="button"
@click="showCreateForm = false"
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90"
>
Cancel
</button>
</div>
</form>
</div>
<div class="grid gap-4">
<div
v-for="task in tasks"
:key="task.id"
class="p-6 border rounded-lg bg-card"
>
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold">{{ task.title }}</h3>
<span
class="text-xs px-2 py-1 rounded"
:class="task.status === 'completed' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'"
>
{{ task.status }}
</span>
</div>
<p class="text-sm text-muted-foreground mb-2">
{{ task.description || 'No description' }}
</p>
<div class="text-sm text-muted-foreground">
<div>Assigned: {{ task.assignedToId || 'Unassigned' }}</div>
<div>Due: {{ formatDate(task.dueAt) }}</div>
</div>
</div>
</div>
</div>
</main>
</NuxtLayout>
</div>
</template>
<script setup lang="ts">
const { api } = useApi()
const tasks = ref<any[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const showCreateForm = ref(false)
const newTask = ref({
title: '',
description: '',
assignedToId: '',
dueAt: '',
relatedObjectType: '',
relatedObjectId: '',
})
const formatDate = (value?: string) => {
if (!value) return '—'
const date = new Date(value)
return date.toLocaleString()
}
const fetchTasks = async () => {
try {
loading.value = true
tasks.value = await api.get('/tasks')
} catch (e: any) {
console.log('here');
error.value = e.message
} finally {
loading.value = false
}
}
const createTask = async () => {
try {
await api.post('/tasks', {
title: newTask.value.title,
description: newTask.value.description || undefined,
assignedToId: newTask.value.assignedToId || undefined,
dueAt: newTask.value.dueAt || undefined,
relatedObjectType: newTask.value.relatedObjectType || undefined,
relatedObjectId: newTask.value.relatedObjectId || undefined,
})
showCreateForm.value = false
newTask.value = {
title: '',
description: '',
assignedToId: '',
dueAt: '',
relatedObjectType: '',
relatedObjectId: '',
}
await fetchTasks()
} catch (e: any) {
alert('Error creating task: ' + e.message)
}
}
onMounted(() => {
fetchTasks()
})
</script>