Compare commits

..

19 Commits

Author SHA1 Message Date
Francisco Gaona
47fa72451d WIP - Added pagination for list view 2026-01-13 09:03:11 +01:00
Francisco Gaona
730fddd181 WIP - add meilisearch for easier record find for AI assistant 2026-01-13 07:44:47 +01:00
Francisco Gaona
a62f68fc10 WIP - refresh list after AI record creation 2026-01-13 00:00:40 +01:00
Francisco Gaona
d2b3fce4eb WIP - initial AI assistant chat working creating records 2026-01-12 23:55:57 +01:00
Francisco Gaona
ca11c8cbe7 WIP - add contct and contact details 2026-01-12 21:08:47 +01:00
Francisco Gaona
f8a3cffb64 WIP - dislpay name field for look up fields in related lists 2026-01-09 08:00:05 +01:00
Francisco Gaona
852c4e28d2 WIP - display related lists 2026-01-09 07:49:30 +01:00
phyroslam
2075fec183 Merge pull request #2 from phyroslam/codex/add-dynamic-related-lists-to-detail-views
Add dynamic tenant-level related lists and page layout selection
2026-01-08 15:16:17 -08:00
phyroslam
9be98e4a09 Add dynamic related lists with page layout support 2026-01-08 15:15:28 -08:00
Francisco Gaona
43cae4289b WIP - add owner to contact 2026-01-08 23:56:48 +01:00
Francisco Gaona
4de9203fd5 Merge branch 'drawer' into codex/add-contact-and-contact-details-objects 2026-01-08 21:34:48 +01:00
Francisco Gaona
8b9fa81594 WIP - UI fixes for bottom bar 2026-01-08 21:19:48 +01:00
phyroslam
a75b41fd0b Add contact and contact detail system objects 2026-01-08 07:41:09 -08:00
Francisco Gaona
7ae36411db WIP - move AI suggestions 2026-01-08 00:28:45 +01:00
Francisco Gaona
c9a3e00a94 WIP - UI cahnges to bottom bar 2026-01-08 00:21:12 +01:00
Francisco Gaona
8ad3fac1b0 WIP - UI drawer initial 2026-01-07 22:11:36 +01:00
Francisco Gaona
b34da6956c WIP - Fix create field dialog placement and look up field creation 2026-01-07 21:00:06 +01:00
Francisco Gaona
6c73eb1658 WIP - Basic adding and deleting field 2026-01-06 10:01:02 +01:00
Francisco Gaona
8e4690c9c9 WIP - initial iteration on manage fields 2026-01-06 09:45:29 +01:00
58 changed files with 231 additions and 5445 deletions

View File

@@ -1,324 +0,0 @@
# 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

@@ -1,115 +0,0 @@
-- 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

@@ -1,72 +0,0 @@
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

@@ -1,14 +0,0 @@
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

@@ -25,15 +25,11 @@
"@nestjs/serve-static": "^4.0.2",
"@nestjs/websockets": "^10.4.20",
"@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",
"bullmq": "^5.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"ioredis": "^5.3.2",
"json-logic-js": "^2.0.5",
"knex": "^3.1.0",
"langchain": "^1.2.7",
"mysql2": "^3.15.3",
@@ -100,41 +96,6 @@
}
}
},
"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": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
@@ -968,23 +929,6 @@
"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": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-9.0.1.tgz",
@@ -2973,12 +2917,6 @@
"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": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -3636,15 +3574,15 @@
}
},
"node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"version": "8.12.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
"require-from-string": "^2.0.2",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
@@ -3652,9 +3590,9 @@
}
},
"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==",
"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"
@@ -3681,22 +3619,6 @@
"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": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
@@ -5627,6 +5549,23 @@
"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": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
@@ -7654,12 +7593,6 @@
"dev": true,
"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": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
@@ -8722,23 +8655,38 @@
"knex": ">=1.0.1"
}
},
"node_modules/objection/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==",
"node_modules/objection/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"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": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
@@ -9399,7 +9347,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -10535,24 +10482,6 @@
}
}
},
"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": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
@@ -11128,7 +11057,6 @@
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"punycode": "^2.1.0"
@@ -11312,25 +11240,6 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",

View File

