WIP - initial AI assistant chat working creating records
This commit is contained in:
431
backend/package-lock.json
generated
431
backend/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
27
backend/src/ai-assistant/ai-assistant.controller.ts
Normal file
27
backend/src/ai-assistant/ai-assistant.controller.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
13
backend/src/ai-assistant/ai-assistant.module.ts
Normal file
13
backend/src/ai-assistant/ai-assistant.module.ts
Normal 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 {}
|
||||
928
backend/src/ai-assistant/ai-assistant.service.ts
Normal file
928
backend/src/ai-assistant/ai-assistant.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
32
backend/src/ai-assistant/ai-assistant.types.ts
Normal file
32
backend/src/ai-assistant/ai-assistant.types.ts
Normal 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;
|
||||
}
|
||||
36
backend/src/ai-assistant/dto/ai-chat.dto.ts
Normal file
36
backend/src/ai-assistant/dto/ai-chat.dto.ts
Normal 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[];
|
||||
}
|
||||
10
backend/src/ai-assistant/dto/ai-chat.message.dto.ts
Normal file
10
backend/src/ai-assistant/dto/ai-chat.message.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user