diff --git a/package-lock.json b/package-lock.json index 6f221b73..83f5a814 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,9 @@ "@fortawesome/free-regular-svg-icons": "^6.4.2", "@fortawesome/free-solid-svg-icons": "^6.4.2", "@fortawesome/react-fontawesome": "^0.2.0", + "@langchain/core": "^0.2.18", + "@langchain/langgraph": "^0.0.31", + "@langchain/openai": "^0.2.5", "@types/lodash": "^4.14.195", "date-fns": "^2.28.0", "html-react-parser": "^5.0.7", @@ -22,7 +25,8 @@ "react-feather": "^2.0.9", "react-fontawesome-svg-icon": "^1.1.2", "recharts": "^2.9.0", - "uuid": "^10.0.0" + "uuid": "^10.0.0", + "zod": "^3.23.8" }, "devDependencies": { "@babel/core": "^7.17.5", @@ -3213,6 +3217,150 @@ "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", "dev": true }, + "node_modules/@langchain/core": { + "version": "0.2.27", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.2.27.tgz", + "integrity": "sha512-QAIlGxXWW7fox1oGmQjEHs1fbPaXOE9CeunmwZl9grFpu1igdkLbKnEJF7fjbVchyJHRB6yzpQ1bwP/S12O4mQ==", + "dependencies": { + "ansi-styles": "^5.0.0", + "camelcase": "6", + "decamelize": "1.2.0", + "js-tiktoken": "^1.0.12", + "langsmith": "~0.1.39", + "mustache": "^4.2.0", + "p-queue": "^6.6.2", + "p-retry": "4", + "uuid": "^10.0.0", + "zod": "^3.22.4", + "zod-to-json-schema": "^3.22.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@langchain/core/node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==" + }, + "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==", + "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==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@langchain/core/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@langchain/core/node_modules/langsmith": { + "version": "0.1.41", + "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.1.41.tgz", + "integrity": "sha512-8R7s/225Pxmv0ipMfd6sqmWVsfHLQivYlQZ0vx5K+ReoknummTenQlVK8gapk3kqRMnzkrouuRHMhWjMR6RgUA==", + "dependencies": { + "@types/uuid": "^9.0.1", + "commander": "^10.0.1", + "p-queue": "^6.6.2", + "p-retry": "4", + "semver": "^7.6.3", + "uuid": "^9.0.0" + }, + "peerDependencies": { + "@langchain/core": "*", + "langchain": "*", + "openai": "*" + }, + "peerDependenciesMeta": { + "@langchain/core": { + "optional": true + }, + "langchain": { + "optional": true + }, + "openai": { + "optional": true + } + } + }, + "node_modules/@langchain/core/node_modules/langsmith/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@langchain/core/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@langchain/langgraph": { + "version": "0.0.31", + "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-0.0.31.tgz", + "integrity": "sha512-f5QMSLy/RnLktsqnNm2mq8gp1xplHwQf87XIPVO0IYuumOJiafx5lE7ahPO+fVmCzAz6LxcsVocvD0JqxXR/2w==", + "dependencies": { + "@langchain/core": ">=0.2.18 <0.3.0", + "uuid": "^10.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "better-sqlite3": "^9.5.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + } + } + }, + "node_modules/@langchain/openai": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-0.2.7.tgz", + "integrity": "sha512-f2XDXbExJf4SYsy17QSiq0YY/UWJXhJwoiS8uRi/gBa20zBQ8+bBFRnb9vPdLkOkGiaTy+yXZVFro3a9iW2r3w==", + "dependencies": { + "@langchain/core": ">=0.2.26 <0.3.0", + "js-tiktoken": "^1.0.12", + "openai": "^4.55.0", + "zod": "^3.22.4", + "zod-to-json-schema": "^3.22.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@mdx-js/react": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-2.3.0.tgz", @@ -14927,16 +15075,17 @@ "dev": true }, "node_modules/@types/node": { - "version": "18.7.18", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.18.tgz", - "integrity": "sha512-m+6nTEOadJZuTPkKR/SYK3A2d7FZrgElol9UP1Kae90VVU4a6mxnPuLiIW1m4Cq4gZ/nWb9GrdVXJCoCazDAbg==", - "dev": true + "version": "18.19.45", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.45.tgz", + "integrity": "sha512-VZxPKNNhjKmaC1SUYowuXSRSMGyQGmQjvvA1xE4QZ0xce2kLtEhPDS+kqpCPBZYgqblCLQ2DAjSzmgCM5auvhA==", + "dependencies": { + "undici-types": "~5.26.4" + } }, "node_modules/@types/node-fetch": { "version": "2.6.4", "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.4.tgz", "integrity": "sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==", - "dev": true, "dependencies": { "@types/node": "*", "form-data": "^3.0.0" @@ -14994,6 +15143,11 @@ "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "dev": true }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" + }, "node_modules/@types/scheduler": { "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", @@ -15264,6 +15418,17 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -15328,6 +15493,17 @@ "node": ">= 14" } }, + "node_modules/agentkeepalive": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz", + "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -15541,8 +15717,7 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/available-typed-arrays": { "version": "1.0.5", @@ -15862,7 +16037,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", @@ -16473,7 +16647,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -17078,6 +17251,14 @@ } } }, + "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==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -17213,7 +17394,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -17780,6 +17960,14 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -18404,7 +18592,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -18414,6 +18601,23 @@ "node": ">= 6" } }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -19104,6 +19308,14 @@ "node": ">=10.17.0" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -20103,6 +20315,14 @@ "node": ">=8" } }, + "node_modules/js-tiktoken": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.14.tgz", + "integrity": "sha512-Pk3l3WOgM9joguZY2k52+jH82RtABRgB5RdGFZNUGbOKGMVlNmafcPA3b0ITcCZPu1L9UclP1tne6aw7ZI4Myg==", + "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", @@ -20795,7 +21015,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -20804,7 +21023,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -20921,8 +21139,15 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "bin": { + "mustache": "bin/mustache" + } }, "node_modules/nanoid": { "version": "3.3.6", @@ -20985,11 +21210,28 @@ "node": ">= 0.10.5" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.6.11", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.11.tgz", "integrity": "sha512-4I6pdBY1EthSqDmJkiNk3JIT8cswwR9nfeW/cPdUagJYEQG7R95WRH74wpz7ma8Gh/9dI9FP+OU+0E4FvtA55w==", - "dev": true, "dependencies": { "whatwg-url": "^5.0.0" }, @@ -21217,6 +21459,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openai": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.56.0.tgz", + "integrity": "sha512-zcag97+3bG890MNNa0DQD9dGmmTWL8unJdNkulZzWRXrl+QeD+YkBI4H58rJcwErxqGK6a0jVPZ4ReJjhDGcmw==", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/ora": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", @@ -21314,7 +21581,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", - "dev": true, "engines": { "node": ">=4" } @@ -21368,7 +21634,6 @@ "version": "6.6.2", "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", - "dev": true, "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" @@ -21380,11 +21645,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, "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==", - "dev": true, "dependencies": { "p-finally": "^1.0.0" }, @@ -23128,6 +23404,14 @@ "node": ">=8" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -24356,8 +24640,7 @@ "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, "node_modules/ts-dedent": { "version": "2.2.0", @@ -24436,6 +24719,11 @@ "node": ">=0.8.0" } }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", @@ -24820,11 +25108,18 @@ "defaults": "^1.0.3" } }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "engines": { + "node": ">= 14" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "node_modules/webpack": { "version": "5.83.1", @@ -25023,7 +25318,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" @@ -25291,6 +25585,22 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.23.2.tgz", + "integrity": "sha512-uSt90Gzc/tUfyNqxnjlfBs8W6WSGpNBv0rVsNxP/BVSMHMKGdthPYff4xtCHYloJGM0CFxFsb3NbC0eqPhfImw==", + "peerDependencies": { + "zod": "^3.23.3" + } } } } diff --git a/package.json b/package.json index a7cbaa3c..6e337407 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,9 @@ "@fortawesome/free-regular-svg-icons": "^6.4.2", "@fortawesome/free-solid-svg-icons": "^6.4.2", "@fortawesome/react-fontawesome": "^0.2.0", + "@langchain/core": "^0.2.18", + "@langchain/langgraph": "^0.0.31", + "@langchain/openai": "^0.2.5", "@types/lodash": "^4.14.195", "date-fns": "^2.28.0", "html-react-parser": "^5.0.7", @@ -72,6 +75,7 @@ "react-feather": "^2.0.9", "react-fontawesome-svg-icon": "^1.1.2", "recharts": "^2.9.0", + "zod": "^3.23.8", "uuid": "^10.0.0" }, "peerDependencies": { diff --git a/rollup.config.js b/rollup.config.js index ce6eb945..bb604929 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -10,7 +10,7 @@ import { terser } from "rollup-plugin-terser"; import peerDepsExternal from 'rollup-plugin-peer-deps-external'; import analyze from 'rollup-plugin-analyzer'; -const limitBytes = 5e6 +const limitBytes = 6e6; const onAnalysis = ({ bundleSize }) => { if (bundleSize < limitBytes) return @@ -25,19 +25,19 @@ export default [ { file: packageJson.main, format: "cjs", - sourcemap: true, + sourcemap: true }, { file: packageJson.module, format: "esm", - sourcemap: true, + sourcemap: true }, ], plugins: [ peerDepsExternal(), resolve(), commonjs(), - typescript({ tsconfig: "./tsconfig.json", sourceMap: false }), + typescript({ tsconfig: "./tsconfig.json", sourceMap: true }), postcss(), terser(), image(), diff --git a/src/components/container/AIAssistant/AIAssistant.stories.tsx b/src/components/container/AIAssistant/AIAssistant.stories.tsx new file mode 100644 index 00000000..e79e3aaf --- /dev/null +++ b/src/components/container/AIAssistant/AIAssistant.stories.tsx @@ -0,0 +1,101 @@ +import React from "react"; +import { Global, css } from '@emotion/react'; +import Layout from "../../presentational/Layout"; +import AIAssistant, { AIAssistantProps } from "./AIAssistant"; +import { MyDataHelpsTools } from "../../../helpers/AIAssistant"; + +export default { + title: 'Container/AIAssistant', + component: AIAssistant, + parameters: { layout: 'fullscreen' }, + argTypes: { + colorScheme: { + name: 'color scheme', + control: 'radio', + options: ['auto', 'light', 'dark'] + }, + debug: { + control: 'boolean', + description: 'If turned on the assistant prints what tools it is trying to call and the corresponding parameters.', + defaultValue: { + summary: false + } + }, + additionalInstructions: { + control: 'text', + description: 'Additional system instructions to be passed to the assistant.', + defaultValue: { + summary: "" + } + }, + appendTools: { + control: false, + description: 'If turned on the tools passed in the tools prop will be appended to the default tools.', + defaultValue: { + summary: true + } + }, + tools: { + control: 'multi-select', + options: ['persistParticipantInfo', 'queryAppleHealthActivitySummaries', 'queryAppleHealthWorkouts', 'getAllDailyDataTypes', + 'queryDailySleep', 'queryDeviceDataV2Aggregate', 'queryDeviceDataV2', 'queryNotifications', + 'querySurveyAnswers', 'queryDailyData', 'getAllDailyDataTypes', 'getEhrNewsFeedPage'], + mapping: { + persistParticipantInfo: new MyDataHelpsTools.PersistParticipantInfoTool(), + queryAppleHealthActivitySummaries: new MyDataHelpsTools.QueryAppleHealthActivitySummariesTool(), + queryAppleHealthWorkouts: new MyDataHelpsTools.QueryAppleHealthWorkoutsTool(), + queryDailySleep: new MyDataHelpsTools.QueryDailySleepTool(), + queryDeviceDataV2Aggregate: new MyDataHelpsTools.QueryDeviceDataV2AggregateTool(), + queryDeviceDataV2: new MyDataHelpsTools.QueryDeviceDataV2Tool(), + queryNotifications: new MyDataHelpsTools.QueryNotificationsTool(), + querySurveyAnswers: new MyDataHelpsTools.QuerySurveyAnswersTool(), + queryDailyDataTool: new MyDataHelpsTools.QueryDailyDataTool(), + getAllDailyDataTypes: new MyDataHelpsTools.GetAllDailyDataTypesTool(), + getEhrNewsFeedPage: new MyDataHelpsTools.GetEhrNewsFeedPageTool(), + getDeviceDataV2AllDataTypes: new MyDataHelpsTools.GetDeviceDataV2AllDataTypesTool() + } + } + } +}; + +interface AIAssistantStoryArgs extends AIAssistantProps { + colorScheme: 'auto' | 'light' | 'dark'; +} + +const render = (args: AIAssistantStoryArgs) => { + return + + + +}; + +export const Default = { + args: { + colorScheme: "auto", + debug: false + }, + render: render +}; + +export const Debug = { + args: { + debug: true + }, + render: render +}; diff --git a/src/components/container/AIAssistant/AIAssistant.tsx b/src/components/container/AIAssistant/AIAssistant.tsx new file mode 100644 index 00000000..4dbc18ce --- /dev/null +++ b/src/components/container/AIAssistant/AIAssistant.tsx @@ -0,0 +1,135 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { FontAwesomeSvgIcon } from 'react-fontawesome-svg-icon'; +import { faLightbulb } from '@fortawesome/free-regular-svg-icons/faLightbulb'; +import { StreamEvent } from '@langchain/core/tracers/log_stream'; +import { AIMessageChunk } from '@langchain/core/messages'; +import { StructuredTool } from '@langchain/core/tools'; +import MyDataHelps from '@careevolution/mydatahelps-js'; + +import { MyDataHelpsAIAssistant } from '../../../helpers/AIAssistant/AIAssistant'; +import language from '../../../helpers/language'; +import Chat from '../../presentational/Chat'; + +import '@fortawesome/fontawesome-svg-core/styles.css'; + +export interface AIAssistantProps { + innerRef?: React.Ref; + previewState?: "default"; + debug: boolean; + additionalInstructions?: string; + tools?: StructuredTool[]; + appendTools?: boolean; + baseUrl?: string; +} + +export type AIAssistantMessageType = "user" | "ai"; + +export interface AIAssistantMessage { + type: AIAssistantMessageType; + content: string; + runId?: string; +} + +export default function (props: AIAssistantProps) { + + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(""); + const [inputDisabled, setInputDisabled] = useState(false); + + let lastAIMessage = ""; + + const assistantRef = useRef(); + + useEffect(() => { + if (assistantRef.current === undefined) { + assistantRef.current = new MyDataHelpsAIAssistant(props.baseUrl, props.additionalInstructions, props.tools, props.appendTools); + } + }, []); + + const addUserMessage = async function (newMessage: string) { + + setMessages(prevMessages => [...prevMessages, { type: 'user', content: newMessage }]); + setInputDisabled(true); + + MyDataHelps.trackCustomEvent({ + eventType: "ai-assistant-message", + properties: { + type: "user", + body: newMessage + } + }); + + await assistantRef.current?.ask(newMessage, function (streamEvent: StreamEvent) { + + const [kind, type] = streamEvent.event.split("_").slice(1); + + if (type === "stream" && kind !== "chain") { + const chunk = streamEvent.data?.chunk; + let msg = chunk.message as AIMessageChunk; + + if (msg.content && typeof msg.content === "string") { + addMessageChunk(streamEvent.run_id, msg.content); + } + else if (props.debug && msg.tool_call_chunks && msg.tool_call_chunks.length > 0) { + if (msg.tool_call_chunks[0].args) { + addMessageChunk(streamEvent.run_id, msg.tool_call_chunks[0].args); + } + else if (msg.tool_call_chunks[0].name) { + addMessageChunk(streamEvent.run_id, msg.tool_call_chunks[0].name + " "); + } + } + } + + if (kind === "tool") { + if (type === "start") { + setLoading(language('ai-assistant-loading')); + } + else if (type === "end") { + setLoading(""); + } + } + + if (kind === "llm" && type === "start") { + lastAIMessage = ""; + } + + if (kind === "llm" && type === "end") { + + MyDataHelps.trackCustomEvent({ + eventType: "ai-assistant-message", + properties: { + type: "ai", + body: lastAIMessage + } + }); + + setInputDisabled(false); + } + }); + } + + const addMessageChunk = function (runId: string, message: string) { + setMessages(prevMessages => { + let existingMessage = prevMessages.find((msg) => msg.runId === runId); + if (existingMessage) { + const updatedMessage = { ...existingMessage, content: existingMessage.content + message }; + return prevMessages.map(msg => msg.runId === runId ? updatedMessage : msg); + } + else { + return [...prevMessages, { type: 'ai', content: message, runId: runId }]; + } + }); + + lastAIMessage += message; + } + + return <> + {messages && { + return { + icon: msg.type === "ai" ? : undefined, + content: msg.content, + type: msg.type === "user" ? "sent" : "received" + } + })} onSendMessage={addUserMessage} loading={loading} inputDisabled={inputDisabled} />} + +} diff --git a/src/components/container/AIAssistant/index.ts b/src/components/container/AIAssistant/index.ts new file mode 100644 index 00000000..466dd4c6 --- /dev/null +++ b/src/components/container/AIAssistant/index.ts @@ -0,0 +1 @@ +export { default } from "./AIAssistant"; diff --git a/src/components/container/index.ts b/src/components/container/index.ts index 0119f262..5ed45375 100644 --- a/src/components/container/index.ts +++ b/src/components/container/index.ts @@ -51,3 +51,4 @@ export { default as TermInformation, TermInformationReference } from "./TermInfo export { default as ViewEhr } from "./ViewEhr" export { default as InboxItemList } from "./InboxItemList" export { default as InboxItemListCoordinator } from "./InboxItemListCoordinator" +export { default as AIAssistant } from "./AIAssistant" diff --git a/src/components/presentational/Chat/Chat.css b/src/components/presentational/Chat/Chat.css new file mode 100644 index 00000000..3b0f4dbe --- /dev/null +++ b/src/components/presentational/Chat/Chat.css @@ -0,0 +1,112 @@ +.mdhui-chat { + background: var(--mdhui-background-color-0); + font-size: 0.88em; + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + min-height: 150px; +} + +.mdhui-chat .mdhui-chat-log { + overflow: hidden; + flex: 1 1 0%; +} + +.mdhui-chat .mdhui-chat-messages-scroll-wrapper { + overflow-y: auto; + height: 100%; +} + +.mdhui-chat .mdhui-chat-messages { + display: flex; + flex-direction: column; + justify-content: flex-end; + min-height: 100%; + width: 100%; +} + +.mdhui-chat p, +.mdhui-chat ul { + margin: .25rem 0; + word-break: break-word; +} + +.mdhui-chat input, +.mdhui-chat button { + min-height: var(--mdhui-touch); + min-width: var(--mdhui-touch); + border: 0; +} + +.mdhui-chat .mdhui-chat-send-button { + position: absolute; + right: 4px; + cursor: pointer; + text-align: center; +} + +.mdhui-chat .mdhui-chat-send-button:disabled { + cursor: default; + color: var(--mdhui-text-color-3); +} + +.mdhui-chat input { + flex: 1; + font-size: 1em; + color: var(--mdhui-text-color-1); + padding: var(--mdhui-padding-md) var(--mdhui-touch) var(--mdhui-padding-md) var(--mdhui-padding-md); + box-sizing: border-box; + outline-offset: -1px; + background: var(--mdhui-background-color-0); +} + +.mdhui-chat .mdhui-chat-input-group { + position: relative; + display: flex; + align-items: center; + width: 100%; + background: #fff; + border-top: 1px solid var(--mdhui-border-color-1); +} + +.mdhui-chat .mdhui-chat-message, +.mdhui-chat .mdhui-chat-message-loading { + padding: var(--mdhui-padding-xxs) var(--mdhui-padding-md); + position: relative; +} + +.mdhui-chat .mdhui-chat-received-message-row { + display: flex; + flex-direction: row; + align-items: flex-start; +} + +.mdhui-chat .mdhui-chat-sent-message-row { + display: flex; + flex-direction: column; + align-items: flex-end; + padding-right: var(--mdhui-padding-md); +} + +.mdhui-chat .mdhui-chat-sent-message { + background: var(--mdhui-background-color-1); + max-width: 70%; + padding: var(--mdhui-padding-xs) var(--mdhui-padding-sm); + border-radius: var(--mdhui-card-border-radius); +} + +.mdhui-chat .mdhui-chat-message .svg-inline--fa, +.mdhui-chat .mdhui-chat-message-loading .svg-inline--fa { + color: var(--mdhui-text-color-3); + margin-top: .35rem; + margin-right: .25rem; +} + +.mdhui-chat .mdhui-chat-message-loading .svg-inline--fa { + font-weight: 700; +} + +.mdhui-chat input:focus-visible { + outline: 1px solid var(--mdhui-color-primary); +} diff --git a/src/components/presentational/Chat/Chat.stories.tsx b/src/components/presentational/Chat/Chat.stories.tsx new file mode 100644 index 00000000..9850030d --- /dev/null +++ b/src/components/presentational/Chat/Chat.stories.tsx @@ -0,0 +1,101 @@ +import React from "react"; +import { FontAwesomeSvgIcon } from 'react-fontawesome-svg-icon'; +import { faPaperPlane } from '@fortawesome/free-solid-svg-icons/faPaperPlane'; +import { faUser } from '@fortawesome/free-solid-svg-icons/faUser'; +import { faGear } from '@fortawesome/free-solid-svg-icons/faGear'; +import { Global, css } from '@emotion/react'; +import Layout from "../../presentational/Layout"; +import Chat, { ChatProps } from "./Chat"; + +export default { + title: 'Presentational/Chat', + component: Chat, + parameters: { layout: 'fullscreen' }, + argTypes: { + colorScheme: { + name: 'color scheme', + control: 'radio', + options: ['auto', 'light', 'dark'] + }, + loading: { + control: 'text', + description: 'Loading text to display when a backend operation is executing.', + defaultValue: { + summary: '' + } + }, + messages: { + control: 'object', + description: 'Messages to display in the chat.', + defaultValue: { + summary: [] + } + } + } +}; + +interface ChatStoryArgs extends ChatProps { + colorScheme: 'auto' | 'light' | 'dark'; +} + +const render = (args: ChatStoryArgs) => { + return + + + +}; + +export const Default = { + args: { + colorScheme: "auto", + messages: [{ + icon: , + content: "Hi!", + type: "sent" + }, + { + icon: , + content: "Hello! How may I assist you today?", + type: "received" + }, + { + icon: , + content: "How has my sleep been in the past 7 days?", + type: "sent" + }, + { + icon: , + content: "Your sleep has been decent.", + type: "received" + }] + }, + render: render +}; + +export const Loading = { + args: { + loading: "Calling the mothership...", + messages: [{ + icon: , + content: "How has my sleep been in the past 7 days?", + type: "sent" + }] + }, + render: render +}; diff --git a/src/components/presentational/Chat/Chat.tsx b/src/components/presentational/Chat/Chat.tsx new file mode 100644 index 00000000..3c376101 --- /dev/null +++ b/src/components/presentational/Chat/Chat.tsx @@ -0,0 +1,94 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { FontAwesomeSvgIcon } from 'react-fontawesome-svg-icon'; +import { faPaperPlane } from '@fortawesome/free-solid-svg-icons/faPaperPlane'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons/faSpinner'; +import MarkdownIt from 'markdown-it'; +import parse from 'html-react-parser'; + +import UnstyledButton from '../UnstyledButton'; + +import './Chat.css'; + +export type ChatMessageType = "sent" | "received"; + +export interface ChatProps { + innerRef?: React.Ref; + messages: ChatMessage[]; + onSendMessage: (newMessage: string) => void; + loading?: string; + inputDisabled?: boolean; +} + +export interface ChatMessage { + icon?: React.JSX.Element; + content: string; + type: ChatMessageType; +} + +const md = new MarkdownIt(); + +export default function (props: ChatProps) { + + const [currentUserMessage, setCurrentUserMessage] = useState(''); + const logRef = useRef(null); + + useEffect(() => { + if (logRef.current) { + logRef.current.scrollTop = logRef.current.scrollHeight; + } + }, [props.messages]); + + const sendMessage = async function () { + let newMessage = currentUserMessage; + props.onSendMessage(newMessage); + setCurrentUserMessage(''); + } + + return ( +
+
+
+
+ {props.messages.map((message, index) => { + if (message.type === "sent") { + return
+
+ {parse(md.render(message.content))} +
+
+ } + else { + return
+ {message.icon} +
{parse(md.render(message.content))}
+
+ } + })} + {props.loading &&
+ {props.loading} +
} +
+
+
+
+
+ setCurrentUserMessage(e.target.value)} + onKeyDown={(e) => { + if (props.inputDisabled || !currentUserMessage) return; + + if (e.key === 'Enter') { + sendMessage(); + } + }} + /> + + + +
+
+
+ ); +} diff --git a/src/components/presentational/Chat/index.ts b/src/components/presentational/Chat/index.ts new file mode 100644 index 00000000..308d054f --- /dev/null +++ b/src/components/presentational/Chat/index.ts @@ -0,0 +1 @@ +export { default } from "./Chat"; diff --git a/src/components/presentational/Layout/Layout.css b/src/components/presentational/Layout/Layout.css index c728dc6c..99abbe06 100644 --- a/src/components/presentational/Layout/Layout.css +++ b/src/components/presentational/Layout/Layout.css @@ -1,5 +1,11 @@ .mdhui-layout { - padding-bottom: env(safe-area-inset-bottom); color: var(--mdhui-text-color-1); display: flow-root; -} \ No newline at end of file +} + +.mdhui-layout-flex { + display: flex; + flex-direction: column; + height: 100%; + padding-top: calc(env(safe-area-inset-top, 0)); +} diff --git a/src/components/presentational/Layout/Layout.tsx b/src/components/presentational/Layout/Layout.tsx index a19844d2..b4e9b0fa 100644 --- a/src/components/presentational/Layout/Layout.tsx +++ b/src/components/presentational/Layout/Layout.tsx @@ -13,6 +13,7 @@ export interface LayoutProps { className?: string; noGlobalStyles?: boolean; colorScheme?: "light" | "dark" | "auto"; + flex?: boolean; /** * @deprecated */ @@ -32,6 +33,9 @@ export default function (props: LayoutProps) { if (props.className) { className += " " + props.className; } + if (props.flex) { + className += " mdhui-layout-flex"; + } let colorScheme: "light" | "dark" = "light"; if (props.colorScheme === "auto" && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { @@ -52,6 +56,8 @@ export default function (props: LayoutProps) { MyDataHelps.setStatusBarStyle(props.statusBarStyle); } + let paddingBottom = props.flex ? "0" : "env(safe-area-inset-bottom)"; + return ( @@ -71,7 +77,7 @@ export default function (props: LayoutProps) { {!props.noGlobalStyles && } -
+
{props.stylesheetPath && } diff --git a/src/components/presentational/index.ts b/src/components/presentational/index.ts index aa5e1b92..9d12d021 100644 --- a/src/components/presentational/index.ts +++ b/src/components/presentational/index.ts @@ -6,6 +6,7 @@ export { default as Calendar } from "./Calendar" export { default as CalendarDay, CalendarDayStateConfiguration } from "./CalendarDay" export { default as Card } from "./Card" export { default as CardTitle } from "./CardTitle" +export { default as Chat } from "./Chat" export { default as DateRangeCoordinator, DateRangeContext } from "./DateRangeCoordinator" export { default as DateRangeNavigator } from "./DateRangeNavigator" export { default as DateRangeTitle } from "./DateRangeTitle" diff --git a/src/components/view/BlankView/BlankView.tsx b/src/components/view/BlankView/BlankView.tsx index 237efe72..41aaf321 100644 --- a/src/components/view/BlankView/BlankView.tsx +++ b/src/components/view/BlankView/BlankView.tsx @@ -14,11 +14,12 @@ export interface BlankViewProps { titleColor?: ColorDefinition; subtitleColor?: ColorDefinition; navigationBarButtonColor?: ColorDefinition; + flexLayout?: boolean; } export default function (props: BlankViewProps) { return ( - + {(props.showBackButton || props.showCloseButton) && - + {!props.flexLayout && } } diff --git a/src/helpers/AIAssistant/AIAssistant.ts b/src/helpers/AIAssistant/AIAssistant.ts new file mode 100644 index 00000000..d12f72b3 --- /dev/null +++ b/src/helpers/AIAssistant/AIAssistant.ts @@ -0,0 +1,180 @@ +import { AIMessage, BaseMessage, HumanMessage } from "@langchain/core/messages"; +import { ChatOpenAI } from "@langchain/openai"; +import { END, StateGraph, StateGraphArgs, START, MemorySaver, CompiledStateGraph, messagesStateReducer } from "@langchain/langgraph/web"; +import { ChatPromptTemplate, MessagesPlaceholder, SystemMessagePromptTemplate } from "@langchain/core/prompts"; +import { ToolNode } from "@langchain/langgraph/prebuilt"; +import { RunnableConfig } from "@langchain/core/runnables"; +import { StructuredTool } from "@langchain/core/tools"; + +import MyDataHelps, { Guid, ParticipantInfo, ProjectInfo } from "@careevolution/mydatahelps-js"; + +import { + PersistParticipantInfoTool, + QueryAppleHealthActivitySummariesTool, + QueryAppleHealthWorkoutsTool, + QueryDailySleepTool, + QueryDeviceDataV2AggregateTool, + QueryDeviceDataV2Tool, + QueryNotificationsTool, + QuerySurveyAnswersTool, + QueryDailyDataTool, + GetAllDailyDataTypesTool, + GetEhrNewsFeedPageTool, + GetDeviceDataV2AllDataTypesTool +} from "./Tools"; + + +export interface AIAssistantState { + messages: BaseMessage[]; + participantInfo: string; + projectInfo: string; +} + +export class MyDataHelpsAIAssistant { + + constructor(baseUrl: string = "", additionalInstructions: string = "", tools: StructuredTool[] = [], appendTools: boolean = true) { + this.baseUrl = baseUrl || "https://xwk5dezh5vnf4in2avp6dxswym0tmgxg.lambda-url.us-east-1.on.aws/"; + this.additionalInstructions = additionalInstructions; + this.tools = tools.length ? (appendTools ? this.defaultTools.concat(tools) : tools) : this.defaultTools; + } + + async ask(userMessage: string, onEvent: (event: any) => void) { + + if (!this.initialized) { + let participantInfo = await MyDataHelps.getParticipantInfo(); + let projectInfo = await MyDataHelps.getProjectInfo(); + this.initialize(participantInfo, projectInfo); + } + + let config = { configurable: { thread_id: this.participantId } }; + let inputs = { + messages: [ + new HumanMessage(userMessage) + ] + }; + for await ( + const event of await this.graph.streamEvents(inputs, { + ...config, + streamMode: "values", + version: "v1" + }) + ) { + onEvent(event); + } + } + + private initialize(participantInfo: ParticipantInfo, projectInfo: ProjectInfo) { + + const toolNode = new ToolNode<{ messages: BaseMessage[] }>(this.tools); + + const graphState: StateGraphArgs["channels"] = { + messages: { + reducer: messagesStateReducer + }, + participantInfo: { + value: (x: string, y: string) => y ? y : x, + default: () => "{}" + }, + projectInfo: { + value: (x: string, y: string) => y ? y : x, + default: () => "{}" + } + }; + + const boundModel = new ChatOpenAI({ + model: "gpt-4o", + temperature: 0, + apiKey: MyDataHelps.token.access_token + }, { + baseURL: this.baseUrl + }).bindTools(this.tools); + + const promptTemplate = ChatPromptTemplate.fromMessages([ + SystemMessagePromptTemplate.fromTemplate(` + You are a health and wellness data assistant. Your purpose is to help users understand their health and wearable data, + which includes providing simple summaries and highlighting insights and connections between data. The tone should + be clear and friendly. You are not a coach, so should not give advice. You should not be disparaging or discouraging, + just objectively share information. + + You can encourage the user to ask more questions and even suggest additional follow-up questions that might be relevant. + + If the user asks for some data, and you query it with a particular tool, but the returned data does not sufficiently + answer the user's question, for example, if the user asks for their last 3 LDL values, and you query the getEhrNewsFeedPage + tool and it returns only the last 2 LDL values and a nextPageID, then query the same tool again while passing the nextPageID as the + pageID parameter to fetch additional data. Continue this process until you have all the data that the user has asked for, up to 5 iterations. + + User information: {participantInfo} + + Project information: {projectInfo} + + The time right now is ${new Date().toISOString()}. + + ${this.additionalInstructions} + `), + new MessagesPlaceholder("messages") + ]); + + const routeMessage = (state: AIAssistantState) => { + const { messages } = state; + const lastMessage = messages[messages.length - 1] as AIMessage; + if (!lastMessage.tool_calls?.length) { + return END; + } + return "tools"; + }; + + const callModel = async (state: AIAssistantState, config?: RunnableConfig) => { + const { messages, participantInfo, projectInfo } = state; + const chain = promptTemplate.pipe(boundModel); + const response = await chain.invoke({ messages, participantInfo, projectInfo }, config); + return { messages: [response] }; + }; + + const setContextInfo = async () => { + return { + participantInfo: JSON.stringify(participantInfo), + projectInfo: JSON.stringify(projectInfo) + }; + }; + + const workflow = new StateGraph({ + channels: graphState + }) + .addNode("setContextInfo", setContextInfo) + .addNode("agent", callModel) + .addNode("tools", toolNode) + .addEdge(START, "setContextInfo") + .addEdge("setContextInfo", "agent") + .addConditionalEdges("agent", routeMessage) + .addEdge("tools", "agent"); + + const memory = new MemorySaver(); + + this.graph = workflow.compile({ checkpointer: memory }); + + this.initialized = true; + this.participantId = participantInfo.participantID; + } + + private initialized = false; + private graph!: CompiledStateGraph, "agent" | "tools" | "setContextInfo" | typeof START>; + private baseUrl: string; + private participantId!: Guid; + private additionalInstructions: string; + private tools: StructuredTool[]; + + private defaultTools: StructuredTool[] = [ + new QueryDailySleepTool(), + new PersistParticipantInfoTool(), + new QueryDeviceDataV2Tool(), + new QueryDeviceDataV2AggregateTool(), + new QueryNotificationsTool(), + new QueryAppleHealthWorkoutsTool(), + new QueryAppleHealthActivitySummariesTool(), + new QuerySurveyAnswersTool(), + new QueryDailyDataTool(), + new GetAllDailyDataTypesTool(), + new GetEhrNewsFeedPageTool(), + new GetDeviceDataV2AllDataTypesTool() + ]; +} diff --git a/src/helpers/AIAssistant/Tools.ts b/src/helpers/AIAssistant/Tools.ts new file mode 100644 index 00000000..8e078f18 --- /dev/null +++ b/src/helpers/AIAssistant/Tools.ts @@ -0,0 +1,280 @@ +import { StructuredTool } from "@langchain/core/tools"; +import { z } from "zod"; +import MyDataHelps, { DeviceDataV2AggregateQuery, DeviceDataV2Query, ParticipantDemographics, StringMap } from "@careevolution/mydatahelps-js"; +import { queryDailyData, getAllDailyDataTypes } from "../query-daily-data"; +import { getNewsFeedPage } from "../news-feed/data"; + +const deviceDataV2QuerySchema = z.object({ + namespace: z.enum(["Fitbit", "AppleHealth", "Garmin", "Dexcom", "HealthConnect"]) + .describe("The namespace of the device data, representing the manufacturer of the devices used to collect the data."), + + type: z.string().describe("The device data type is specific to the namespace. Example data types for AppleHealth are Steps, Heart Rate."), + + observedAfter: z.string().optional() + .describe(`The start of the date range for the query. This is a datetime in the participant's local timezone, passed without the timezone offset. + This is exclusive. For example, if you want to query data for 11/26/2024, you would pass in "observedAfter": "2024-11-26T00:00:00". An event + that happened at 11/26/2024 00:00:00 would not be included in the results.`), + + observedBefore: z.string().optional() + .describe(`The end of the date range for the query. This is a datetime in the participant's local timezone, passed without the timezone offset. + This is exclusive. For example, if you want to query data for up to 11/26/2024, you would pass in "observedBefore": "2024-11-26T00:00:00". An event + that happened at 11/26/2024 00:00:00 would not be included in the results.`), + + dataSource: z.record(z.string(), z.string()).optional() + .describe(`These can be used to restrict the returned results to data coming from a specific device only. For example, if I + wanted to only query data from my iPhone, I could pass in "dataSource": { "sourceName": "Mike's iPhone" }. If I wanted to only query data + from my Oura ring, I could pass in "dataSource": { "sourceName": "Oura" }. You can prompt the user for the device name if it is unclear what that + is from the user's question.`), + + properties: z.record(z.string(), z.string()).optional() + .describe('Filters to apply to the properties of the data points.') +}); + +export class PersistParticipantInfoTool extends StructuredTool { + schema = z.object({ + demographics: z.object({ + email: z.string().optional().describe("The email address of the participant."), + mobilePhone: z.string().optional().describe("The mobile phone number of the participant."), + firstName: z.string().optional().describe("The first name of the participant."), + middleName: z.string().optional().describe("The middle name of the participant."), + lastName: z.string().optional().describe("The last name of the participant."), + dateOfBirth: z.string().optional().describe("The date of birth of the participant."), + gender: z.string().optional().describe("The gender of the participant."), + preferredLanguage: z.string().optional().describe("The preferred language of the participant."), + street1: z.string().optional().describe("The first line of the participant's street address."), + street2: z.string().optional().describe("The second line of the participant's street address."), + city: z.string().optional().describe("The city of the participant."), + state: z.string().optional().describe("The state of the participant."), + postalCode: z.string().optional().describe("The postal code of the participant."), + unsubscribedFromEmails: z.string().optional().describe("Whether the participant is unsubscribed from email messages."), + unsubscribedFromSms: z.string().optional().describe("Whether the participant is unsubscribed from SMS messages.") + }).optional().describe("Demographic information about the participant."), + customFields: z.record(z.string(), z.string()) + .optional().describe("Custom fields where dynamic information can be saved for the participant.") + }); + + name = "persistParticipantInfo"; + + description = "Can be used to save data about the current participant, including dynamic information in the participant's custom fields."; + + async _call(input: z.infer) { + let response = await MyDataHelps.persistParticipantInfo(input.demographics as Partial, input.customFields as StringMap); + + return JSON.stringify(response); + } +} + +const appleHealthQuerySchema = z.object({ + endDate: z.string().optional().describe("The end of the date range for the query. This is a datetime in the participant's local timezone."), + startDate: z.string().optional().describe("The start of the date range for the query. This is a datetime in the participant's local timezone."), + pageSize: z.number().optional().describe("The number of items to return."), + pageID: z.string().optional().describe("The page ID to continue from.") +}); + +export class QueryAppleHealthWorkoutsTool extends StructuredTool { + schema = appleHealthQuerySchema; + + name = "queryAppleHealthWorkouts"; + + description = "Query the participant's workouts from Apple Health."; + + async _call(input: z.infer) { + let response = await MyDataHelps.queryAppleHealthWorkouts(input); + + return JSON.stringify(response); + } +} + +export class QueryAppleHealthActivitySummariesTool extends StructuredTool { + schema = appleHealthQuerySchema; + + name = "queryAppleHealthActivitySummaries"; + + description = `Query the participant's Apple Health activity summaries. These include Active Energy Burned, + Active Energy Burned Goal, Apple Exercise Time, Apple Exercise Time Goal, Apple Stand Hours and Apple Stand Hours Goal.`; + + async _call(input: z.infer) { + let response = await MyDataHelps.queryAppleHealthActivitySummaries(input); + + return JSON.stringify(response); + } +} + +export class QueryNotificationsTool extends StructuredTool { + schema = z.object({ + type: z.enum(["Sms", "Push", "Email"]).optional().describe("The type of notification to query."), + statusCode: z.enum(["Succeeded", "Unsubscribed", "MissingContactInfo", "NoRegisteredMobileDevice", "NoAssociatedUser", "ServiceError"]) + .optional().describe("The status code of the notification."), + sentBefore: z.string().optional().describe("The end of the date range for the query. This is a datetime in the participant's local timezone."), + sentAfter: z.string().optional().describe("The start of the date range for the query. This is a datetime in the participant's local timezone."), + identifier: z.string().optional().describe("The identifier of the notification."), + }); + + name = "queryNotifications"; + + description = "Query notifications sent to the participant."; + + async _call(input: z.infer) { + let response = await MyDataHelps.queryNotifications(input); + + return JSON.stringify(response); + } +} + +export class QuerySurveyAnswersTool extends StructuredTool { + schema = z.object({ + surveyResultID: z.string().optional().describe("The ID of the survey result."), + surveyID: z.string().optional().describe("The ID of the survey."), + surveyName: z.string().optional().describe("The name of the survey."), + after: z.string().optional() + .describe(`The start of the date range for the query. Survey answers submitted after this date will be retrieved. + This is a datetime in the participant's local timezone.`), + before: z.string().optional() + .describe(`The end of the date range for the query. Survey answers submitted before this date will be retrieved. + This is a datetime in the participant's local timezone.`), + stepIdentifier: z.string().optional().describe("The step identifier."), + resultIdentifier: z.string().optional().describe("The result identifier."), + answer: z.string().optional().describe("A particular answer."), + pageID: z.string().optional().describe("The page ID to continue from.") + }); + + name = "querySurveyAnswers"; + + description = `Query answers to surveys the participant has completed.`; + + async _call(input: z.infer) { + let response = await MyDataHelps.querySurveyAnswers(input); + + return JSON.stringify(response); + } +} + +export class QueryDeviceDataV2Tool extends StructuredTool { + schema = deviceDataV2QuerySchema; + + name = "queryDeviceDataV2"; + + description = `Can query a participant's device data. This represents raw individual fine grained data points, not aggregated in any way. + + Before using this tool call the getDeviceDataV2AllDataTypes tool to see which data types are available. If a particular data type is not available + use the queryDailyData tool instead. + + For sleep this is the function you would query to determine the time a participant went to bed or woke up. For sleep a day consists of from 6 pm the + previous day to 6 pm the day of (For example 11/26/2024 sleep is from 11/25/2024 at 6 pm to 11/26/2024 at 6 pm). To determine the time a participant + went to bed look at the startDate of the first not awake and not in bed sleep stage for that day. To determine wake up times look at the observationDate + of the last not awake and not in bed sleep stage for that day.`; + + async _call(input: z.infer) { + let response = await MyDataHelps.queryDeviceDataV2(input as DeviceDataV2Query); + + return JSON.stringify(response.deviceDataPoints.map(({ value, startDate, startDateOffset, observationDate, observationDateOffset, units, dataSource }) => ({ + value, + startDate, + startDateOffset, + observationDate, + observationDateOffset, + units, + dataSource: dataSource?.sourceName + }))); + } +} + +export class QueryDeviceDataV2AggregateTool extends StructuredTool { + schema = deviceDataV2QuerySchema.extend({ + intervalAmount: z.number().describe("The number of periods to aggregate over. Together with intervalType this can be 1 Days or 3 Minutes."), + intervalType: z.enum(["Minutes", "Hours", "Days", "Weeks", "Months"]) + .describe("The type of interval to aggregate over. Together with intervalAmount this can be 1 Days or 3 Minutes."), + aggregateFunctions: z.array(z.enum(["sum", "avg", "count", "min", "max"])).describe("The aggregations functions to apply to the granular data."), + }); + + name = "queryDeviceDataV2Aggregate"; + + description = `Can query a participant's aggregated device data. This can be used to get for instance a participant's hourly steps + count or their min max heart rate every day.`; + + async _call(input: z.infer) { + let response = await MyDataHelps.queryDeviceDataV2Aggregate(input as DeviceDataV2AggregateQuery); + + return JSON.stringify(response.intervals.map(({ date, statistics }) => ({ date, statistics }))); + } +} + +export class QueryDailySleepTool extends StructuredTool { + schema = deviceDataV2QuerySchema.extend({ + type: z.string() + .describe("The device data type is specific to the namespace. For Apple Health this is called Sleep Analysis, for Fitbit it is called Sleep."), + }); + + name = "queryDeviceDataV2DailySleep"; + + description = `Can query daily aggregated sleep data. This will include sleep broken down by sleep stage. + Use this function when you need to do sleep aggregate queries instead of deviceDataV2Aggregate.`; + + async _call(input: z.infer) { + let response = await MyDataHelps.queryDeviceDataV2DailySleep(input as DeviceDataV2AggregateQuery); + + return JSON.stringify(response.sleepStageSummaries.map(({ date, duration, value }) => ({ date, duration, value }))); + } +} + +export class QueryDailyDataTool extends StructuredTool { + schema = z.object({ + type: z.string().describe("The type of daily data to query."), + startDate: z.string().describe("The start of the date range for the query. This is a datetime in the participant's local timezone."), + endDate: z.string().describe("The end of the date range for the query. This is a datetime in the participant's local timezone.") + }); + + name = "queryDailyData"; + + description = "Query daily data for a participant. Before using this tool call the getAllDailyDataTypes tool to see which data types are available."; + + async _call(input: z.infer) { + let response = await queryDailyData(input.type, new Date(input.startDate), new Date(input.endDate), false); + + return JSON.stringify(response); + } +} + +export class GetDeviceDataV2AllDataTypesTool extends StructuredTool { + schema = z.object({}); + + name = "getDeviceDataV2AllDataTypes"; + + description = "Get all the device data types that can be queried with the queryDeviceDataV2 tool. Only types that are enabled can be queried."; + + async _call() { + let response = await MyDataHelps.getDeviceDataV2AllDataTypes(); + + return JSON.stringify(response); + } +} + +export class GetAllDailyDataTypesTool extends StructuredTool { + schema = z.object({}); + + name = "getAllDailyDataTypes"; + + description = "Get all the daily data types that can be queried with the queryDailyData tool."; + + async _call() { + return JSON.stringify(getAllDailyDataTypes()); + } +} + +export class GetEhrNewsFeedPageTool extends StructuredTool { + schema = z.object({ + feed: z.enum(["Immunizations", "LabReports", "Procedures", "Reports"]).describe("The type of feed to query."), + pageID: z.string().optional().describe("The page ID to continue from if you need to fetch more results."), + pageDate: z.string().optional().describe("The date of the page to continue from if you are doing a time based query.") + }); + + name = "getEhrNewsFeedPage" + + description = `Get electronic health record (EHR) data for the participant.`; + + async _call(input: z.infer) { + let response = await getNewsFeedPage(input.feed, input.pageID, input.pageDate); + + return JSON.stringify(response); + } +} diff --git a/src/helpers/AIAssistant/index.ts b/src/helpers/AIAssistant/index.ts new file mode 100644 index 00000000..c571fa05 --- /dev/null +++ b/src/helpers/AIAssistant/index.ts @@ -0,0 +1 @@ +export * as MyDataHelpsTools from './Tools'; diff --git a/src/helpers/globalCss.ts b/src/helpers/globalCss.ts index b5033ea5..ec670372 100644 --- a/src/helpers/globalCss.ts +++ b/src/helpers/globalCss.ts @@ -16,6 +16,8 @@ export const core = css` --mdhui-padding-sm: 12px; --mdhui-padding-md: 16px; --mdhui-padding-lg: 24px; + + --mdhui-touch: 44px; } @media (prefers-reduced-motion) { @@ -112,6 +114,7 @@ a { html { font-size: 17px; + height: 100%; } @supports (font: -apple-system-body) { @@ -129,4 +132,5 @@ body { -moz-osx-font-smoothing: grayscale; line-height: 1.3; font-size: 17px; + height: 100%; }`; \ No newline at end of file diff --git a/src/helpers/index.ts b/src/helpers/index.ts index 9e731fdf..e43b7db6 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -16,4 +16,5 @@ export { default as dailyDataTypeDefinitions } from './daily-data-types/all'; export * from './chartOptions'; export * from './chartHelpers'; export * from './heart-rate-data-providers'; -export * from './glucose-and-meals'; \ No newline at end of file +export * from './glucose-and-meals'; +export * from './AIAssistant'; diff --git a/src/helpers/strings-de.ts b/src/helpers/strings-de.ts index fbaaa638..a250bf00 100644 --- a/src/helpers/strings-de.ts +++ b/src/helpers/strings-de.ts @@ -408,7 +408,8 @@ let strings: { [key: string]: string } = { "meal-log-no-data": "Keine Mahlzeiten protokolliert", "meal-editor-time-input-label": "Zeit", "meal-editor-duplicate-timestamp-error": "Zwei Mahlzeiten können nicht die gleiche Uhrzeit haben.", - "glucose-view-title": "Blutzuckerüberwachung" + "glucose-view-title": "Blutzuckerüberwachung", + "ai-assistant-loading": "Interaktion mit Ihren Daten..." }; export default strings; \ No newline at end of file diff --git a/src/helpers/strings-en.ts b/src/helpers/strings-en.ts index 229f43cd..d00670ad 100644 --- a/src/helpers/strings-en.ts +++ b/src/helpers/strings-en.ts @@ -408,7 +408,8 @@ "meal-log-no-data": "No meals logged", "meal-editor-time-input-label": "Time", "meal-editor-duplicate-timestamp-error": "Two meals cannot have the same timestamp.", - "glucose-view-title": "Glucose Monitoring" + "glucose-view-title": "Glucose Monitoring", + "ai-assistant-loading": "Interacting with your data..." }; export default strings; \ No newline at end of file diff --git a/src/helpers/strings-es.ts b/src/helpers/strings-es.ts index 04dfee95..02118064 100644 --- a/src/helpers/strings-es.ts +++ b/src/helpers/strings-es.ts @@ -408,7 +408,8 @@ "meal-log-no-data": "No hay comidas registradas", "meal-editor-time-input-label": "Hora", "meal-editor-duplicate-timestamp-error": "Dos comidas no pueden tener la misma hora.", - "glucose-view-title": "Monitorización de glucosa" + "glucose-view-title": "Monitorización de glucosa", + "ai-assistant-loading": "Interactuando con tus datos..." }; export default strings; \ No newline at end of file diff --git a/src/helpers/strings-fr.ts b/src/helpers/strings-fr.ts index de1e06b0..a5b945c3 100644 --- a/src/helpers/strings-fr.ts +++ b/src/helpers/strings-fr.ts @@ -408,7 +408,8 @@ let strings: { [key: string]: string } = { "meal-log-no-data": "Aucun repas enregistré", "meal-editor-time-input-label": "Heure", "meal-editor-duplicate-timestamp-error": "Deux repas ne peuvent pas avoir la même heure.", - "glucose-view-title": "Surveillance de la glycémie" + "glucose-view-title": "Surveillance de la glycémie", + "ai-assistant-loading": "Interagir avec vos données..." }; export default strings; \ No newline at end of file diff --git a/src/helpers/strings-it.ts b/src/helpers/strings-it.ts index cbd0849d..e7be46f0 100644 --- a/src/helpers/strings-it.ts +++ b/src/helpers/strings-it.ts @@ -408,7 +408,8 @@ let strings: { [key: string]: string } = { "meal-log-no-data": "Nessun pasto registrato", "meal-editor-time-input-label": "Ora", "meal-editor-duplicate-timestamp-error": "Due pasti non possono avere la stessa ora.", - "glucose-view-title": "Monitoraggio della glicemia" + "glucose-view-title": "Monitoraggio della glicemia", + "ai-assistant-loading": "Interagire con i tuoi dati..." }; export default strings; \ No newline at end of file diff --git a/src/helpers/strings-nl.ts b/src/helpers/strings-nl.ts index b298c336..798800b1 100644 --- a/src/helpers/strings-nl.ts +++ b/src/helpers/strings-nl.ts @@ -408,7 +408,8 @@ let strings: { [key: string]: string } = { "meal-log-no-data": "Geen maaltijden geregistreerd", "meal-editor-time-input-label": "Tijd", "meal-editor-duplicate-timestamp-error": "Twee maaltijden kunnen niet dezelfde tijd hebben.", - "glucose-view-title": "Glucosemonitoring" + "glucose-view-title": "Glucosemonitoring", + "ai-assistant-loading": "Interageren met uw gegevens..." }; export default strings; \ No newline at end of file diff --git a/src/helpers/strings-pl.ts b/src/helpers/strings-pl.ts index 4376729d..bffab1de 100644 --- a/src/helpers/strings-pl.ts +++ b/src/helpers/strings-pl.ts @@ -408,7 +408,8 @@ let strings: { [key: string]: string } = { "meal-log-no-data": "Brak zarejestrowanych posiłków", "meal-editor-time-input-label": "Czas", "meal-editor-duplicate-timestamp-error": "Dwa posiłki nie mogą mieć tej samej godziny.", - "glucose-view-title": "Monitorowanie glukozy" + "glucose-view-title": "Monitorowanie glukozy", + "ai-assistant-loading": "Interakcja z Twoimi danymi..." }; export default strings; \ No newline at end of file diff --git a/src/helpers/strings-pt.ts b/src/helpers/strings-pt.ts index f0bf5c15..7774733c 100644 --- a/src/helpers/strings-pt.ts +++ b/src/helpers/strings-pt.ts @@ -408,7 +408,8 @@ let strings: { [key: string]: string } = { "meal-log-no-data": "Nenhuma refeição registrada", "meal-editor-time-input-label": "Hora", "meal-editor-duplicate-timestamp-error": "Duas refeições não podem ter o mesmo horário.", - "glucose-view-title": "Monitoramento de glicose" + "glucose-view-title": "Monitoramento de glicose", + "ai-assistant-loading": "Interagindo com seus dados..." }; export default strings; \ No newline at end of file