@@ -23,8 +23,7 @@
"migrate:rollback": "knex migrate:rollback --knexfile=knexfile.js",
"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: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"
"migrate:all-tenants": "ts-node -r tsconfig-paths/register scripts/migrate-all-tenants.ts"
},
"dependencies": {
"@casl/ability": "^6.7.5",
@@ -43,15 +42,11 @@
"@nestjs/serve-static": "^4.0.2",
"@nestjs/websockets": "^10.4.20",
"@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",
"bullmq": "^5.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"ioredis": "^5.3.2",
"json-logic-js": "^2.0.5",
"knex": "^3.1.0",
"langchain": "^1.2.7",
"mysql2": "^3.15.3",

View File

@@ -181,101 +181,6 @@ model ContactDetail {
@@map("contact_details")
}
// AI Process Builder + Chat Orchestrator
model AiProcess {
id String @id @default(uuid())
tenantId String @map("tenant_id")
name String
description String? @db.Text
latestVersion Int @default(1) @map("latest_version")
createdBy String @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
versions AiProcessVersion[]
runs AiProcessRun[]
@@index([tenantId])
@@map("ai_processes")
}
model AiProcessVersion {
id String @id @default(uuid())
tenantId String @map("tenant_id")
processId String @map("process_id")
version Int
graphJson Json @map("graph_json")
compiledJson Json @map("compiled_json")
createdBy String @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
process AiProcess @relation(fields: [processId], references: [id], onDelete: Cascade)
@@unique([processId, version])
@@index([tenantId])
@@map("ai_process_versions")
}
model AiProcessRun {
id String @id @default(uuid())
tenantId String @map("tenant_id")
processId String @map("process_id")
version Int
status String
inputJson Json @map("input_json")
outputJson Json? @map("output_json")
errorJson Json? @map("error_json")
stateJson Json? @map("state_json")
currentNodeId String? @map("current_node_id")
startedAt DateTime @default(now()) @map("started_at")
endedAt DateTime? @map("ended_at")
process AiProcess @relation(fields: [processId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@index([processId])
@@map("ai_process_runs")
}
model AiChatSession {
id String @id @default(uuid())
tenantId String @map("tenant_id")
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
messages AiChatMessage[]
@@index([tenantId])
@@index([userId])
@@map("ai_chat_sessions")
}
model AiChatMessage {
id String @id @default(uuid())
sessionId String @map("session_id")
role String
content String @db.Text
createdAt DateTime @default(now()) @map("created_at")
session AiChatSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@@index([sessionId])
@@map("ai_chat_messages")
}
model AiAuditEvent {
id String @id @default(uuid())
tenantId String @map("tenant_id")
runId String @map("run_id")
eventType String @map("event_type")
payloadJson Json @map("payload_json")
createdAt DateTime @default(now()) @map("created_at")
@@index([tenantId])
@@index([runId])
@@map("ai_audit_events")
}
// Application Builder
model App {
id String @id @default(uuid())

View File

@@ -1,332 +0,0 @@
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

@@ -4,7 +4,6 @@ import { CurrentUser } from '../auth/current-user.decorator';
import { TenantId } from '../tenant/tenant.decorator';
import { AiAssistantService } from './ai-assistant.service';
import { AiChatRequestDto } from './dto/ai-chat.dto';
import { AiSearchRequestDto } from './dto/ai-search.dto';
@Controller('ai')
@UseGuards(JwtAuthGuard)
@@ -25,17 +24,4 @@ export class AiAssistantController {
payload.context,
);
}
@Post('search')
async search(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Body() payload: AiSearchRequestDto,
) {
return this.aiAssistantService.searchRecords(
tenantId,
user.userId,
payload,
);
}
}

View File

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

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { JsonOutputParser } from '@langchain/core/output_parsers';
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
import { ChatOpenAI } from '@langchain/openai';
@@ -11,30 +11,6 @@ import { OpenAIConfig } from '../voice/interfaces/integration-config.interface';
import { AiAssistantReply, AiAssistantState } from './ai-assistant.types';
import { MeilisearchService } from '../search/meilisearch.service';
type AiSearchFilter = {
field: string;
operator: string;
value?: any;
values?: any[];
from?: string;
to?: string;
};
type AiSearchPlan = {
strategy: 'keyword' | 'query';
explanation: string;
keyword?: string | null;
filters?: AiSearchFilter[];
sort?: { field: string; direction: 'asc' | 'desc' } | null;
};
type AiSearchPayload = {
objectApiName: string;
query: string;
page?: number;
pageSize?: number;
};
@Injectable()
export class AiAssistantService {
private readonly logger = new Logger(AiAssistantService.name);
@@ -94,108 +70,6 @@ export class AiAssistantService {
};
}
async searchRecords(
tenantId: string,
userId: string,
payload: AiSearchPayload,
) {
const queryText = payload?.query?.trim();
if (!payload?.objectApiName || !queryText) {
throw new BadRequestException('objectApiName and query are required');
}
// Normalize tenant ID so Meilisearch index names align with indexed records
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const objectDefinition = await this.objectService.getObjectDefinition(
resolvedTenantId,
payload.objectApiName,
);
if (!objectDefinition) {
throw new BadRequestException(`Object ${payload.objectApiName} not found`);
}
const page = Number.isFinite(Number(payload.page)) ? Number(payload.page) : 1;
const pageSize = Number.isFinite(Number(payload.pageSize)) ? Number(payload.pageSize) : 20;
const plan = await this.buildSearchPlan(
resolvedTenantId,
queryText,
objectDefinition,
);
console.log('AI search plan:', plan);
if (plan.strategy === 'keyword') {
console.log('AI search plan (keyword):', plan);
const keyword = plan.keyword?.trim() || queryText;
if (this.meilisearchService.isEnabled()) {
const offset = (page - 1) * pageSize;
const meiliResults = await this.meilisearchService.searchRecords(
resolvedTenantId,
payload.objectApiName,
keyword,
{ limit: pageSize, offset },
);
console.log('Meilisearch results:', meiliResults);
const ids = meiliResults.hits
.map((hit: any) => hit?.id)
.filter(Boolean);
const records = ids.length
? await this.objectService.searchRecordsByIds(
resolvedTenantId,
payload.objectApiName,
userId,
ids,
{ page, pageSize },
)
: { data: [], totalCount: 0, page, pageSize };
return {
...records,
totalCount: meiliResults.total ?? records.totalCount ?? 0,
strategy: plan.strategy,
explanation: plan.explanation,
};
}
const fallback = await this.objectService.searchRecordsByKeyword(
resolvedTenantId,
payload.objectApiName,
userId,
keyword,
{ page, pageSize },
);
return {
...fallback,
strategy: plan.strategy,
explanation: plan.explanation,
};
}
console.log('AI search plan (query):', plan);
const filtered = await this.objectService.searchRecordsWithFilters(
resolvedTenantId,
payload.objectApiName,
userId,
plan.filters || [],
{ page, pageSize },
plan.sort || undefined,
);
return {
...filtered,
strategy: plan.strategy,
explanation: plan.explanation,
};
}
private async runAssistantGraph(
tenantId: string,
userId: string,
@@ -649,110 +523,6 @@ export class AiAssistantService {
return extracted;
}
private async buildSearchPlan(
tenantId: string,
message: string,
objectDefinition: any,
): Promise<AiSearchPlan> {
const openAiConfig = await this.getOpenAiConfig(tenantId);
if (!openAiConfig) {
return this.buildSearchPlanFallback(message);
}
try {
return await this.buildSearchPlanWithAi(openAiConfig, message, objectDefinition);
} catch (error) {
this.logger.warn(`AI search planning failed: ${error.message}`);
return this.buildSearchPlanFallback(message);
}
}
private buildSearchPlanFallback(message: string): AiSearchPlan {
const trimmed = message.trim();
return {
strategy: 'keyword',
keyword: trimmed,
explanation: `Searched records that matches the word: "${trimmed}"`,
};
}
private async buildSearchPlanWithAi(
openAiConfig: OpenAIConfig,
message: string,
objectDefinition: any,
): Promise<AiSearchPlan> {
const model = new ChatOpenAI({
apiKey: openAiConfig.apiKey,
model: this.normalizeChatModel(openAiConfig.model),
temperature: 0.2,
});
const parser = new JsonOutputParser<AiSearchPlan>();
const fields = (objectDefinition.fields || []).map((field: any) => ({
apiName: field.apiName,
label: field.label,
type: field.type,
}));
const formatInstructions = parser.getFormatInstructions();
const today = new Date().toISOString();
const response = await model.invoke([
new SystemMessage(
`You are a CRM search assistant. Decide whether the user input is a keyword search or a structured query.` +
`\nReturn a JSON object with keys: strategy, explanation, keyword, filters, sort.` +
`\n- strategy must be "keyword" or "query".` +
`\n- explanation must be a short sentence explaining the approach.` +
`\n- keyword should be the search term when strategy is "keyword", otherwise null.` +
`\n- filters is an array of {field, operator, value, values, from, to}.` +
`\n- operators must be one of eq, neq, gt, gte, lt, lte, contains, startsWith, endsWith, in, notIn, isNull, notNull, between.` +
`\n- Use between with from/to when the user gives date ranges like "yesterday" or "last week".` +
`\n- sort should be {field, direction} when sorting is requested.` +
`\n- Only use field apiName values exactly as provided.` +
`\n${formatInstructions}`,
),
new HumanMessage(
`Object: ${objectDefinition.label || objectDefinition.apiName}.\n` +
`Fields: ${JSON.stringify(fields)}.\n` +
`Today is ${today}.\n` +
`User query: ${message}`,
),
]);
const content = typeof response.content === 'string' ? response.content : '{}';
const parsed = await parser.parse(content);
return this.normalizeSearchPlan(parsed, message);
}
private normalizeSearchPlan(plan: AiSearchPlan, message: string): AiSearchPlan {
if (!plan || typeof plan !== 'object') {
return this.buildSearchPlanFallback(message);
}
const strategy = plan.strategy === 'query' ? 'query' : 'keyword';
const explanation = plan.explanation?.trim()
? plan.explanation.trim()
: strategy === 'keyword'
? `Searched records that matches the word: "${message.trim()}"`
: `Applied filters based on: "${message.trim()}"`;
if (strategy === 'keyword') {
return {
strategy,
keyword: plan.keyword?.trim() || message.trim(),
explanation,
};
}
return {
strategy,
explanation,
keyword: null,
filters: Array.isArray(plan.filters) ? plan.filters : [],
sort: plan.sort || null,
};
}
private sanitizeUserOwnerFields(
fields: Record<string, any>,
fieldDefinitions: any[],
@@ -986,7 +756,7 @@ export class AiAssistantService {
].includes(apiName);
}
async getOpenAiConfig(tenantId: string): Promise<OpenAIConfig | null> {
private async getOpenAiConfig(tenantId: string): Promise<OpenAIConfig | null> {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const centralPrisma = getCentralPrisma();
const tenant = await centralPrisma.tenant.findUnique({
@@ -1129,18 +899,12 @@ export class AiAssistantService {
const providedValue = typeof value === 'string' ? value.trim() : value;
if (providedValue && typeof providedValue === 'string') {
console.log('providedValue:', providedValue);
const meiliMatch = await this.meilisearchService.searchRecord(
resolvedTenantId,
targetDefinition?.apiName || targetApiName,
providedValue,
displayField,
);
console.log('MeiliSearch lookup for', meiliMatch);
if (meiliMatch?.id) {
resolvedFields[field.apiName] = meiliMatch.id;
continue;

View File

@@ -1,22 +0,0 @@
import { Type } from 'class-transformer';
import { IsNotEmpty, IsOptional, IsString, IsNumber } from 'class-validator';
export class AiSearchRequestDto {
@IsString()
@IsNotEmpty()
objectApiName: string;
@IsString()
@IsNotEmpty()
query: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
page?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
pageSize?: number;
}

View File

@@ -1,25 +0,0 @@
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

@@ -1,40 +0,0 @@
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

@@ -1,191 +0,0 @@
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

@@ -1,144 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,212 +0,0 @@
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

@@ -1,222 +0,0 @@
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

@@ -1,79 +0,0 @@
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

@@ -1,319 +0,0 @@
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

@@ -1,33 +0,0 @@
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

@@ -1,125 +0,0 @@
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

@@ -1,202 +0,0 @@
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

@@ -1,173 +0,0 @@
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

@@ -1,28 +0,0 @@
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

@@ -1,24 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,226 +0,0 @@
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

@@ -1,89 +0,0 @@
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,7 +9,6 @@ import { AppBuilderModule } from './app-builder/app-builder.module';
import { PageLayoutModule } from './page-layout/page-layout.module';
import { VoiceModule } from './voice/voice.module';
import { AiAssistantModule } from './ai-assistant/ai-assistant.module';
import { AiProcessesModule } from './ai-processes/ai-processes.module';
@Module({
imports: [
@@ -25,7 +24,6 @@ import { AiProcessesModule } from './ai-processes/ai-processes.module';
PageLayoutModule,
VoiceModule,
AiAssistantModule,
AiProcessesModule,
],
})
export class AppModule {}

View File

@@ -1,63 +0,0 @@
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

@@ -1,164 +0,0 @@
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,38 +1,7 @@
import { Model, ModelOptions, QueryContext } from 'objection';
import { Model, ModelOptions, QueryContext, snakeCaseMappers } from 'objection';
export class BaseModel extends Model {
/**
* Use a minimal column mapper: keep property names as-is, but handle
* timestamp fields that are stored as created_at/updated_at in the DB.
*/
static columnNameMappers = {
parse(dbRow: Record<string, any>) {
const mapped: Record<string, any> = {};
for (const [key, value] of Object.entries(dbRow || {})) {
if (key === 'created_at') {
mapped.createdAt = value;
} else if (key === 'updated_at') {
mapped.updatedAt = value;
} else {
mapped[key] = value;
}
}
return mapped;
},
format(model: Record<string, any>) {
const mapped: Record<string, any> = {};
for (const [key, value] of Object.entries(model || {})) {
if (key === 'createdAt') {
mapped.created_at = value;
} else if (key === 'updatedAt') {
mapped.updated_at = value;
} else {
mapped[key] = value;
}
}
return mapped;
},
};
static columnNameMappers = snakeCaseMappers();
id: string;
createdAt: Date;

View File

@@ -179,8 +179,7 @@ export class DynamicModelFactory {
* Convert a field definition to JSON schema property
*/
private static fieldToJsonSchema(field: FieldDefinition): Record<string, any> {
const baseSchema = () => {
switch (field.type.toUpperCase()) {
switch (field.type.toUpperCase()) {
case 'TEXT':
case 'STRING':
case 'EMAIL':
@@ -188,57 +187,45 @@ export class DynamicModelFactory {
case 'PHONE':
case 'PICKLIST':
case 'MULTI_PICKLIST':
return {
type: 'string',
...(field.isUnique && { uniqueItems: true }),
};
return {
type: 'string',
...(field.isUnique && { uniqueItems: true }),
};
case 'LONG_TEXT':
return { type: 'string' };
return { type: 'string' };
case 'NUMBER':
case 'DECIMAL':
case 'CURRENCY':
case 'PERCENT':
return {
type: 'number',
...(field.isUnique && { uniqueItems: true }),
};
return {
type: 'number',
...(field.isUnique && { uniqueItems: true }),
};
case 'INTEGER':
return {
type: 'integer',
...(field.isUnique && { uniqueItems: true }),
};
return {
type: 'integer',
...(field.isUnique && { uniqueItems: true }),
};
case 'BOOLEAN':
return { type: 'boolean', default: false };
return { type: 'boolean', default: false };
case 'DATE':
return { type: 'string', format: 'date' };
return { type: 'string', format: 'date' };
case 'DATE_TIME':
return { type: 'string', format: 'date-time' };
return { type: 'string', format: 'date-time' };
case 'LOOKUP':
case 'BELONGS_TO':
return { type: 'string' };
return { type: 'string' };
default:
return { type: 'string' };
}
};
const schema = baseSchema();
// Allow null for non-required fields so optional strings/numbers don't fail validation
if (!field.isRequired) {
return {
anyOf: [schema, { type: 'null' }],
};
return { type: 'string' };
}
return schema;
}
/**

View File

@@ -10,25 +10,6 @@ import { User } from '../models/user.model';
import { ObjectMetadata } from './models/dynamic-model.factory';
import { MeilisearchService } from '../search/meilisearch.service';
type SearchFilter = {
field: string;
operator: string;
value?: any;
values?: any[];
from?: string;
to?: string;
};
type SearchSort = {
field: string;
direction: 'asc' | 'desc';
};
type SearchPagination = {
page?: number;
pageSize?: number;
};
@Injectable()
export class ObjectService {
private readonly logger = new Logger(ObjectService.name);
@@ -81,10 +62,8 @@ export class ObjectService {
.where({ objectDefinitionId: obj.id })
.orderBy('label', 'asc');
// Normalize all fields to ensure system fields are properly marked and add any missing system fields
const normalizedFields = this.addMissingSystemFields(
fields.map((field: any) => this.normalizeField(field)),
);
// Normalize all fields to ensure system fields are properly marked
const normalizedFields = fields.map((field: any) => this.normalizeField(field));
// Get app information if object belongs to an app
let app = null;
@@ -530,14 +509,17 @@ export class ObjectService {
}
}
private async buildAuthorizedQuery(
// Runtime endpoints - CRUD operations
async getRecords(
tenantId: string,
objectApiName: string,
userId: string,
filters?: any,
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Get user with roles and permissions
const user = await User.query(knex)
.findById(userId)
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
@@ -546,6 +528,7 @@ export class ObjectService {
throw new NotFoundException('User not found');
}
// Get object definition with authorization settings
const objectDefModel = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName })
.withGraphFetched('fields');
@@ -554,17 +537,20 @@ export class ObjectService {
throw new NotFoundException(`Object ${objectApiName} not found`);
}
// Normalize and enrich fields to include system fields for downstream permissions/search
const normalizedFields = this.addMissingSystemFields(
(objectDefModel.fields || []).map((field: any) => this.normalizeField(field)),
const tableName = this.getTableName(
objectDefModel.apiName,
objectDefModel.label,
objectDefModel.pluralLabel,
);
objectDefModel.fields = normalizedFields;
// Ensure model is registered
await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel);
// Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
let query = boundModel.query();
// Apply authorization scope (modifies query in place)
await this.authService.applyScopeToQuery(
query,
objectDefModel,
@@ -573,13 +559,15 @@ export class ObjectService {
knex,
);
const lookupFields = objectDefModel.fields?.filter((field) =>
field.type === 'LOOKUP' && field.referenceObject,
// Build graph expression for lookup fields
const lookupFields = objectDefModel.fields?.filter(f =>
f.type === 'LOOKUP' && f.referenceObject
) || [];
if (lookupFields.length > 0) {
// Build relation expression - use singular lowercase for relation name
const relationExpression = lookupFields
.map((field) => field.apiName.replace(/Id$/, '').toLowerCase())
.map(f => f.apiName.replace(/Id$/, '').toLowerCase())
.filter(Boolean)
.join(', ');
@@ -588,175 +576,6 @@ export class ObjectService {
}
}
return {
query,
objectDefModel,
user,
knex,
};
}
private applySearchFilters(
query: any,
filters: SearchFilter[],
validFields: Set<string>,
) {
if (!Array.isArray(filters) || filters.length === 0) {
return query;
}
for (const filter of filters) {
const field = filter?.field;
if (!field || !validFields.has(field)) {
continue;
}
const operator = String(filter.operator || 'eq').toLowerCase();
const value = filter.value;
const values = Array.isArray(filter.values) ? filter.values : undefined;
switch (operator) {
case 'eq':
query.where(field, value);
break;
case 'neq':
query.whereNot(field, value);
break;
case 'gt':
query.where(field, '>', value);
break;
case 'gte':
query.where(field, '>=', value);
break;
case 'lt':
query.where(field, '<', value);
break;
case 'lte':
query.where(field, '<=', value);
break;
case 'contains':
if (value !== undefined && value !== null) {
query.whereRaw('LOWER(??) like ?', [field, `%${String(value).toLowerCase()}%`]);
}
break;
case 'startswith':
if (value !== undefined && value !== null) {
query.whereRaw('LOWER(??) like ?', [field, `${String(value).toLowerCase()}%`]);
}
break;
case 'endswith':
if (value !== undefined && value !== null) {
query.whereRaw('LOWER(??) like ?', [field, `%${String(value).toLowerCase()}`]);
}
break;
case 'in':
if (values?.length) {
query.whereIn(field, values);
} else if (Array.isArray(value)) {
query.whereIn(field, value);
}
break;
case 'notin':
if (values?.length) {
query.whereNotIn(field, values);
} else if (Array.isArray(value)) {
query.whereNotIn(field, value);
}
break;
case 'isnull':
query.whereNull(field);
break;
case 'notnull':
query.whereNotNull(field);
break;
case 'between': {
const from = filter.from ?? (Array.isArray(value) ? value[0] : undefined);
const to = filter.to ?? (Array.isArray(value) ? value[1] : undefined);
if (from !== undefined && to !== undefined) {
query.whereBetween(field, [from, to]);
} else if (from !== undefined) {
query.where(field, '>=', from);
} else if (to !== undefined) {
query.where(field, '<=', to);
}
break;
}
default:
break;
}
}
return query;
}
private applyKeywordFilter(
query: any,
keyword: string,
fields: string[],
) {
const trimmed = keyword?.trim();
if (!trimmed || fields.length === 0) {
return query;
}
query.where((builder: any) => {
const lowered = trimmed.toLowerCase();
for (const field of fields) {
builder.orWhereRaw('LOWER(??) like ?', [field, `%${lowered}%`]);
}
});
return query;
}
private async finalizeRecordQuery(
query: any,
objectDefModel: any,
user: any,
pagination?: SearchPagination,
) {
const parsedPage = Number.isFinite(Number(pagination?.page)) ? Number(pagination?.page) : 1;
const parsedPageSize = Number.isFinite(Number(pagination?.pageSize))
? Number(pagination?.pageSize)
: 0;
const safePage = parsedPage > 0 ? parsedPage : 1;
const safePageSize = parsedPageSize > 0 ? Math.min(parsedPageSize, 500) : 0;
const shouldPaginate = safePageSize > 0;
const totalCount = await query.clone().resultSize();
if (shouldPaginate) {
query = query.offset((safePage - 1) * safePageSize).limit(safePageSize);
}
const records = await query.select('*');
const filteredRecords = await Promise.all(
records.map((record: any) =>
this.authService.filterReadableFields(record, objectDefModel.fields, user),
),
);
return {
data: filteredRecords,
totalCount,
page: shouldPaginate ? safePage : 1,
pageSize: shouldPaginate ? safePageSize : filteredRecords.length,
};
}
// Runtime endpoints - CRUD operations
async getRecords(
tenantId: string,
objectApiName: string,
userId: string,
filters?: any,
) {
let { query, objectDefModel, user } = await this.buildAuthorizedQuery(
tenantId,
objectApiName,
userId,
);
// Extract pagination and sorting parameters from query string
const {
page,
@@ -784,98 +603,36 @@ export class ObjectService {
}
if (sortField) {
const direction = sortDirection === 'desc' ? 'desc' : 'asc';
query = query.orderBy(sortField, direction);
query = query.orderBy(sortField, sortDirection === 'desc' ? 'desc' : 'asc');
}
return this.finalizeRecordQuery(query, objectDefModel, user, { page, pageSize });
}
const parsedPage = Number.isFinite(Number(page)) ? Number(page) : 1;
const parsedPageSize = Number.isFinite(Number(pageSize)) ? Number(pageSize) : 0;
const safePage = parsedPage > 0 ? parsedPage : 1;
const safePageSize = parsedPageSize > 0 ? Math.min(parsedPageSize, 500) : 0;
const shouldPaginate = safePageSize > 0;
async searchRecordsByIds(
tenantId: string,
objectApiName: string,
userId: string,
recordIds: string[],
pagination?: SearchPagination,
) {
if (!Array.isArray(recordIds) || recordIds.length === 0) {
return {
data: [],
totalCount: 0,
page: pagination?.page ?? 1,
pageSize: pagination?.pageSize ?? 0,
};
const totalCount = await query.clone().resultSize();
if (shouldPaginate) {
query = query.offset((safePage - 1) * safePageSize).limit(safePageSize);
}
const { query, objectDefModel, user } = await this.buildAuthorizedQuery(
tenantId,
objectApiName,
userId,
const records = await query.select('*');
// Filter fields based on field-level permissions
const filteredRecords = await Promise.all(
records.map(record =>
this.authService.filterReadableFields(record, objectDefModel.fields, user)
)
);
query.whereIn('id', recordIds);
const orderBindings: any[] = ['id'];
const cases = recordIds
.map((id, index) => {
orderBindings.push(id, index);
return 'when ? then ?';
})
.join(' ');
if (cases) {
query.orderByRaw(`case ?? ${cases} end`, orderBindings);
}
return this.finalizeRecordQuery(query, objectDefModel, user, pagination);
}
async searchRecordsWithFilters(
tenantId: string,
objectApiName: string,
userId: string,
filters: SearchFilter[],
pagination?: SearchPagination,
sort?: SearchSort,
) {
const { query, objectDefModel, user } = await this.buildAuthorizedQuery(
tenantId,
objectApiName,
userId,
);
const validFields = new Set([
...(objectDefModel.fields?.map((field: any) => field.apiName) || []),
...this.getSystemFieldNames(),
]);
this.applySearchFilters(query, filters, validFields);
if (sort?.field && validFields.has(sort.field)) {
query.orderBy(sort.field, sort.direction === 'desc' ? 'desc' : 'asc');
}
return this.finalizeRecordQuery(query, objectDefModel, user, pagination);
}
async searchRecordsByKeyword(
tenantId: string,
objectApiName: string,
userId: string,
keyword: string,
pagination?: SearchPagination,
) {
const { query, objectDefModel, user } = await this.buildAuthorizedQuery(
tenantId,
objectApiName,
userId,
);
const keywordFields = (objectDefModel.fields || [])
.filter((field: any) => this.isKeywordField(field.type))
.map((field: any) => field.apiName);
this.applyKeywordFilter(query, keyword, keywordFields);
return this.finalizeRecordQuery(query, objectDefModel, user, pagination);
return {
data: filteredRecords,
totalCount,
page: shouldPaginate ? safePage : 1,
pageSize: shouldPaginate ? safePageSize : filteredRecords.length,
};
}
async getRecord(
@@ -1179,8 +936,7 @@ export class ObjectService {
objectApiName,
editableData,
);
// Use patch to avoid validating or overwriting fields that aren't present in the edit view
await boundModel.query().patch(normalizedEditableData).where({ id: recordId });
await boundModel.query().where({ id: recordId }).update(normalizedEditableData);
const record = await boundModel.query().where({ id: recordId }).first();
await this.indexRecord(resolvedTenantId, objectApiName, objectDefModel.fields, record);
return record;
@@ -1285,22 +1041,9 @@ export class ObjectService {
throw new NotFoundException(`Records not found: ${missingIds.join(', ')}`);
}
const deletableIds: string[] = [];
const deniedIds: string[] = [];
// Check if user can delete each record
for (const record of records) {
const canDelete = await this.authService.canPerformAction(
'delete',
objectDefModel,
record,
user,
knex,
);
if (canDelete) {
deletableIds.push(record.id);
} else {
deniedIds.push(record.id);
}
await this.authService.assertCanPerformAction('delete', objectDefModel, record, user, knex);
}
// Ensure model is registered
@@ -1308,23 +1051,14 @@ export class ObjectService {
// Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
if (deletableIds.length > 0) {
await boundModel.query().whereIn('id', deletableIds).delete();
}
await boundModel.query().whereIn('id', recordIds).delete();
// Remove from search index
await Promise.all(
deletableIds.map((id) =>
this.removeIndexedRecord(resolvedTenantId, objectApiName, id),
),
recordIds.map((id) => this.removeIndexedRecord(resolvedTenantId, objectApiName, id)),
);
return {
success: true,
deleted: deletableIds.length,
deletedIds: deletableIds,
deniedIds,
};
return { success: true, deleted: recordIds.length };
}
private async indexRecord(
@@ -1339,32 +1073,12 @@ export class ObjectService {
.map((field: any) => field.apiName)
.filter((apiName) => apiName && !this.isSystemField(apiName));
console.log('Indexing record', {
tenantId,
objectApiName,
recordId: record.id,
fieldsToIndex,
});
await this.meilisearchService.upsertRecord(
tenantId,
objectApiName,
record,
fieldsToIndex,
);
console.log('Indexed record successfully');
const meiliResults = await this.meilisearchService.searchRecords(
tenantId,
objectApiName,
record.name,
{ limit: 10 },
);
console.log('Meilisearch results:', meiliResults);
}
private async removeIndexedRecord(
@@ -1377,48 +1091,15 @@ export class ObjectService {
}
private isSystemField(apiName: string): boolean {
return this.getSystemFieldNames().includes(apiName);
}
private getSystemFieldNames(): string[] {
return ['id', 'ownerId', 'created_at', 'updated_at', 'createdAt', 'updatedAt', 'tenantId'];
}
private addMissingSystemFields(fields: any[]): any[] {
const existing = new Map((fields || []).map((field) => [field.apiName, field]));
const systemDefaults = [
{ apiName: 'id', label: 'ID', type: 'STRING' },
{ apiName: 'created_at', label: 'Created At', type: 'DATE_TIME' },
{ apiName: 'updated_at', label: 'Updated At', type: 'DATE_TIME' },
{ apiName: 'ownerId', label: 'Owner', type: 'LOOKUP', referenceObject: 'User' },
];
const merged = [...fields];
for (const sysField of systemDefaults) {
if (!existing.has(sysField.apiName)) {
merged.push({
...sysField,
isSystem: true,
isCustom: false,
isRequired: false,
});
}
}
return merged;
}
private isKeywordField(type: string | undefined): boolean {
const normalized = String(type || '').toUpperCase();
return [
'STRING',
'TEXT',
'EMAIL',
'PHONE',
'URL',
'RICH_TEXT',
'TEXTAREA',
].includes(normalized);
'id',
'ownerId',
'created_at',
'updated_at',
'createdAt',
'updatedAt',
'tenantId',
].includes(apiName);
}
private async normalizePolymorphicRelatedObject(

View File

@@ -28,8 +28,6 @@ export class MeilisearchService {
const indexName = this.buildIndexName(config, tenantId, objectApiName);
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`;
console.log('querying Meilisearch index:', { indexName, query, displayField });
try {
const response = await this.requestJson('POST', url, {
q: query,
@@ -68,48 +66,6 @@ export class MeilisearchService {
return null;
}
async searchRecords(
tenantId: string,
objectApiName: string,
query: string,
options?: { limit?: number; offset?: number },
): Promise<{ hits: any[]; total: number }> {
const config = this.getConfig();
if (!config) return { hits: [], total: 0 };
const indexName = this.buildIndexName(config, tenantId, objectApiName);
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`;
const limit = Number.isFinite(Number(options?.limit)) ? Number(options?.limit) : 20;
const offset = Number.isFinite(Number(options?.offset)) ? Number(options?.offset) : 0;
try {
const response = await this.requestJson('POST', url, {
q: query,
limit,
offset,
}, this.buildHeaders(config));
console.log('Meilisearch response body:', response.body);
if (!this.isSuccessStatus(response.status)) {
this.logger.warn(
`Meilisearch query failed for index ${indexName}: ${response.status}`,
);
return { hits: [], total: 0 };
}
const hits = Array.isArray(response.body?.hits) ? response.body.hits : [];
const total =
response.body?.estimatedTotalHits ??
response.body?.nbHits ??
hits.length;
return { hits, total };
} catch (error) {
this.logger.warn(`Meilisearch query failed: ${error.message}`);
return { hits: [], total: 0 };
}
}
async upsertRecord(
tenantId: string,
objectApiName: string,

View File

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

View File

@@ -1,12 +0,0 @@
<!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

@@ -1,23 +0,0 @@
{
"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

@@ -1,153 +0,0 @@
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

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

View File

@@ -1,71 +0,0 @@
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

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

View File

@@ -1,11 +1,13 @@
<script setup lang="ts">
import { Toaster } from 'vue-sonner'
import BottomDrawer from '@/components/BottomDrawer.vue'
</script>
<template>
<div>
<Toaster position="top-right" :duration="4000" richColors />
<NuxtPage />
<BottomDrawer />
</div>
</template>

View File

@@ -7,36 +7,15 @@ import {
InputGroupText,
} from '@/components/ui/input-group'
import { Separator } from '@/components/ui/separator'
import { ArrowUp, Loader2 } from 'lucide-vue-next'
import { ArrowUp } from 'lucide-vue-next'
import { useRoute } from 'vue-router'
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 messages = ref<ChatMessage[]>([])
const messages = ref<{ role: 'user' | 'assistant'; text: string }[]>([])
const sending = ref(false)
const route = useRoute()
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 recordId = route.params.recordId ? String(route.params.recordId) : undefined
@@ -54,97 +33,6 @@ 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 () => {
if (!chatInput.value.trim()) return
@@ -153,53 +41,31 @@ const handleSend = async () => {
chatInput.value = ''
sending.value = true
// Add a streaming message placeholder
messages.value.push({
role: 'assistant',
text: '🤔 Thinking...',
isStreaming: true
})
try {
const history = messages.value
.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`, {
const history = messages.value.slice(0, -1).slice(-6)
const response = await api.post('/ai/chat', {
message,
history,
context: buildContext(),
sessionId: sessionId.value || undefined,
})
if (response.sessionId && !sessionId.value) {
sessionId.value = response.sessionId
connectToStream(response.sessionId)
}
messages.value.push({
role: 'assistant',
text: response.reply || 'Let me know what else you need.',
})
// Remove streaming placeholder and add response
messages.value = messages.value.filter(m => !m.isStreaming)
if (response.reply) {
messages.value.push({
role: 'assistant',
text: response.reply,
})
}
// If process is running, stream will handle updates
if (response.runId) {
messages.value.push({
role: 'assistant',
text: '⏳ Processing workflow...',
isStreaming: true,
})
if (response.action === 'create_record') {
window.dispatchEvent(
new CustomEvent('ai-record-created', {
detail: {
objectApiName: buildContext().objectApiName,
record: response.record,
},
}),
)
}
} catch (error: any) {
console.error('Failed to send AI chat message:', error)
messages.value = messages.value.filter(m => !m.isStreaming)
messages.value.push({
role: 'assistant',
text: error.message || 'Sorry, I ran into an error. Please try again.',
@@ -208,17 +74,11 @@ const handleSend = async () => {
sending.value = false
}
}
onUnmounted(() => {
if (eventSource.value) {
eventSource.value.close()
}
})
</script>
<template>
<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 max-h-[400px] overflow-y-auto">
<div class="ai-chat-messages mb-4 space-y-3">
<div
v-for="(message, index) in messages"
:key="`${message.role}-${index}`"
@@ -226,19 +86,14 @@ onUnmounted(() => {
:class="message.role === 'user' ? 'justify-end' : 'justify-start'"
>
<div
class="max-w-[80%] rounded-lg px-3 py-2 text-sm whitespace-pre-line"
: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',
}"
class="max-w-[80%] rounded-lg px-3 py-2 text-sm"
:class="message.role === 'user' ? 'bg-primary text-primary-foreground' : 'bg-white border border-border text-foreground'"
>
<Loader2 v-if="message.isStreaming" class="inline-block size-3 animate-spin mr-1" />
{{ message.text }}
</div>
</div>
<p v-if="messages.length === 0" class="text-sm text-muted-foreground">
Ask the assistant to execute business processes, add records, or answer questions.
Ask the assistant to add records, filter lists, or summarize the page.
</p>
</div>
<InputGroup>

View File

@@ -11,9 +11,6 @@ import { useSoftphone } from '~/composables/useSoftphone'
const isDrawerOpen = useState<boolean>('bottomDrawerOpen', () => false)
const activeTab = useState<string>('bottomDrawerTab', () => 'softphone')
const drawerHeight = useState<number>('bottomDrawerHeight', () => 240)
const props = defineProps<{
bounds?: { left: number; width: number }
}>()
const softphone = useSoftphone()
@@ -193,17 +190,9 @@ onBeforeUnmount(() => {
</script>
<template>
<div
class="pointer-events-none fixed bottom-0 z-30 flex justify-center px-2"
:style="{
left: props.bounds?.left ? `${props.bounds.left}px` : '0',
width: props.bounds?.width ? `${props.bounds.width}px` : '100vw',
right: props.bounds?.width ? 'auto' : '0',
}"
>
<div class="pointer-events-none fixed inset-x-0 bottom-0 z-30 flex justify-center px-2">
<div
class="pointer-events-auto w-full border border-border bg-background transition-all duration-200"
:class="{ 'shadow-2xl': isDrawerOpen }"
class="pointer-events-auto w-full border border-border bg-background shadow-xl transition-all duration-200"
:style="{ height: `${isDrawerOpen ? drawerHeight : collapsedHeight}px` }"
>
<div class="grid grid-cols-3 items-center justify-between border-border px-2 py-2">

View File

@@ -1,52 +0,0 @@
<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

@@ -1,19 +0,0 @@
<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

@@ -18,7 +18,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<CheckboxRoot
v-bind="forwarded"
:class="
cn('grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
cn('grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
props.class)"
>
<CheckboxIndicator class="grid place-content-center text-current">

View File

@@ -12,7 +12,6 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
import { ListViewConfig, ViewMode, FieldType } from '@/types/field-types'
import { ChevronDown, ChevronUp, Search, Plus, Download, Trash2, Edit } from 'lucide-vue-next'
@@ -24,7 +23,6 @@ interface Props {
selectable?: boolean
baseUrl?: string
totalCount?: number
searchSummary?: string
}
const props = withDefaults(defineProps<Props>(), {
@@ -32,7 +30,6 @@ const props = withDefaults(defineProps<Props>(), {
loading: false,
selectable: false,
baseUrl: '/runtime/objects',
searchSummary: '',
})
const emit = defineEmits<{
@@ -50,13 +47,11 @@ const emit = defineEmits<{
}>()
// State
const normalizeId = (id: any) => String(id)
const selectedRowIds = ref<string[]>([])
const selectedRows = ref<Set<string>>(new Set())
const searchQuery = ref('')
const sortField = ref<string>('')
const sortDirection = ref<'asc' | 'desc'>('asc')
const currentPage = ref(1)
const bulkAction = ref('delete')
// Computed
const visibleFields = computed(() =>
@@ -97,39 +92,27 @@ const showLoadMore = computed(() => (
))
const allSelected = computed({
get: () => props.data.length > 0 && selectedRowIds.value.length === props.data.length,
get: () => props.data.length > 0 && selectedRows.value.size === props.data.length,
set: (val: boolean) => {
if (val) {
selectedRowIds.value = props.data.map(row => normalizeId(row.id))
selectedRows.value = new Set(props.data.map(row => row.id))
} else {
selectedRowIds.value = []
selectedRows.value.clear()
}
emit('row-select', getSelectedRows())
},
})
const getSelectedRows = () => {
const idSet = new Set(selectedRowIds.value)
return props.data.filter(row => idSet.has(normalizeId(row.id)))
return props.data.filter(row => selectedRows.value.has(row.id))
}
const toggleRowSelection = (rowId: string) => {
const normalizedId = normalizeId(rowId)
const nextSelection = new Set(selectedRowIds.value)
nextSelection.has(normalizedId) ? nextSelection.delete(normalizedId) : nextSelection.add(normalizedId)
selectedRowIds.value = Array.from(nextSelection)
emit('row-select', getSelectedRows())
}
const setRowSelection = (rowId: string, checked: boolean) => {
const normalizedId = normalizeId(rowId)
const nextSelection = new Set(selectedRowIds.value)
if (checked) {
nextSelection.add(normalizedId)
if (selectedRows.value.has(rowId)) {
selectedRows.value.delete(rowId)
} else {
nextSelection.delete(normalizedId)
selectedRows.value.add(rowId)
}
selectedRowIds.value = Array.from(nextSelection)
emit('row-select', getSelectedRows())
}
@@ -151,14 +134,6 @@ const handleAction = (actionId: string) => {
emit('action', actionId, getSelectedRows())
}
const handleBulkAction = () => {
if (bulkAction.value === 'delete') {
emit('delete', getSelectedRows())
return
}
emit('action', bulkAction.value, getSelectedRows())
}
const goToPage = (page: number) => {
const nextPage = Math.min(Math.max(page, 1), availablePages.value)
if (nextPage !== currentPage.value) {
@@ -180,19 +155,6 @@ watch(
}
}
)
watch(
() => props.data,
(rows) => {
const rowIds = new Set(rows.map(row => normalizeId(row.id)))
const nextSelection = selectedRowIds.value.filter(id => rowIds.has(id))
if (nextSelection.length !== selectedRowIds.value.length) {
selectedRowIds.value = nextSelection
emit('row-select', getSelectedRows())
}
},
{ deep: true }
)
</script>
<template>
@@ -210,31 +172,18 @@ watch(
@keyup.enter="handleSearch"
/>
</div>
<p v-if="searchSummary" class="mt-2 text-xs text-muted-foreground">
{{ searchSummary }}
</p>
</div>
<div class="flex items-center gap-2">
<!-- Bulk Actions -->
<template v-if="selectedRowIds.length > 0">
<template v-if="selectedRows.size > 0">
<Badge variant="secondary" class="px-3 py-1">
{{ selectedRowIds.length }} selected
{{ selectedRows.size }} selected
</Badge>
<div class="flex items-center gap-2">
<Select v-model="bulkAction" @update:model-value="(value) => bulkAction = value">
<SelectTrigger class="h-8 w-[180px]">
<SelectValue placeholder="Select action" />
</SelectTrigger>
<SelectContent>
<SelectItem value="delete">Delete selected</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" @click="handleBulkAction">
<Trash2 class="h-4 w-4 mr-2" />
Run
</Button>
</div>
<Button variant="outline" size="sm" @click="emit('delete', getSelectedRows())">
<Trash2 class="h-4 w-4 mr-2" />
Delete
</Button>
</template>
<!-- Custom Actions -->
@@ -268,10 +217,7 @@ watch(
<TableHeader>
<TableRow>
<TableHead v-if="selectable" class="w-12">
<Checkbox
:model-value="allSelected"
@update:model-value="(value: boolean) => (allSelected = value)"
/>
<Checkbox v-model:checked="allSelected" />
</TableHead>
<TableHead
v-for="field in visibleFields"
@@ -312,8 +258,8 @@ watch(
>
<TableCell v-if="selectable" @click.stop>
<Checkbox
:model-value="selectedRowIds.includes(normalizeId(row.id))"
@update:model-value="(checked: boolean) => setRowSelection(normalizeId(row.id), checked)"
:checked="selectedRows.has(row.id)"
@update:checked="toggleRowSelection(row.id)"
/>
</TableCell>
<TableCell v-for="field in visibleFields" :key="field.id">

View File

@@ -330,25 +330,9 @@ export const useViewState = <T extends { id?: string }>(
loading.value = true
error.value = null
try {
const useBulkEndpoint = apiEndpoint.includes('/runtime/objects/')
if (useBulkEndpoint) {
const response = await api.post(`${apiEndpoint}/bulk-delete`, { ids })
const deletedIds = Array.isArray(response?.deletedIds) ? response.deletedIds : ids
records.value = records.value.filter(r => !deletedIds.includes(r.id!))
totalCount.value = Math.max(0, totalCount.value - deletedIds.length)
return {
deletedIds,
deniedIds: Array.isArray(response?.deniedIds) ? response.deniedIds : [],
}
}
await Promise.all(ids.map(id => api.delete(`${apiEndpoint}/${id}`)))
records.value = records.value.filter(r => !ids.includes(r.id!))
totalCount.value = Math.max(0, totalCount.value - ids.length)
return {
deletedIds: ids,
deniedIds: [],
}
} catch (e: any) {
error.value = e.message
console.error('Failed to delete records:', e)

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { ref } from 'vue'
import AppSidebar from '@/components/AppSidebar.vue'
import BottomDrawer from '@/components/BottomDrawer.vue'
import {
@@ -15,9 +15,6 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/s
const route = useRoute()
const { breadcrumbs: customBreadcrumbs } = useBreadcrumbs()
const drawerBounds = useState('bottomDrawerBounds', () => ({ left: 0, width: 0 }))
const insetRef = ref<any>(null)
let resizeObserver: ResizeObserver | null = null
const breadcrumbs = computed(() => {
// If custom breadcrumbs are set by the page, use those
@@ -33,47 +30,12 @@ const breadcrumbs = computed(() => {
isLast: index === paths.length - 1,
}))
})
const resolveInsetEl = (): HTMLElement | null => {
const maybeComponent = insetRef.value as any
if (!maybeComponent) return null
return maybeComponent.$el ? maybeComponent.$el as HTMLElement : (maybeComponent as HTMLElement)
}
const updateBounds = () => {
const el = resolveInsetEl()
if (!el || typeof el.getBoundingClientRect !== 'function') return
const rect = el.getBoundingClientRect()
drawerBounds.value = {
left: rect.left,
width: rect.width,
}
}
onMounted(() => {
updateBounds()
const el = resolveInsetEl()
if (el && 'ResizeObserver' in window) {
resizeObserver = new ResizeObserver(updateBounds)
resizeObserver.observe(el)
}
window.addEventListener('resize', updateBounds)
})
onBeforeUnmount(() => {
const el = resolveInsetEl()
if (resizeObserver && el) {
resizeObserver.unobserve(el)
}
resizeObserver = null
window.removeEventListener('resize', updateBounds)
})
</script>
<template>
<SidebarProvider>
<AppSidebar />
<SidebarInset ref="insetRef" class="relative flex flex-col">
<SidebarInset class="flex flex-col">
<header
class="relative z-10 flex h-16 shrink-0 items-center gap-2 bg-background transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 border-b shadow-md"
>
@@ -111,8 +73,7 @@ onBeforeUnmount(() => {
<slot />
</div>
<!-- Keep BottomDrawer bound to the inset width so it aligns with the sidebar layout -->
<BottomDrawer :bounds="drawerBounds" />
</SidebarInset>
</SidebarProvider>
</template>

View File

@@ -26,8 +26,6 @@ export default defineNuxtConfig({
runtimeConfig: {
public: {
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

@@ -6,14 +6,6 @@ import { useFields, useViewState } from '@/composables/useFieldViews'
import ListView from '@/components/views/ListView.vue'
import DetailView from '@/components/views/DetailViewEnhanced.vue'
import EditView from '@/components/views/EditViewEnhanced.vue'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
const route = useRoute()
const router = useRouter()
@@ -153,17 +145,6 @@ const editConfig = computed(() => {
const listPageSize = computed(() => listConfig.value?.pageSize ?? 25)
const maxFrontendRecords = computed(() => listConfig.value?.maxFrontendRecords ?? 500)
const searchQuery = ref('')
const searchSummary = ref('')
const searchLoading = ref(false)
const deleteDialogOpen = ref(false)
const deleteSubmitting = ref(false)
const pendingDeleteRows = ref<any[]>([])
const deleteSummary = ref<{ deletedIds: string[]; deniedIds: string[] } | null>(null)
const isSearchActive = computed(() => searchQuery.value.trim().length > 0)
const pendingDeleteCount = computed(() => pendingDeleteRows.value.length)
const deniedDeleteCount = computed(() => deleteSummary.value?.deniedIds.length ?? 0)
// Fetch object definition
const fetchObjectDefinition = async () => {
@@ -208,42 +189,16 @@ const handleCreateRelated = (relatedObjectApiName: string, _parentId: string) =>
}
const handleDelete = async (rows: any[]) => {
pendingDeleteRows.value = rows
deleteSummary.value = null
deleteDialogOpen.value = true
}
const resetDeleteDialog = () => {
deleteDialogOpen.value = false
deleteSubmitting.value = false
pendingDeleteRows.value = []
deleteSummary.value = null
}
const confirmDelete = async () => {
if (pendingDeleteRows.value.length === 0) {
resetDeleteDialog()
return
}
deleteSubmitting.value = true
try {
const ids = pendingDeleteRows.value.map(r => r.id)
const result = await deleteRecords(ids)
const deletedIds = result?.deletedIds ?? []
const deniedIds = result?.deniedIds ?? []
deleteSummary.value = { deletedIds, deniedIds }
if (deniedIds.length === 0) {
resetDeleteDialog()
if (confirm(`Delete ${rows.length} record(s)? This action cannot be undone.`)) {
try {
const ids = rows.map(r => r.id)
await deleteRecords(ids)
if (view.value !== 'list') {
await router.push(`/${objectApiName.value.toLowerCase()}/`)
}
} catch (e: any) {
error.value = e.message || 'Failed to delete records'
}
} catch (e: any) {
error.value = e.message || 'Failed to delete records'
} finally {
deleteSubmitting.value = false
}
}
@@ -280,36 +235,6 @@ const loadListRecords = async (
return result
}
const searchListRecords = async (
page = 1,
options?: { append?: boolean; pageSize?: number }
) => {
if (!isSearchActive.value) {
return initializeListRecords()
}
searchLoading.value = true
try {
const pageSize = options?.pageSize ?? listPageSize.value
const response = await api.post('/ai/search', {
objectApiName: objectApiName.value,
query: searchQuery.value.trim(),
page,
pageSize,
})
const data = response?.data ?? []
const total = response?.totalCount ?? data.length
records.value = options?.append ? [...records.value, ...data] : data
totalCount.value = total
searchSummary.value = response?.explanation || ''
return response
} catch (e: any) {
error.value = e.message || 'Failed to search records'
return null
} finally {
searchLoading.value = false
}
}
const initializeListRecords = async () => {
const firstResult = await loadListRecords(1)
const resolvedTotal = firstResult?.totalCount ?? totalCount.value ?? records.value.length
@@ -322,10 +247,6 @@ const initializeListRecords = async () => {
}
const handlePageChange = async (page: number, pageSize: number) => {
if (isSearchActive.value) {
await searchListRecords(page, { append: page > 1, pageSize })
return
}
const loadedPages = Math.ceil(records.value.length / pageSize)
if (page > loadedPages && totalCount.value > records.value.length) {
await loadListRecords(page, { append: true, pageSize })
@@ -333,24 +254,9 @@ const handlePageChange = async (page: number, pageSize: number) => {
}
const handleLoadMore = async (page: number, pageSize: number) => {
if (isSearchActive.value) {
await searchListRecords(page, { append: true, pageSize })
return
}
await loadListRecords(page, { append: true, pageSize })
}
const handleSearch = async (query: string) => {
const trimmed = query.trim()
searchQuery.value = trimmed
if (!trimmed) {
searchSummary.value = ''
await initializeListRecords()
return
}
await searchListRecords(1, { append: false, pageSize: listPageSize.value })
}
// Watch for route changes
watch(() => route.params, async (newParams, oldParams) => {
// Reset current record when navigating to 'new'
@@ -419,16 +325,14 @@ onMounted(async () => {
v-else-if="view === 'list' && listConfig"
:config="listConfig"
:data="records"
:loading="dataLoading || searchLoading"
:loading="dataLoading"
:total-count="totalCount"
:search-summary="searchSummary"
:base-url="`/runtime/objects`"
selectable
@row-click="handleRowClick"
@create="handleCreate"
@edit="handleEdit"
@delete="handleDelete"
@search="handleSearch"
@page-change="handlePageChange"
@load-more="handleLoadMore"
/>
@@ -462,46 +366,6 @@ onMounted(async () => {
@back="handleBack"
/>
</div>
<Dialog v-model:open="deleteDialogOpen">
<DialogContent class="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Delete records</DialogTitle>
<DialogDescription>
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<div class="space-y-2 text-sm">
<p>
You are about to delete {{ pendingDeleteCount }} record<span v-if="pendingDeleteCount !== 1">s</span>.
</p>
<p v-if="deleteSummary" class="text-muted-foreground">
Deleted {{ deleteSummary.deletedIds.length }} record<span v-if="deleteSummary.deletedIds.length !== 1">s</span>.
</p>
<p v-if="deniedDeleteCount > 0" class="text-destructive">
{{ deniedDeleteCount }} record<span v-if="deniedDeleteCount !== 1">s</span> could not be deleted due to missing permissions.
</p>
<p v-if="!deleteSummary" class="text-muted-foreground">
Records you do not have permission to delete will be skipped.
</p>
</div>
<DialogFooter>
<Button variant="outline" @click="resetDeleteDialog" :disabled="deleteSubmitting">
{{ deleteSummary ? 'Close' : 'Cancel' }}
</Button>
<Button
v-if="!deleteSummary"
variant="destructive"
@click="confirmDelete"
:disabled="deleteSubmitting"
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</NuxtLayout>
</template>

View File

@@ -1,14 +0,0 @@
<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

@@ -1,192 +0,0 @@
<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

@@ -1,133 +0,0 @@
<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>