WIP - initial AI assistant chat working creating records

This commit is contained in:
Francisco Gaona
2026-01-12 23:55:57 +01:00
parent ca11c8cbe7
commit d2b3fce4eb
10 changed files with 1551 additions and 6 deletions

View File

@@ -11,6 +11,9 @@
"dependencies": {
"@casl/ability": "^6.7.5",
"@fastify/websocket": "^10.0.1",
"@langchain/core": "^1.1.12",
"@langchain/langgraph": "^1.0.15",
"@langchain/openai": "^1.2.1",
"@nestjs/bullmq": "^10.1.0",
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
@@ -28,6 +31,7 @@
"class-validator": "^0.14.1",
"ioredis": "^5.3.2",
"knex": "^3.1.0",
"langchain": "^1.2.7",
"mysql2": "^3.15.3",
"objection": "^3.1.5",
"openai": "^6.15.0",
@@ -762,6 +766,12 @@
"url": "https://github.com/stalniy/casl/blob/master/BACKERS.md"
}
},
"node_modules/@cfworker/json-schema": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz",
"integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==",
"license": "MIT"
},
"node_modules/@colors/colors": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
@@ -1679,6 +1689,220 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@langchain/core": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.12.tgz",
"integrity": "sha512-sHWLvhyLi3fntlg3MEPB89kCjxEX7/+imlIYJcp6uFGCAZfGxVWklqp22HwjT1szorUBYrkO8u0YA554ReKxGQ==",
"license": "MIT",
"dependencies": {
"@cfworker/json-schema": "^4.0.2",
"ansi-styles": "^5.0.0",
"camelcase": "6",
"decamelize": "1.2.0",
"js-tiktoken": "^1.0.12",
"langsmith": ">=0.4.0 <1.0.0",
"mustache": "^4.2.0",
"p-queue": "^6.6.2",
"uuid": "^10.0.0",
"zod": "^3.25.76 || ^4"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@langchain/core/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@langchain/core/node_modules/camelcase": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
"integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@langchain/core/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@langchain/langgraph": {
"version": "1.0.15",
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.0.15.tgz",
"integrity": "sha512-l7/f255sPilanhyY+lbX+VDXQSnytFwJ4FVoEl4OBpjDoCHuDyHUL5yrb568apBSHgQA7aKsYac0mBEqIR5Bjg==",
"license": "MIT",
"dependencies": {
"@langchain/langgraph-checkpoint": "^1.0.0",
"@langchain/langgraph-sdk": "~1.5.0",
"uuid": "^10.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@langchain/core": "^1.0.1",
"zod": "^3.25.32 || ^4.1.0",
"zod-to-json-schema": "^3.x"
},
"peerDependenciesMeta": {
"zod-to-json-schema": {
"optional": true
}
}
},
"node_modules/@langchain/langgraph-checkpoint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.0.tgz",
"integrity": "sha512-xrclBGvNCXDmi0Nz28t3vjpxSH6UYx6w5XAXSiiB1WEdc2xD2iY/a913I3x3a31XpInUW/GGfXXfePfaghV54A==",
"license": "MIT",
"dependencies": {
"uuid": "^10.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@langchain/core": "^1.0.1"
}
},
"node_modules/@langchain/langgraph-checkpoint/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@langchain/langgraph-sdk": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.5.2.tgz",
"integrity": "sha512-ArRnYIqJEUKnS+HFZoTtsIy2Uxy158l5ZTPWNhJkws6FuDEA3q/h6bhvHpZIf5z0JseDHCCoIbx6yOc2RpMpgg==",
"license": "MIT",
"dependencies": {
"p-queue": "^9.0.1",
"p-retry": "^7.1.1",
"uuid": "^13.0.0"
},
"peerDependencies": {
"@langchain/core": "^1.0.1",
"react": "^18 || ^19",
"react-dom": "^18 || ^19"
},
"peerDependenciesMeta": {
"@langchain/core": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/@langchain/langgraph-sdk/node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/@langchain/langgraph-sdk/node_modules/p-queue": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz",
"integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^5.0.1",
"p-timeout": "^7.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@langchain/langgraph-sdk/node_modules/p-timeout": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz",
"integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@langchain/langgraph-sdk/node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/@langchain/langgraph/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@langchain/openai": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.2.1.tgz",
"integrity": "sha512-eZYPhvXIwz0/8iCjj2LWqeaznQ7DZ6tBdvF+Ebv4sQW2UqJWZqRC8QIdKZgTbs8ffMWPHkSSOidYqu4XfWCNYg==",
"license": "MIT",
"dependencies": {
"js-tiktoken": "^1.0.12",
"openai": "^6.10.0",
"zod": "^3.25.76 || ^4"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@langchain/core": "^1.0.0"
}
},
"node_modules/@ljharb/through": {
"version": "2.3.14",
"resolved": "https://registry.npmjs.org/@ljharb/through/-/through-2.3.14.tgz",
@@ -2818,6 +3042,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"license": "MIT"
},
"node_modules/@types/validator": {
"version": "13.15.10",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
@@ -3711,7 +3941,6 @@
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -4337,6 +4566,15 @@
"integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
"license": "ISC"
},
"node_modules/console-table-printer": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/console-table-printer/-/console-table-printer-2.15.0.tgz",
"integrity": "sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==",
"license": "MIT",
"dependencies": {
"simple-wcswidth": "^1.1.2"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -4485,6 +4723,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dedent": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz",
@@ -5150,6 +5397,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -6389,6 +6642,18 @@
"node": ">=8"
}
},
"node_modules/is-network-error": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz",
"integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -7279,6 +7544,15 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/js-tiktoken": {
"version": "1.0.21",
"resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz",
"integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.5.1"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -7536,6 +7810,85 @@
"node": ">=8"
}
},
"node_modules/langchain": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.2.7.tgz",
"integrity": "sha512-G+3Ftz/08CurJaE7LukQGBf3mCSz7XM8LZeAaFPg391Ru4lT8eLYfG6Fv4ZI0u6EBsPVcOQfaS9ig8nCRmJeqA==",
"license": "MIT",
"dependencies": {
"@langchain/langgraph": "^1.0.0",
"@langchain/langgraph-checkpoint": "^1.0.0",
"langsmith": ">=0.4.0 <1.0.0",
"uuid": "^10.0.0",
"zod": "^3.25.76 || ^4"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@langchain/core": "1.1.12"
}
},
"node_modules/langchain/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/langsmith": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.4.5.tgz",
"integrity": "sha512-9N4JSQLz6fWiZwVXaiy0erlvNHlC68EtGJZG2OX+1y9mqj7KvKSL+xJnbCFc+ky3JN8s1d6sCfyyDdi4uDdLnQ==",
"license": "MIT",
"dependencies": {
"@types/uuid": "^10.0.0",
"chalk": "^4.1.2",
"console-table-printer": "^2.12.1",
"p-queue": "^6.6.2",
"semver": "^7.6.3",
"uuid": "^10.0.0"
},
"peerDependencies": {
"@opentelemetry/api": "*",
"@opentelemetry/exporter-trace-otlp-proto": "*",
"@opentelemetry/sdk-trace-base": "*",
"openai": "*"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@opentelemetry/exporter-trace-otlp-proto": {
"optional": true
},
"@opentelemetry/sdk-trace-base": {
"optional": true
},
"openai": {
"optional": true
}
}
},
"node_modules/langsmith/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -8037,6 +8390,15 @@
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
}
},
"node_modules/mustache": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
"integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
"license": "MIT",
"bin": {
"mustache": "bin/mustache"
}
},
"node_modules/mute-stream": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
@@ -8438,6 +8800,15 @@
"node": ">=0.10.0"
}
},
"node_modules/p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
"integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -8470,6 +8841,49 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-queue": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz",
"integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^4.0.4",
"p-timeout": "^3.2.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-retry": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz",
"integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==",
"license": "MIT",
"dependencies": {
"is-network-error": "^1.1.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-timeout": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
"integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
"license": "MIT",
"dependencies": {
"p-finally": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
@@ -9617,6 +10031,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/simple-wcswidth": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz",
"integrity": "sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==",
"license": "MIT"
},
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -11086,6 +11506,15 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -28,6 +28,9 @@
"dependencies": {
"@casl/ability": "^6.7.5",
"@fastify/websocket": "^10.0.1",
"@langchain/core": "^1.1.12",
"@langchain/langgraph": "^1.0.15",
"@langchain/openai": "^1.2.1",
"@nestjs/bullmq": "^10.1.0",
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
@@ -45,6 +48,7 @@
"class-validator": "^0.14.1",
"ioredis": "^5.3.2",
"knex": "^3.1.0",
"langchain": "^1.2.7",
"mysql2": "^3.15.3",
"objection": "^3.1.5",
"openai": "^6.15.0",

View File

@@ -0,0 +1,27 @@
import { Body, Controller, Post, 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 { AiAssistantService } from './ai-assistant.service';
import { AiChatRequestDto } from './dto/ai-chat.dto';
@Controller('ai')
@UseGuards(JwtAuthGuard)
export class AiAssistantController {
constructor(private readonly aiAssistantService: AiAssistantService) {}
@Post('chat')
async chat(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Body() payload: AiChatRequestDto,
) {
return this.aiAssistantService.handleChat(
tenantId,
user.userId,
payload.message,
payload.history,
payload.context,
);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AiAssistantController } from './ai-assistant.controller';
import { AiAssistantService } from './ai-assistant.service';
import { ObjectModule } from '../object/object.module';
import { PageLayoutModule } from '../page-layout/page-layout.module';
import { TenantModule } from '../tenant/tenant.module';
@Module({
imports: [ObjectModule, PageLayoutModule, TenantModule],
controllers: [AiAssistantController],
providers: [AiAssistantService],
})
export class AiAssistantModule {}

View File

@@ -0,0 +1,928 @@
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';
import { Annotation, END, START, StateGraph } from '@langchain/langgraph';
import { ObjectService } from '../object/object.service';
import { PageLayoutService } from '../page-layout/page-layout.service';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { getCentralPrisma } from '../prisma/central-prisma.service';
import { OpenAIConfig } from '../voice/interfaces/integration-config.interface';
import { AiAssistantReply, AiAssistantState } from './ai-assistant.types';
@Injectable()
export class AiAssistantService {
private readonly logger = new Logger(AiAssistantService.name);
private readonly defaultModel = process.env.OPENAI_MODEL || 'gpt-4o';
private readonly conversationState = new Map<
string,
{ fields: Record<string, any>; updatedAt: number }
>();
private readonly conversationTtlMs = 30 * 60 * 1000; // 30 minutes
constructor(
private readonly objectService: ObjectService,
private readonly pageLayoutService: PageLayoutService,
private readonly tenantDbService: TenantDatabaseService,
) {}
async handleChat(
tenantId: string,
userId: string,
message: string,
history: AiAssistantState['history'],
context: AiAssistantState['context'],
): Promise<AiAssistantReply> {
this.pruneConversations();
const conversationKey = this.getConversationKey(
tenantId,
userId,
context?.objectApiName,
);
const prior = this.conversationState.get(conversationKey);
const trimmedHistory = Array.isArray(history) ? history.slice(-6) : [];
const initialState: AiAssistantState = {
message: this.combineHistory(trimmedHistory, message),
history: trimmedHistory,
context: context || {},
extractedFields: prior?.fields,
};
const finalState = await this.runAssistantGraph(tenantId, userId, initialState);
if (finalState.record) {
this.conversationState.delete(conversationKey);
} else if (finalState.extractedFields && Object.keys(finalState.extractedFields).length > 0) {
this.conversationState.set(conversationKey, {
fields: finalState.extractedFields,
updatedAt: Date.now(),
});
}
return {
reply: finalState.reply || 'How can I help?',
action: finalState.action,
missingFields: finalState.missingFields,
record: finalState.record,
};
}
private async runAssistantGraph(
tenantId: string,
userId: string,
state: AiAssistantState,
): Promise<AiAssistantState> {
const AssistantState = Annotation.Root({
message: Annotation<string>(),
history: Annotation<AiAssistantState['history']>(),
context: Annotation<AiAssistantState['context']>(),
objectDefinition: Annotation<any>(),
pageLayout: Annotation<any>(),
extractedFields: Annotation<Record<string, any>>(),
requiredFields: Annotation<string[]>(),
missingFields: Annotation<string[]>(),
action: Annotation<AiAssistantState['action']>(),
record: Annotation<any>(),
reply: Annotation<string>(),
});
const workflow = new StateGraph(AssistantState)
.addNode('loadContext', async (current: AiAssistantState) => {
return this.loadContext(tenantId, current);
})
.addNode('extractFields', async (current: AiAssistantState) => {
return this.extractFields(tenantId, current);
})
.addNode('decideNext', async (current: AiAssistantState) => {
return this.decideNextStep(current);
})
.addNode('createRecord', async (current: AiAssistantState) => {
return this.createRecord(tenantId, userId, current);
})
.addNode('respondMissing', async (current: AiAssistantState) => {
return this.respondWithMissingFields(current);
})
.addEdge(START, 'loadContext')
.addEdge('loadContext', 'extractFields')
.addEdge('extractFields', 'decideNext')
.addConditionalEdges('decideNext', (current: AiAssistantState) => {
return current.action === 'create_record' ? 'createRecord' : 'respondMissing';
})
.addEdge('createRecord', END)
.addEdge('respondMissing', END);
const graph = workflow.compile();
return graph.invoke(state);
}
private async loadContext(
tenantId: string,
state: AiAssistantState,
): Promise<AiAssistantState> {
const objectApiName = state.context?.objectApiName;
console.log('Here:');
console.log(objectApiName);
if (!objectApiName) {
return {
...state,
action: 'clarify',
reply: 'Tell me which object you want to work with, for example: "Add an account named Cloudflare."',
};
}
const objectDefinition = await this.objectService.getObjectDefinition(
tenantId,
objectApiName,
);
if (!objectDefinition) {
return {
...state,
action: 'clarify',
reply: `I could not find an object named "${objectApiName}". Which object should I use?`,
};
}
const pageLayout = await this.pageLayoutService.findDefaultByObject(
tenantId,
objectDefinition.id,
);
return {
...state,
objectDefinition,
pageLayout,
history: state.history,
};
}
private async extractFields(
tenantId: string,
state: AiAssistantState,
): Promise<AiAssistantState> {
if (!state.objectDefinition) {
return state;
}
const openAiConfig = await this.getOpenAiConfig(tenantId);
const fieldDefinitions = (state.objectDefinition.fields || []).filter(
(field: any) => !this.isSystemField(field.apiName),
);
if (!openAiConfig) {
this.logger.warn('No OpenAI config found; using heuristic extraction.');
}
const newExtraction = openAiConfig
? await this.extractWithOpenAI(openAiConfig, state.message, state.objectDefinition.label, fieldDefinitions)
: this.extractWithHeuristics(state.message, fieldDefinitions);
const mergedExtraction = {
...(state.extractedFields || {}),
...(newExtraction || {}),
};
return {
...state,
extractedFields: mergedExtraction,
history: state.history,
};
}
private decideNextStep(state: AiAssistantState): AiAssistantState {
if (!state.objectDefinition) {
return state;
}
console.log('extracated:',state.extractedFields);
const fieldDefinitions = (state.objectDefinition.fields || []).filter(
(field: any) => !this.isSystemField(field.apiName),
);
const requiredFields = this.getRequiredFields(fieldDefinitions);
const missingFields = requiredFields.filter(
(fieldApiName) => !state.extractedFields?.[fieldApiName],
);
if (missingFields.length > 0) {
return {
...state,
requiredFields,
missingFields,
action: 'collect_fields',
};
}
return {
...state,
requiredFields,
missingFields: [],
action: 'create_record',
};
}
private async createRecord(
tenantId: string,
userId: string,
state: AiAssistantState,
): Promise<AiAssistantState> {
if (!state.objectDefinition || !state.extractedFields) {
return {
...state,
action: 'clarify',
reply: 'I could not infer the record details. Can you provide the fields you want to set?'
};
}
const enrichedState = await this.resolvePolymorphicRelatedObject(
tenantId,
this.applyPolymorphicDefaults(state),
);
const {
resolvedFields,
unresolvedLookups,
} = await this.resolveLookupFields(
tenantId,
enrichedState.objectDefinition,
enrichedState.extractedFields,
);
if (unresolvedLookups.length > 0) {
const missingText = unresolvedLookups
.map(
(lookup) =>
`${lookup.fieldLabel || lookup.fieldApiName} (value "${lookup.providedValue}") for ${lookup.targetLabel || 'the related record'}`,
)
.join('; ');
return {
...state,
action: 'collect_fields',
reply: `I couldn't find these related records: ${missingText}. Please provide an existing record name or ID for each.`,
};
}
if (this.isContactDetail(enrichedState.objectDefinition.apiName)) {
const hasId = !!resolvedFields.relatedObjectId;
const hasType = !!resolvedFields.relatedObjectType;
if (!hasId || !hasType) {
return {
...enrichedState,
action: 'collect_fields',
reply:
'I need which record this contact detail belongs to. Please provide the related Contact or Account name/ID.',
history: state.history,
};
}
}
const record = await this.objectService.createRecord(
tenantId,
enrichedState.objectDefinition.apiName,
resolvedFields,
userId,
);
const nameValue = enrichedState.extractedFields.name || record?.name || record?.id;
const label = enrichedState.objectDefinition.label || enrichedState.objectDefinition.apiName;
return {
...enrichedState,
record,
action: 'create_record',
reply: `Created ${label} ${nameValue ? `"${nameValue}"` : 'record'} successfully.`,
history: state.history,
};
}
private respondWithMissingFields(state: AiAssistantState): AiAssistantState {
if (!state.objectDefinition) {
return state;
}
const label = state.objectDefinition.label || state.objectDefinition.apiName;
const orderedMissing = this.orderMissingFields(state);
const missingLabels = orderedMissing.map(
(apiName) => this.getFieldLabel(state.objectDefinition.fields || [], apiName),
);
return {
...state,
action: 'collect_fields',
reply: `To create a ${label}, I still need: ${missingLabels.join(', ')}.`,
history: state.history,
};
}
private orderMissingFields(state: AiAssistantState): string[] {
if (!state.pageLayout || !state.missingFields) {
return state.missingFields || [];
}
const layoutConfig = this.parseLayoutConfig(state.pageLayout.layout_config);
const layoutFieldIds: string[] = layoutConfig?.fields?.map((field: any) => field.fieldId) || [];
const fieldIdToApiName = new Map(
(state.objectDefinition.fields || []).map((field: any) => [field.id, field.apiName]),
);
const ordered = layoutFieldIds
.map((fieldId) => fieldIdToApiName.get(fieldId))
.filter((apiName): apiName is string => Boolean(apiName))
.filter((apiName) => state.missingFields?.includes(apiName));
console.log('ordered:',ordered);
const remaining = (state.missingFields || []).filter(
(apiName) => !ordered.includes(apiName),
);
console.log('remaining:',remaining);
return [...ordered, ...remaining];
}
private getRequiredFields(fieldDefinitions: any[]): string[] {
const required = fieldDefinitions
.filter((field) => field.isRequired)
.map((field) => field.apiName);
const hasNameField = fieldDefinitions.some((field) => field.apiName === 'name');
if (hasNameField && !required.includes('name')) {
required.unshift('name');
}
return Array.from(new Set(required));
}
private async extractWithOpenAI(
openAiConfig: OpenAIConfig,
message: string,
objectLabel: string,
fieldDefinitions: any[],
didRetry = false,
): Promise<Record<string, any>> {
console.log('Using OpenAI extraction for message:', message);
try {
const model = new ChatOpenAI({
apiKey: openAiConfig.apiKey,
model: this.normalizeChatModel(openAiConfig.model),
temperature: 0.2,
});
const fieldDescriptions = fieldDefinitions.map((field) => {
return `${field.label} (${field.apiName}, type: ${field.type})`;
});
console.log('fieldDescriptions:',fieldDescriptions);
const parser = new JsonOutputParser<Record<string, any>>();
const response = await model.invoke([
new SystemMessage(
`You extract field values to create a ${objectLabel} record.` +
'\n- Return JSON only with keys: action, fields.' +
'\n- Use action "create_record" when the user wants to add or create.' +
'\n- Use ONLY apiName keys exactly as provided (case-sensitive). NEVER use labels or other keys.' +
'\n- Prefer values from the latest user turn, but keep earlier user-provided values in the same conversation for missing fields.' +
'\n- If a field value is provided, include it even if it looks custom; do not drop custom fields.' +
'\n- Avoid guessing fields that were not mentioned.' +
'\n- Example: {"action":"create_record","fields":{"apiName1":"value"}}',
),
new HumanMessage(
`Fields: ${fieldDescriptions.join('; ')}.\nUser message: ${message}`,
),
]);
console.log('respomse:', response);
const content = typeof response.content === 'string' ? response.content : '{}';
const parsed = await parser.parse(content);
const rawFields = parsed.fields || {};
const normalizedFields = this.normalizeExtractedFieldKeys(rawFields, fieldDefinitions);
const sanitizedFields = this.sanitizeUserOwnerFields(
normalizedFields,
fieldDefinitions,
message,
);
return Object.fromEntries(
Object.entries(sanitizedFields).filter(([apiName]) =>
fieldDefinitions.some((field) => field.apiName === apiName),
),
);
} catch (error) {
const messageText = error?.message || '';
const shouldRetryWithDefault =
!didRetry &&
(messageText.includes('not a chat model') ||
messageText.includes('MODEL_NOT_FOUND') ||
messageText.includes('404'));
if (shouldRetryWithDefault) {
this.logger.warn(
`OpenAI extraction failed with model "${openAiConfig.model}". Retrying with gpt-4o-mini. Error: ${messageText}`,
);
return this.extractWithOpenAI(
{ ...openAiConfig, model: 'gpt-4o-mini' },
message,
objectLabel,
fieldDefinitions,
true,
);
}
this.logger.warn(`OpenAI extraction failed: ${messageText}`);
return this.extractWithHeuristics(message, fieldDefinitions);
}
}
private extractWithHeuristics(
message: string,
fieldDefinitions: any[],
): Record<string, any> {
const extracted: Record<string, any> = {};
const lowerMessage = message.toLowerCase();
console.log('Heuristic extraction for message:', message);
const nameField = fieldDefinitions.find(
(field) => field.apiName === 'name' || field.label.toLowerCase() === 'name',
);
const phoneField = fieldDefinitions.find((field) =>
field.apiName.toLowerCase().includes('phone') || field.label.toLowerCase().includes('phone'),
);
// Generic pattern matching for any field: "label: value" or "set label to value"
for (const field of fieldDefinitions) {
const value = this.extractValueForField(message, field);
if (value) {
extracted[field.apiName] = value;
}
}
if (nameField) {
const nameMatch = message.match(/add\s+([^\n]+?)(?:\s+with\s+|$)/i);
if (nameMatch?.[1]) {
extracted[nameField.apiName] = nameMatch[1].trim();
}
}
if (phoneField) {
const phoneMatch = message.match(/phone\s+([\d+().\s-]+)/i);
if (phoneMatch?.[1]) {
extracted[phoneField.apiName] = phoneMatch[1].trim();
}
}
if (Object.keys(extracted).length === 0 && lowerMessage.startsWith('add ') && nameField) {
extracted[nameField.apiName] = message.replace(/^add\s+/i, '').trim();
}
return extracted;
}
private sanitizeUserOwnerFields(
fields: Record<string, any>,
fieldDefinitions: any[],
message: string,
): Record<string, any> {
const mentionsAssignment = /\b(user|owner|assign|assigned)\b/i.test(message);
const defsByApi = new Map(fieldDefinitions.map((f: any) => [f.apiName, f]));
const result: Record<string, any> = {};
for (const [apiName, value] of Object.entries(fields || {})) {
const def = defsByApi.get(apiName);
const label = def?.label || apiName;
const isUserish = /\b(user|owner)\b/i.test(label);
if (isUserish && !mentionsAssignment) {
// Skip auto-assigned "User"/"Owner" when the user didn't mention assignment
continue;
}
result[apiName] = value;
}
return result;
}
private normalizeExtractedFieldKeys(
fields: Record<string, any>,
fieldDefinitions: any[],
): Record<string, any> {
if (!fields) return {};
const apiNames = new Map(
(fieldDefinitions || []).map((f: any) => [f.apiName.toLowerCase(), f.apiName]),
);
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(fields)) {
const canonical = apiNames.get(key.toLowerCase());
if (canonical) {
result[canonical] = value;
}
}
return result;
}
private applyPolymorphicDefaults(state: AiAssistantState): AiAssistantState {
if (!state.objectDefinition || !state.extractedFields) return state;
const apiName = String(state.objectDefinition.apiName || '').toLowerCase();
if (!this.isContactDetail(apiName)) {
return state;
}
const updatedFields = { ...(state.extractedFields || {}) };
if (!updatedFields.relatedObjectId && state.context?.recordId) {
updatedFields.relatedObjectId = state.context.recordId;
}
if (!updatedFields.relatedObjectType && state.context?.objectApiName) {
const type = this.toPolymorphicType(state.context.objectApiName);
if (type) {
updatedFields.relatedObjectType = type;
}
}
return {
...state,
extractedFields: updatedFields,
};
}
private toPolymorphicType(objectApiName: string): string | null {
const normalized = objectApiName.toLowerCase();
if (normalized === 'account' || normalized === 'accounts') return 'Account';
if (normalized === 'contact' || normalized === 'contacts') return 'Contact';
return null;
}
private isContactDetail(objectApiName: string | undefined): boolean {
if (!objectApiName) return false;
const normalized = objectApiName.toLowerCase();
return ['contactdetail', 'contact_detail', 'contactdetails', 'contact_details'].includes(
normalized,
);
}
private async resolvePolymorphicRelatedObject(
tenantId: string,
state: AiAssistantState,
): Promise<AiAssistantState> {
if (!state.objectDefinition || !state.extractedFields) return state;
const apiName = String(state.objectDefinition.apiName || '').toLowerCase();
if (!this.isContactDetail(apiName)) {
return state;
}
const provided = state.extractedFields.relatedObjectId;
if (!provided || typeof provided !== 'string' || this.isUuid(provided)) {
return state;
}
const preferredType =
state.extractedFields.relatedObjectType ||
this.toPolymorphicType(state.context?.objectApiName || '');
const candidateTypes = preferredType
? [preferredType, ...['Account', 'Contact'].filter((t) => t !== preferredType)]
: ['Account', 'Contact'];
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
for (const type of candidateTypes) {
const objectApi = type.toLowerCase();
let targetDefinition: any;
try {
targetDefinition = await this.objectService.getObjectDefinition(tenantId, objectApi);
} catch (error) {
continue;
}
const displayField = this.getDisplayFieldForObject(targetDefinition);
const tableName = this.toTableName(
targetDefinition.apiName,
targetDefinition.label,
targetDefinition.pluralLabel,
);
const record = await knex(tableName)
.whereRaw('LOWER(??) = ?', [displayField, provided.toLowerCase()])
.first();
if (record?.id) {
return {
...state,
extractedFields: {
...state.extractedFields,
relatedObjectId: record.id,
relatedObjectType: type,
},
};
}
}
return state;
}
private getDisplayFieldForObject(objectDefinition: any): string {
if (!objectDefinition?.fields) return 'name';
const hasName = objectDefinition.fields.some(
(candidate: any) => candidate.apiName === 'name',
);
if (hasName) return 'name';
const firstText = objectDefinition.fields.find((candidate: any) =>
['STRING', 'TEXT', 'EMAIL'].includes((candidate.type || '').toUpperCase()),
);
return firstText?.apiName || 'id';
}
private extractValueForField(message: string, field: any): string | null {
const label = field.label || field.apiName;
const apiName = field.apiName;
const patterns = [
new RegExp(`${this.escapeRegex(label)}\\s*:\\s*([^\\n;,]+)`, 'i'),
new RegExp(`${this.escapeRegex(apiName)}\\s*:\\s*([^\\n;,]+)`, 'i'),
new RegExp(`set\\s+${this.escapeRegex(label)}\\s+to\\s+([^\\n;,]+)`, 'i'),
new RegExp(`set\\s+${this.escapeRegex(apiName)}\\s+to\\s+([^\\n;,]+)`, 'i'),
];
for (const pattern of patterns) {
const match = message.match(pattern);
if (match?.[1]) {
return match[1].trim();
}
}
return null;
}
private escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
private getFieldLabel(fields: any[], apiName: string): string {
const field = fields.find((candidate) => candidate.apiName === apiName);
return field?.label || apiName;
}
private parseLayoutConfig(layoutConfig: any) {
if (!layoutConfig) return null;
if (typeof layoutConfig === 'string') {
try {
return JSON.parse(layoutConfig);
} catch (error) {
this.logger.warn(`Failed to parse layout config: ${error.message}`);
return null;
}
}
return layoutConfig;
}
private isSystemField(apiName: string): boolean {
return [
'id',
'ownerId',
'created_at',
'updated_at',
'createdAt',
'updatedAt',
'tenantId',
].includes(apiName);
}
private async getOpenAiConfig(tenantId: string): Promise<OpenAIConfig | null> {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const centralPrisma = getCentralPrisma();
const tenant = await centralPrisma.tenant.findUnique({
where: { id: resolvedTenantId },
select: { integrationsConfig: true },
});
let config = tenant?.integrationsConfig
? typeof tenant.integrationsConfig === 'string'
? this.tenantDbService.decryptIntegrationsConfig(tenant.integrationsConfig)
: tenant.integrationsConfig
: null;
// Fallback to environment if tenant config is missing
if (!config?.openai && process.env.OPENAI_API_KEY) {
this.logger.log('Using OPENAI_API_KEY fallback for AI assistant.');
config = {
...(config || {}),
openai: {
apiKey: process.env.OPENAI_API_KEY,
model: process.env.OPENAI_MODEL || this.defaultModel,
},
};
}
if (config?.openai?.apiKey) {
return {
apiKey: config.openai.apiKey,
model: this.defaultModel,
};
}
return null;
}
private normalizeChatModel(model?: string): string {
if (!model) return this.defaultModel;
const lower = model.toLowerCase();
if (
lower.includes('instruct') ||
lower.startsWith('text-') ||
lower.startsWith('davinci') ||
lower.startsWith('curie') ||
lower.includes('realtime')
) {
return this.defaultModel;
}
return model;
}
private combineHistory(history: AiAssistantState['history'], message: string): string {
if (!history || history.length === 0) return message;
const recent = history.slice(-6);
const serialized = recent
.map((entry) => `[${entry.role}] ${entry.text}`)
.join('\n');
return `${serialized}\n[user] ${message}`;
}
private getConversationKey(
tenantId: string,
userId: string,
objectApiName?: string,
): string {
return `${tenantId}:${userId}:${objectApiName || 'global'}`;
}
private pruneConversations() {
const now = Date.now();
for (const [key, value] of this.conversationState.entries()) {
if (now - value.updatedAt > this.conversationTtlMs) {
this.conversationState.delete(key);
}
}
}
private async resolveLookupFields(
tenantId: string,
objectDefinition: any,
extractedFields: Record<string, any>,
): Promise<{
resolvedFields: Record<string, any>;
unresolvedLookups: Array<{
fieldApiName: string;
fieldLabel?: string;
targetLabel?: string;
providedValue: any;
}>;
}> {
if (!extractedFields) {
return { resolvedFields: {}, unresolvedLookups: [] };
}
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
const resolvedFields = { ...extractedFields };
const unresolvedLookups: Array<{
fieldApiName: string;
fieldLabel?: string;
targetLabel?: string;
providedValue: any;
}> = [];
const lookupFields = (objectDefinition.fields || []).filter(
(field: any) => field.type === 'LOOKUP' && field.referenceObject,
);
for (const field of lookupFields) {
const value = extractedFields[field.apiName];
if (value === undefined || value === null) continue;
// Already an ID or object with ID
if (typeof value === 'object' && value.id) {
resolvedFields[field.apiName] = value.id;
continue;
}
if (typeof value === 'string' && this.isUuid(value)) {
resolvedFields[field.apiName] = value;
continue;
}
// Resolve by display field (e.g., name)
const targetApiName = String(field.referenceObject);
let targetDefinition: any = null;
try {
targetDefinition = await this.objectService.getObjectDefinition(tenantId, targetApiName);
} catch (error) {
this.logger.warn(
`Failed to load reference object ${targetApiName} for field ${field.apiName}: ${error.message}`,
);
}
const displayField = targetDefinition ? this.getLookupDisplayField(field, targetDefinition) : 'name';
const tableName = targetDefinition
? this.toTableName(targetDefinition.apiName, targetDefinition.label, targetDefinition.pluralLabel)
: this.toTableName(targetApiName);
const providedValue = typeof value === 'string' ? value.trim() : value;
const record =
providedValue && typeof providedValue === 'string'
? await knex(tableName)
.whereRaw('LOWER(??) = ?', [displayField, providedValue.toLowerCase()])
.first()
: null;
if (record?.id) {
resolvedFields[field.apiName] = record.id;
} else {
unresolvedLookups.push({
fieldApiName: field.apiName,
fieldLabel: field.label,
targetLabel: targetDefinition?.label || targetApiName,
providedValue: value,
});
}
}
return { resolvedFields, unresolvedLookups };
}
private getLookupDisplayField(field: any, targetDefinition: any): string {
const uiMetadata = this.parseUiMetadata(field.uiMetadata || field.ui_metadata);
if (uiMetadata?.relationDisplayField) {
return uiMetadata.relationDisplayField;
}
const hasName = (targetDefinition.fields || []).some(
(candidate: any) => candidate.apiName === 'name',
);
if (hasName) return 'name';
// Fallback to first string-like field
const firstTextField = (targetDefinition.fields || []).find((candidate: any) =>
['STRING', 'TEXT', 'EMAIL'].includes((candidate.type || '').toUpperCase()),
);
return firstTextField?.apiName || 'id';
}
private parseUiMetadata(uiMetadata: any): any {
if (!uiMetadata) return null;
if (typeof uiMetadata === 'object') return uiMetadata;
if (typeof uiMetadata === 'string') {
try {
return JSON.parse(uiMetadata);
} catch (error) {
this.logger.warn(`Failed to parse UI metadata: ${error.message}`);
return null;
}
}
return null;
}
private isUuid(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}
private toTableName(objectApiName: string, objectLabel?: string, pluralLabel?: string): string {
const toSnakePlural = (source: string): string => {
const cleaned = source.replace(/[\s-]+/g, '_');
const snake = cleaned
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/__+/g, '_')
.toLowerCase()
.replace(/^_/, '');
if (snake.endsWith('y')) return `${snake.slice(0, -1)}ies`;
if (snake.endsWith('s')) return snake;
return `${snake}s`;
};
const fromApi = toSnakePlural(objectApiName);
const fromLabel = objectLabel ? toSnakePlural(objectLabel) : null;
const fromPlural = pluralLabel ? toSnakePlural(pluralLabel) : null;
if (fromLabel && fromLabel.includes('_') && !fromApi.includes('_')) {
return fromLabel;
}
if (fromPlural && fromPlural.includes('_') && !fromApi.includes('_')) {
return fromPlural;
}
if (fromLabel && fromLabel !== fromApi) return fromLabel;
if (fromPlural && fromPlural !== fromApi) return fromPlural;
return fromApi;
}
}

View File

@@ -0,0 +1,32 @@
export interface AiChatMessage {
role: 'user' | 'assistant';
text: string;
}
export interface AiChatContext {
objectApiName?: string;
view?: string;
recordId?: string;
route?: string;
}
export interface AiAssistantReply {
reply: string;
action?: 'create_record' | 'collect_fields' | 'clarify';
missingFields?: string[];
record?: any;
}
export interface AiAssistantState {
message: string;
history?: AiChatMessage[];
context: AiChatContext;
objectDefinition?: any;
pageLayout?: any;
extractedFields?: Record<string, any>;
requiredFields?: string[];
missingFields?: string[];
action?: AiAssistantReply['action'];
record?: any;
reply?: string;
}

View File

@@ -0,0 +1,36 @@
import { Type } from 'class-transformer';
import { IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
import { AiChatMessageDto } from './ai-chat.message.dto';
export class AiChatContextDto {
@IsOptional()
@IsString()
objectApiName?: string;
@IsOptional()
@IsString()
view?: string;
@IsOptional()
@IsString()
recordId?: string;
@IsOptional()
@IsString()
route?: string;
}
export class AiChatRequestDto {
@IsString()
@IsNotEmpty()
message: string;
@IsOptional()
@IsObject()
context?: AiChatContextDto;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => AiChatMessageDto)
history?: AiChatMessageDto[];
}

View File

@@ -0,0 +1,10 @@
import { IsIn, IsNotEmpty, IsString } from 'class-validator';
export class AiChatMessageDto {
@IsIn(['user', 'assistant'])
role: 'user' | 'assistant';
@IsString()
@IsNotEmpty()
text: string;
}

View File

@@ -8,6 +8,7 @@ import { ObjectModule } from './object/object.module';
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';
@Module({
imports: [
@@ -22,6 +23,7 @@ import { VoiceModule } from './voice/voice.module';
AppBuilderModule,
PageLayoutModule,
VoiceModule,
AiAssistantModule,
],
})
export class AppModule {}

View File

@@ -8,26 +8,90 @@ import {
} from '@/components/ui/input-group'
import { Separator } from '@/components/ui/separator'
import { ArrowUp } from 'lucide-vue-next'
import { useRoute } from 'vue-router'
import { useApi } from '@/composables/useApi'
const chatInput = ref('')
const messages = ref<{ role: 'user' | 'assistant'; text: string }[]>([])
const sending = ref(false)
const route = useRoute()
const { api } = useApi()
const handleSend = () => {
const buildContext = () => {
const recordId = route.params.recordId ? String(route.params.recordId) : undefined
const viewParam = route.params.view ? String(route.params.view) : undefined
const view = viewParam || (recordId ? (recordId === 'new' ? 'edit' : 'detail') : 'list')
const objectApiName = route.params.objectName
? String(route.params.objectName)
: undefined
return {
objectApiName,
view,
recordId,
route: route.fullPath,
}
}
const handleSend = async () => {
if (!chatInput.value.trim()) return
// TODO: Implement AI chat send functionality
console.log('Sending message:', chatInput.value)
const message = chatInput.value.trim()
messages.value.push({ role: 'user', text: message })
chatInput.value = ''
sending.value = true
try {
const history = messages.value.slice(0, -1).slice(-6)
const response = await api.post('/ai/chat', {
message,
history,
context: buildContext(),
})
messages.value.push({
role: 'assistant',
text: response.reply || 'Let me know what else you need.',
})
} catch (error: any) {
console.error('Failed to send AI chat message:', error)
messages.value.push({
role: 'assistant',
text: error.message || 'Sorry, I ran into an error. Please try again.',
})
} finally {
sending.value = false
}
}
</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">
<div
v-for="(message, index) in messages"
:key="`${message.role}-${index}`"
class="flex"
:class="message.role === 'user' ? 'justify-end' : 'justify-start'"
>
<div
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'"
>
{{ message.text }}
</div>
</div>
<p v-if="messages.length === 0" class="text-sm text-muted-foreground">
Ask the assistant to add records, filter lists, or summarize the page.
</p>
</div>
<InputGroup>
<InputGroupTextarea
v-model="chatInput"
placeholder="Ask, Search or Chat..."
class="min-h-[60px] rounded-lg"
@keydown.enter.exact.prevent="handleSend"
:disabled="sending"
/>
<InputGroupAddon>
<InputGroupText class="ml-auto">
@@ -37,7 +101,7 @@ const handleSend = () => {
<InputGroupButton
variant="default"
class="rounded-full"
:disabled="!chatInput.trim()"
:disabled="!chatInput.trim() || sending"
@click="handleSend"
>
<ArrowUp class="size-4" />