diff --git a/backend/package-lock.json b/backend/package-lock.json index e5d76be..a3299e1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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" + } } } } diff --git a/backend/package.json b/backend/package.json index 6f756c1..83e842a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/ai-assistant/ai-assistant.controller.ts b/backend/src/ai-assistant/ai-assistant.controller.ts new file mode 100644 index 0000000..3f88223 --- /dev/null +++ b/backend/src/ai-assistant/ai-assistant.controller.ts @@ -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, + ); + } +} diff --git a/backend/src/ai-assistant/ai-assistant.module.ts b/backend/src/ai-assistant/ai-assistant.module.ts new file mode 100644 index 0000000..f80af6d --- /dev/null +++ b/backend/src/ai-assistant/ai-assistant.module.ts @@ -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 {} diff --git a/backend/src/ai-assistant/ai-assistant.service.ts b/backend/src/ai-assistant/ai-assistant.service.ts new file mode 100644 index 0000000..bb4015c --- /dev/null +++ b/backend/src/ai-assistant/ai-assistant.service.ts @@ -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; 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 { + 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 { + const AssistantState = Annotation.Root({ + message: Annotation(), + history: Annotation(), + context: Annotation(), + objectDefinition: Annotation(), + pageLayout: Annotation(), + extractedFields: Annotation>(), + requiredFields: Annotation(), + missingFields: Annotation(), + action: Annotation(), + record: Annotation(), + reply: Annotation(), + }); + + 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 { + 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 { + 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 { + 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> { + + 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>(); + 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 { + const extracted: Record = {}; + 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, + fieldDefinitions: any[], + message: string, + ): Record { + 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 = {}; + 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, + fieldDefinitions: any[], + ): Record { + if (!fields) return {}; + const apiNames = new Map( + (fieldDefinitions || []).map((f: any) => [f.apiName.toLowerCase(), f.apiName]), + ); + + const result: Record = {}; + 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 { + 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 { + 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, + ): Promise<{ + resolvedFields: Record; + 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; + } +} diff --git a/backend/src/ai-assistant/ai-assistant.types.ts b/backend/src/ai-assistant/ai-assistant.types.ts new file mode 100644 index 0000000..cdb1b3e --- /dev/null +++ b/backend/src/ai-assistant/ai-assistant.types.ts @@ -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; + requiredFields?: string[]; + missingFields?: string[]; + action?: AiAssistantReply['action']; + record?: any; + reply?: string; +} diff --git a/backend/src/ai-assistant/dto/ai-chat.dto.ts b/backend/src/ai-assistant/dto/ai-chat.dto.ts new file mode 100644 index 0000000..6f7f005 --- /dev/null +++ b/backend/src/ai-assistant/dto/ai-chat.dto.ts @@ -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[]; +} diff --git a/backend/src/ai-assistant/dto/ai-chat.message.dto.ts b/backend/src/ai-assistant/dto/ai-chat.message.dto.ts new file mode 100644 index 0000000..70cf0ea --- /dev/null +++ b/backend/src/ai-assistant/dto/ai-chat.message.dto.ts @@ -0,0 +1,10 @@ +import { IsIn, IsNotEmpty, IsString } from 'class-validator'; + +export class AiChatMessageDto { + @IsIn(['user', 'assistant']) + role: 'user' | 'assistant'; + + @IsString() + @IsNotEmpty() + text: string; +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 7b9db0c..00dcef8 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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 {} diff --git a/frontend/components/AIChatBar.vue b/frontend/components/AIChatBar.vue index 0e491b8..c2b5c54 100644 --- a/frontend/components/AIChatBar.vue +++ b/frontend/components/AIChatBar.vue @@ -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 + } }