From 0bb908453b4acaf9f7628ad9ff8ab21ed07aab56 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Fri, 22 Sep 2023 14:18:41 -0400 Subject: [PATCH] Developer AI (#2128) AI-powered Q&A w/ Document Citation Builds on work from - https://github.com/hashicorp/dev-portal/pull/2148 - https://github.com/hashicorp/dev-portal/pull/2151 - https://github.com/hashicorp/dev-portal/pull/2155 - https://github.com/hashicorp/dev-portal/pull/2157 - https://github.com/hashicorp/dev-portal/pull/2161 - https://github.com/hashicorp/dev-portal/pull/2163 --- next-auth.d.ts | 2 + package-lock.json | 416 ++++++++++++++---- package.json | 3 + src/components/chatbox/ai-feature-toast.tsx | 82 ++++ src/components/chatbox/chatbox.module.css | 105 +++++ src/components/chatbox/chatbox.tsx | 412 +++++++++++++++++ src/components/chatbox/message.module.css | 94 ++++ src/components/chatbox/message.tsx | 316 +++++++++++++ src/components/chatbox/utils.ts | 313 +++++++++++++ .../chatbox/welcome-message.module.css | 81 ++++ src/components/chatbox/welcome-message.tsx | 100 +++++ .../command-bar/commands/chat/chat.module.css | 5 + .../command-bar/commands/chat/index.tsx | 41 ++ src/components/command-bar/commands/index.ts | 2 + .../command-bar/commands/search/index.tsx | 7 +- .../components/dialog-body/chat.module.css | 23 + .../components/dialog-body/index.tsx | 61 ++- .../components/recent-searches/index.tsx | 2 +- .../command-bar/commands/settings/index.tsx | 7 +- .../command-bar/components/dialog/header.tsx | 29 +- .../components/list-item/index.tsx | 10 +- src/components/command-bar/index.tsx | 23 +- src/components/command-bar/types.ts | 8 +- .../mdx-components/mdx-code-blocks/index.tsx | 16 +- src/components/hds-copy-snippet/index.tsx | 6 +- .../toast/components/toast-display/types.ts | 2 +- src/hooks/use-authentication/index.ts | 2 +- src/pages/_app.tsx | 2 + src/pages/api/auth/[...nextauth].ts | 16 + src/pages/api/chat/feedback.ts | 76 ++++ src/pages/api/chat/route.ts | 159 +++++++ 31 files changed, 2270 insertions(+), 151 deletions(-) create mode 100644 src/components/chatbox/ai-feature-toast.tsx create mode 100644 src/components/chatbox/chatbox.module.css create mode 100644 src/components/chatbox/chatbox.tsx create mode 100644 src/components/chatbox/message.module.css create mode 100644 src/components/chatbox/message.tsx create mode 100644 src/components/chatbox/utils.ts create mode 100644 src/components/chatbox/welcome-message.module.css create mode 100644 src/components/chatbox/welcome-message.tsx create mode 100644 src/components/command-bar/commands/chat/chat.module.css create mode 100644 src/components/command-bar/commands/chat/index.tsx create mode 100644 src/components/command-bar/commands/search/unified-search/components/dialog-body/chat.module.css create mode 100644 src/pages/api/chat/feedback.ts create mode 100644 src/pages/api/chat/route.ts diff --git a/next-auth.d.ts b/next-auth.d.ts index e4c5b6ef08..37b83dd3c1 100644 --- a/next-auth.d.ts +++ b/next-auth.d.ts @@ -31,6 +31,8 @@ declare module 'next-auth' { /** The user's nickname. */ nickname?: string | null } & DefaultSession['user'] + /* Key-value store of additional session data */ + meta: Record } /** The OAuth profile returned from your provider */ diff --git a/package-lock.json b/package-lock.json index 1f03522739..d4a321091e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "@tanstack/react-query-devtools": "^4.0.10", "@types/cors": "^2.8.12", "@types/google-spreadsheet": "^3.2.2", + "@vercel/edge-config": "^0.2.1", "@vercel/fetch": "^6.2.0", "adm-zip": "^0.5.9", "algoliasearch": "^4.13.0", @@ -84,6 +85,7 @@ "mdast": "^3.0.0", "mdast-util-to-string": "^2.0.0", "moize": "^6.1.0", + "ms": "^2.1.3", "next": "^13.0.5", "next-auth": "^4.23.1", "next-mdx-remote": "^3.0.8", @@ -103,6 +105,7 @@ "react-hot-toast": "^2.2.0", "react-instantsearch-hooks-web": "^6.33.0", "react-intersection-observer": "^8.33.1", + "react-markdown": "^8.0.7", "react-player": "^2.9.0", "rehype-sanitize": "^5.0.1", "remark-gfm": "^3.0.1", @@ -162,8 +165,8 @@ "yargs": "^17.6.2" }, "engines": { - "node": ">=16.x <=19.x", - "npm": ">=7.0.0" + "node": ">=16.x <=18.x", + "npm": ">=8.0.0" } }, "node_modules/@actions/core": { @@ -324,12 +327,12 @@ "integrity": "sha512-eBxvljiwvajSsg9Pz9nYNH+QH/b5q66Z4xRDr1LhICNuLibDF64mH+Vv4mg29qPxmmgMWlmWiwJQmQqR9Z229w==" }, "node_modules/@ampproject/remapping": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", - "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", "dev": true, "dependencies": { - "@jridgewell/gen-mapping": "^0.1.0", + "@jridgewell/gen-mapping": "^0.3.0", "@jridgewell/trace-mapping": "^0.3.9" }, "engines": { @@ -455,19 +458,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/generator/node_modules/@jridgewell/gen-mapping": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.1.tgz", - "integrity": "sha512-GcHwniMlA2z+WFPWuY8lp3fsza0I8xPFMWL5+n8LYyP6PSvPrXf4+n8stDHZY2DM0zy9sVkRDy1jDI4XGzYVqg==", - "dependencies": { - "@jridgewell/set-array": "^1.0.0", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@babel/helper-compilation-targets": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.18.2.tgz", @@ -682,9 +672,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.18.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.18.4.tgz", - "integrity": "sha512-FDge0dFazETFcxGw/EXzOkN8uJp0PC7Qbm+Pe9T+av2zlBpOgunFHkQPPn+eRuClU73JF+98D531UgayY89tow==", + "version": "7.22.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.7.tgz", + "integrity": "sha512-7NF8pOkHP5o2vpmGgNGcfAeCvOYhGLyA3Z4eBQkT1RJlWu47n63bCs93QfJ2hIAFCil7L5P2IWhs1oToVgrL0Q==", "bin": { "parser": "bin/babel-parser.js" }, @@ -1416,9 +1406,9 @@ } }, "node_modules/@exodus/schemasafe": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@exodus/schemasafe/-/schemasafe-1.0.1.tgz", - "integrity": "sha512-PQdbF8dGd4LnbwBlcc4ML8RKYdplm+e9sUeWBTr4zgF13/Shiuov9XznvM4T8cb1CfyKK21yTUkuAIIh/DAH/g==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@exodus/schemasafe/-/schemasafe-1.1.1.tgz", + "integrity": "sha512-Pd7+aGvWIaTDL5ecV4ZBEtBrjXnk8/ly5xyHbikxVhgcq7qhihzHWHbcYmFupQBT2A5ggNZGvT7Bpj0M6AKHjA==" }, "node_modules/@graphql-typed-document-node/core": { "version": "3.2.0", @@ -5733,22 +5723,22 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz", - "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==", - "dev": true, + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", "dependencies": { - "@jridgewell/set-array": "^1.0.0", - "@jridgewell/sourcemap-codec": "^1.4.10" + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" }, "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.0.7.tgz", - "integrity": "sha512-8cXDaBBHOr2pQ7j77Y6Vp5VDT2sIqWyWQ56TjEq4ih/a4iST3dItRe8Q9fp0rrIl9DoKhWQtUQz/YpOxLkXbNA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", + "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", "engines": { "node": ">=6.0.0" } @@ -5762,19 +5752,24 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.13", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.13.tgz", - "integrity": "sha512-GryiOJmNcWbovBxTfZSF71V/mXbgcV3MewDe3kIMCLyIh5e7SKAeUZs+rMnJ8jkMolZ/4/VsdBmMrw3l+VdZ3w==" + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.13.tgz", - "integrity": "sha512-o1xbKhp9qnIAoHJSWd6KlCZfqslL4valSF81H8ImioOAxluWYWOpWkpyktY2vnt4tbrX9XYaxovq6cgowaJp2w==", + "version": "0.3.18", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz", + "integrity": "sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==", "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" + "@jridgewell/resolve-uri": "3.1.0", + "@jridgewell/sourcemap-codec": "1.4.14" } }, + "node_modules/@jridgewell/trace-mapping/node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.14", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", + "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" + }, "node_modules/@jsdevtools/ono": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", @@ -10971,9 +10966,9 @@ "integrity": "sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ==" }, "node_modules/@types/node-fetch": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.1.tgz", - "integrity": "sha512-oMqjURCaxoSIsHSr1E47QHzbmzNR5rK8McHuNb11BOM9cHcIK3Avy0s/b2JlXHoQGTYS3NsvWzV1M0iK7l0wbA==", + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.4.tgz", + "integrity": "sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==", "dependencies": { "@types/node": "*", "form-data": "^3.0.0" @@ -11411,6 +11406,22 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vercel/edge-config": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@vercel/edge-config/-/edge-config-0.2.1.tgz", + "integrity": "sha512-847kYqJEbga4PGgNrctQ9XsD+2Kw/S+UjzZnIFpebQ9VbdtB0MX4anq33WetcYZYPfhZd2L0uXVnY/BcjI5dOw==", + "dependencies": { + "@vercel/edge-config-fs": "0.1.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/@vercel/edge-config-fs": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@vercel/edge-config-fs/-/edge-config-fs-0.1.0.tgz", + "integrity": "sha512-NRIBwfcS0bUoUbRWlNGetqjvLSwgYH/BqKqDN7vK1g32p7dN96k0712COgaz6VFizAm9b0g6IG6hR6+hc0KCPg==" + }, "node_modules/@vercel/fetch": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/@vercel/fetch/-/fetch-6.2.0.tgz", @@ -12520,48 +12531,14 @@ } }, "node_modules/axobject-query": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz", - "integrity": "sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==", - "dev": true, - "dependencies": { - "deep-equal": "^2.0.5" - } - }, - "node_modules/axobject-query/node_modules/deep-equal": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.0.tgz", - "integrity": "sha512-RdpzE0Hv4lhowpIUKKMJfeH6C1pXdtT1/it80ubgWqwI3qpuxUBpC1S4hnHg+zjnuOoDkzUtUCEEkG+XG5l3Mw==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", + "integrity": "sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "es-get-iterator": "^1.1.2", - "get-intrinsic": "^1.1.3", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.1", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.4.3", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.9" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "dequal": "^2.0.3" } }, - "node_modules/axobject-query/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true - }, "node_modules/babel-code-frame": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", @@ -16350,9 +16327,9 @@ "dev": true }, "node_modules/csstype": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", - "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==" + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" }, "node_modules/currently-unhandled": { "version": "0.4.1", @@ -23671,9 +23648,9 @@ } }, "node_modules/jose": { - "version": "4.11.4", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.11.4.tgz", - "integrity": "sha512-94FdcR8felat4vaTJyL/WVdtlWLlsnLMZP8v+A0Vru18K3bQ22vn7TtpVh3JlgBFNIlYOUlGqwp/MjRPOnIyCQ==", + "version": "4.14.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.14.4.tgz", + "integrity": "sha512-j8GhLiKmUAh+dsFXlX1aJCbt5KMibuKb+d7j1JaOJG6s2UjX1PQlW+OKB/sD4a/5ZYF4RcmYmLSndOoU3Lt/3g==", "funding": { "url": "https://github.com/sponsors/panva" } @@ -28505,9 +28482,15 @@ } }, "node_modules/nanoid": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", - "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -35276,6 +35259,255 @@ "npm": ">= 2.0.0" } }, + "node_modules/react-markdown": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-8.0.7.tgz", + "integrity": "sha512-bvWbzG4MtOU62XqBx3Xx+zB2raaFFsq4mYiAzfjXJMEz2sixgeAfraA3tvzULF02ZdOMUOKTBFFaZJDDrq+BJQ==", + "dependencies": { + "@types/hast": "^2.0.0", + "@types/prop-types": "^15.0.0", + "@types/unist": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^2.0.0", + "prop-types": "^15.0.0", + "property-information": "^6.0.0", + "react-is": "^18.0.0", + "remark-parse": "^10.0.0", + "remark-rehype": "^10.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-object": "^0.4.0", + "unified": "^10.0.0", + "unist-util-visit": "^4.0.0", + "vfile": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=16", + "react": ">=16" + } + }, + "node_modules/react-markdown/node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/react-markdown/node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/react-markdown/node_modules/hast-util-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-2.0.1.tgz", + "integrity": "sha512-nAxA0v8+vXSBDt3AnRUNjyRIQ0rD+ntpbAp4LnPkumc5M9yUbSMa4XDU9Q6etY4f1Wp4bNgvc1yjiZtsTTrSng==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/react-markdown/node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-markdown/node_modules/mdast-util-from-markdown": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-1.3.1.tgz", + "integrity": "sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==", + "dependencies": { + "@types/mdast": "^3.0.0", + "@types/unist": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "mdast-util-to-string": "^3.1.0", + "micromark": "^3.0.0", + "micromark-util-decode-numeric-character-reference": "^1.0.0", + "micromark-util-decode-string": "^1.0.0", + "micromark-util-normalize-identifier": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "unist-util-stringify-position": "^3.0.0", + "uvu": "^0.5.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/react-markdown/node_modules/mdast-util-to-string": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-3.2.0.tgz", + "integrity": "sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==", + "dependencies": { + "@types/mdast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/react-markdown/node_modules/micromark": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-3.2.0.tgz", + "integrity": "sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "micromark-core-commonmark": "^1.0.1", + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-chunked": "^1.0.0", + "micromark-util-combine-extensions": "^1.0.0", + "micromark-util-decode-numeric-character-reference": "^1.0.0", + "micromark-util-encode": "^1.0.0", + "micromark-util-normalize-identifier": "^1.0.0", + "micromark-util-resolve-all": "^1.0.0", + "micromark-util-sanitize-uri": "^1.0.0", + "micromark-util-subtokenize": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.1", + "uvu": "^0.5.0" + } + }, + "node_modules/react-markdown/node_modules/property-information": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.2.0.tgz", + "integrity": "sha512-kma4U7AFCTwpqq5twzC1YVIDXSqg6qQK6JN0smOw8fgRy1OkMi0CYSzFmsy6dnqSenamAtj0CyXMUJ1Mf6oROg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/react-markdown/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + }, + "node_modules/react-markdown/node_modules/remark-parse": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-10.0.2.tgz", + "integrity": "sha512-3ydxgHa/ZQzG8LvC7jTXccARYDcRld3VfcgIIFs7bI6vbRSxJJmzgLEIIoYKyrfhaY+ujuWaf/PJiMZXoiCXgw==", + "dependencies": { + "@types/mdast": "^3.0.0", + "mdast-util-from-markdown": "^1.0.0", + "unified": "^10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/react-markdown/node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/react-markdown/node_modules/style-to-object": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.1.tgz", + "integrity": "sha512-HFpbb5gr2ypci7Qw+IOhnP2zOU7e77b+rzM+wTzXzfi1PrtBCX0E7Pk4wL4iTLnhzZ+JgEGAhX81ebTg/aYjQw==", + "dependencies": { + "inline-style-parser": "0.1.1" + } + }, + "node_modules/react-markdown/node_modules/trough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.1.0.tgz", + "integrity": "sha512-AqTiAOLcj85xS7vQ8QkAV41hPDIJ71XJB4RCUrzo/1GM2CQwhkJGaf9Hgr7BOugMRpgGUrqRg/DrBDl4H40+8g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/react-markdown/node_modules/unified": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz", + "integrity": "sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==", + "dependencies": { + "@types/unist": "^2.0.0", + "bail": "^2.0.0", + "extend": "^3.0.0", + "is-buffer": "^2.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/react-markdown/node_modules/unist-util-stringify-position": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz", + "integrity": "sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==", + "dependencies": { + "@types/unist": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/react-markdown/node_modules/vfile": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-5.3.7.tgz", + "integrity": "sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==", + "dependencies": { + "@types/unist": "^2.0.0", + "is-buffer": "^2.0.0", + "unist-util-stringify-position": "^3.0.0", + "vfile-message": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/react-markdown/node_modules/vfile-message": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-3.1.4.tgz", + "integrity": "sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw==", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-stringify-position": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/react-player": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/react-player/-/react-player-2.9.0.tgz", diff --git a/package.json b/package.json index b91a85f44e..6be6c8ab5f 100644 --- a/package.json +++ b/package.json @@ -148,6 +148,7 @@ "@tanstack/react-query-devtools": "^4.0.10", "@types/cors": "^2.8.12", "@types/google-spreadsheet": "^3.2.2", + "@vercel/edge-config": "^0.2.1", "@vercel/fetch": "^6.2.0", "adm-zip": "^0.5.9", "algoliasearch": "^4.13.0", @@ -171,6 +172,7 @@ "mdast": "^3.0.0", "mdast-util-to-string": "^2.0.0", "moize": "^6.1.0", + "ms": "^2.1.3", "next": "^13.0.5", "next-auth": "^4.23.1", "next-mdx-remote": "^3.0.8", @@ -190,6 +192,7 @@ "react-hot-toast": "^2.2.0", "react-instantsearch-hooks-web": "^6.33.0", "react-intersection-observer": "^8.33.1", + "react-markdown": "^8.0.7", "react-player": "^2.9.0", "rehype-sanitize": "^5.0.1", "remark-gfm": "^3.0.1", diff --git a/src/components/chatbox/ai-feature-toast.tsx b/src/components/chatbox/ai-feature-toast.tsx new file mode 100644 index 0000000000..8e14ce440f --- /dev/null +++ b/src/components/chatbox/ai-feature-toast.tsx @@ -0,0 +1,82 @@ +import { useEffect } from 'react' +import Cookies from 'js-cookie' +import { IconWand24 } from '@hashicorp/flight-icons/svg-react/wand-24' + +import { toast, ToastColor } from 'components/toast' +import useAuthentication from 'hooks/use-authentication' + +import { CmdCtrlIcon } from 'components/command-bar/components/cmd-ctrl-icon' +import { KIcon } from 'components/command-bar/components/k-icon' +import Badge from 'components/badge' + +const COOKIE_IGNORE_AI_TOAST = 'dev-dot-ignore-ai-toast' +const COOKIE_HAS_SEEN_AI_TOAST = 'dev-dot-has-seen-ai-toast' +const COOKIE_EXPIRES_AT = new Date('12/25/2024') // arbitrary date + +const TOAST_AUTO_DISMISS_MS = 15000 + +export function AIFeatureToast() { + const { session } = useAuthentication() + + useEffect(() => { + // skip toast if we're past the expiration date + if (new Date() > COOKIE_EXPIRES_AT) { + return + } + + // skip toast if user has previously dismissed it + if (Cookies.get(COOKIE_IGNORE_AI_TOAST)) { + return + } + + // skip toast if it's already been seen + if (Cookies.get(COOKIE_HAS_SEEN_AI_TOAST)) { + return + } + + if (session?.meta.isAIEnabled) { + toast({ + color: ToastColor.highlight, + icon: , + title: 'Welcome to the Developer AI closed beta', + description: ( + <> + Try it out in our{' '} + } + size="small" + />{' '} + } + size="small" + />{' '} + menu! + + ), + autoDismiss: TOAST_AUTO_DISMISS_MS, + onDismissCallback: () => { + // when user dismisses the toast, we should ignore it going forwards + Cookies.set(COOKIE_IGNORE_AI_TOAST, true, { + expires: COOKIE_EXPIRES_AT, + }) + // remove extra cookie + Cookies.remove(COOKIE_HAS_SEEN_AI_TOAST) + }, + dismissOnRouteChange: false, + }) + + // if the user didn't explicitly dismiss the toast, show it again in 24 hours + // this is a simple means to boost discoverability / awareness + Cookies.set(COOKIE_HAS_SEEN_AI_TOAST, true, { + expires: 1, // https://github.com/js-cookie/js-cookie?tab=readme-ov-file#expires + }) + } + }, [session?.meta.isAIEnabled]) + + // no dom elements are needed for this component + return null +} diff --git a/src/components/chatbox/chatbox.module.css b/src/components/chatbox/chatbox.module.css new file mode 100644 index 0000000000..1cc52a9a0c --- /dev/null +++ b/src/components/chatbox/chatbox.module.css @@ -0,0 +1,105 @@ +.chat { + height: 100%; + background: var(--token-color-palette-neutral-100); + display: flex; + flex-direction: column; + z-index: 9999; + justify-content: space-between; +} + +.messageList { + overflow-y: auto; + max-height: 100%; + display: flex; + flex-direction: column; + flex: 1; +} + +.arrowdown { + position: absolute; + top: -52px; + right: 20px; +} + +.bottom { + position: relative; /* for absolute positioned arrow down button */ + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 12px; + background: var(--token-color-surface-primary); + border-top: 1px solid var(--token-color-border-primary); +} + +.question { + display: flex; + flex-direction: row; + align-items: flex-end; + padding: 8px 0; + gap: 12px; + background: var(--token-color-surface-primary); +} +.questionIcon { + height: 40px; + width: 40px; + padding: 8px; + + /* See prior art: https://hashicorp-mktg.sourcegraph.com/github.com/hashicorp/web/-/blob/apps/www/views/state-of-the-cloud/2021/components/hero/style.module.css?L18-19 */ + & :global(.loading) { + animation: hds-flight-icon-animation-rotation 9s linear infinite; + animation-duration: 0.7s; + } +} + +.purple { + color: var(--token-color-foreground-highlight-on-surface); +} + +@keyframes hds-flight-icon-animation-rotation { + 100% { + transform: rotate(360deg); + } +} + +.reset { + margin: 0; + padding: 0; + border: 0; + box-sizing: border-box; + font: inherit; + font-size: 100%; + vertical-align: baseline; + text-decoration: none; + -webkit-tap-highlight-color: transparent; + outline: transparent; +} + +.textarea { + composes: g-focus-ring-from-box-shadow from global; + + /* Body/300/Medium */ + composes: hds-typography-body-300 from global; + composes: hds-font-weight-medium from global; + appearance: none; + border-radius: 5px; + padding: 8px 4px; + display: flex; + flex: 1; + height: 100%; + resize: none; + background: transparent; +} +.button { + height: 24px; + width: 24px; + background: transparent; + &:focus { + outline: auto; + } +} + +.disclaimer { + color: var(--token-color-foreground-faint); /* #656a76 */ + display: block; + padding: 8px 0 0 10px; +} diff --git a/src/components/chatbox/chatbox.tsx b/src/components/chatbox/chatbox.tsx new file mode 100644 index 0000000000..a16245fb96 --- /dev/null +++ b/src/components/chatbox/chatbox.tsx @@ -0,0 +1,412 @@ +import React, { useEffect, useState, useRef } from 'react' + +// https://helios.hashicorp.design/icons/library +import { IconArrowDownCircle16 } from '@hashicorp/flight-icons/svg-react/arrow-down-circle-16' +import { IconLoading24 } from '@hashicorp/flight-icons/svg-react/loading-24' +import { IconSend24 } from '@hashicorp/flight-icons/svg-react/send-24' +import { IconStopCircle24 } from '@hashicorp/flight-icons/svg-react/stop-circle-24' +import { IconWand24 } from '@hashicorp/flight-icons/svg-react/wand-24' +import classNames from 'classnames' +import ms from 'ms' +import { z } from 'zod' + +import { useMutation } from '@tanstack/react-query' + +import useAuthentication from 'hooks/use-authentication' +import IconTile from 'components/icon-tile' +import Button from 'components/button' + +import Text from 'components/text' + +import s from './chatbox.module.css' +import { MessageList, type MessageType } from './message' +import { WelcomeMessage } from './welcome-message' + +import { + streamToAsyncIterable, + mergeRefs, + useScrollBarVisible, + useThrottle, +} from './utils' + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +const useAI = () => { + // We'll call `update` to retrieve a new access token if the current one is expired + // and we receive a 401 from the backend. + const { update, session } = useAuthentication() + const token = session?.accessToken + + // The backend id of a conversation + const [conversationId, setConversationId] = useState('') + // The backend id of the most recently returned message + const [messageId, setMessageId] = useState('') + + // Is the stream being read? + const [isReading, setIsReading] = useState(false) + // The streamed-in text + const [streamedText, setStreamedText] = useState('') + // Error text + const [errorText, setErrorText] = useState('') + + type MutationParams = { + value: string + conversationId?: string + parentMessageId?: string + } + + // Use useMutation to make a POST request more ergonomic + const mutation = useMutation({ + onMutate: async () => { + // clear previous response + setStreamedText('...') + setErrorText('') + setMessageId('') + }, + onError: async (error) => { + console.log('onError', error) + switch (error.status) { + case 400: + case 401: + case 403: { + setErrorText(`${error.status} ${error.statusText}`) + break + } + case 429: { + const resetAtSec = mutation.error.headers.get('x-ratelimit-reset') + const resetAtMs = Number(resetAtSec) * 1000 + const diffMs = resetAtMs - Date.now() + + const errorMessage = `Too many requests. Please try again in ${ms( + diffMs, + { + long: true, + } + )}.` + setErrorText(errorMessage) + break + } + default: { + setErrorText(`${error.status} ${error.statusText}`) + break + } + } + }, + mutationFn: async ({ value: task, conversationId, parentMessageId }) => { + // call our edge function which is capable of streaming + const request = async (jwt: string) => { + const res = await fetch('/api/chat/route', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ task, conversationId, parentMessageId }), + }) + return res + } + + let response = await request(token) + + if (response.ok) { + // messageId and conversationId are noops until we are ready for multi-message conversations + setConversationId(response.headers.get('x-conversation-id')) + setMessageId(response.headers.get('x-message-id')) + return response + } else { + // if the response is not ok + // if the response is a 401, we need to refresh the access token and retry once. + // otherwise, throw the response + if (response.status == 401) { + const { accessToken } = await update() + response = await request(accessToken) + if (response.ok) { + return response + } + } + throw response + } + }, + }) + + // A stream reader + const [reader, setReader] = + useState | null>(null) + // Function to stop the stream + const stopStream = () => { + reader?.cancel() + mutation.reset() + } + + // when the mutation / POST is successful + useEffect(() => { + if (mutation.data?.ok) { + const stream = mutation.data.body + const _reader = stream.getReader() + setReader(_reader) + + // transform stream to async iterable; + // each chunk is transformed into a SSE object + const iter = streamToAsyncIterable(_reader) + + // Self-invoking async function to read the stream + ;(async () => { + // Reset streamed text + setStreamedText('') + setIsReading(true) + + // This is our contract with the backend + const messageFormat = z.object({ + content: z.string(), + }) + + for await (const { event, data, raw } of iter) { + const parseResult = messageFormat.safeParse(JSON.parse(data)) + if (!parseResult.success) { + // malformatted message + continue + } else { + const { content } = parseResult.data + + // call sleep(0) to opt out of react's state update batching + // this allows the text to appear to stream-in smoothly + await sleep(0) + setStreamedText((prev) => prev + content) + } + } + + // cleanup + setIsReading(false) + })() + } + }, [mutation.data]) + + return { + stopStream, + streamedText, + errorText, + conversationId, + messageId, + isLoading: mutation.isLoading || isReading, + sendMessage: mutation.mutate, + } +} + +const ChatBox = () => { + const { + errorText, + streamedText, + messageId, + conversationId, + isLoading, + stopStream, + sendMessage, + } = useAI() + const { user } = useAuthentication() + + // Text area + // Note: We are not re-using the command bar's input state as we intentionally + // want the "search" & "AI" UX to be separate. + const [userInput, setUserInput] = useState('') + + // List of user and assistant messages + const [messageList, setMessageList] = useState([]) + const appendMessage = (message: MessageType) => { + setMessageList((prev) => [...prev, message]) + } + const resetMessageList = () => { + setMessageList([]) + } + + // stream text into our message list + const updateAssistantMessageByIds = ({ + conversationId, + messageId, + text, + isLoading, + }: { + conversationId: string + messageId: string + text: string + isLoading: boolean + }) => { + setMessageList((prev) => { + const next = [...prev] + const assistantMessage = next.find( + (e) => + e.type == 'assistant' && + e.messageId === messageId && + e.conversationId === conversationId + ) + + // update or create + if (assistantMessage) { + assistantMessage.text = text + // @ts-expect-error - Ignore TS not being able to narrow down here + assistantMessage.isLoading = isLoading + } else { + next.push({ + type: 'assistant', + text: text, + messageId: messageId, + conversationId: conversationId, + isLoading: true, + }) + } + return next + }) + } + + // update component state when text is streamed in from the backend + useEffect(() => { + if (!streamedText || !messageId || !conversationId) { + return + } + updateAssistantMessageByIds({ + conversationId, + messageId, + text: streamedText, + isLoading, + }) + }, [streamedText, messageId, conversationId, isLoading]) + + const handleSubmit = async (e) => { + const task = e.currentTarget.task?.value + e.preventDefault() + + // Reset previous messages + // -- Revisit this when we're ready for multi-message conversations + resetMessageList() + + // Clear textarea + setUserInput('') + + sendMessage({ + value: task, + // -- uncomment these parameters when we are ready for multi-message conversations + // conversationId, + // parentMessageId: messageId, + }) + + // append user message to list + appendMessage({ type: 'user', text: task, image: user.image }) + } + + // Throttle this value to enable uninterrupted smooth scrolling + const throttledText = useThrottle(streamedText, 400) + + // scroll text response to bottom as text streams in from the backend + const textContentRef = useRef(null) + useEffect(() => { + textContentRef.current?.scrollTo({ + top: textContentRef.current.scrollHeight, + behavior: 'smooth', + }) + }, [throttledText]) + + // for imperatively submitting the form via textarea-enter-key + const formRef = useRef(null) + + // for conditionally rendering a down arrow + const [textContentRef2, textContentScrollBarIsVisible] = useScrollBarVisible() + + // update component state when the mutation fails + useEffect(() => { + if (errorText) { + appendMessage({ type: 'application', text: errorText }) + } + }, [errorText]) + + const handleArrowDownClick = () => { + textContentRef.current?.scrollTo({ + top: textContentRef.current.scrollHeight, + behavior: 'smooth', + }) + } + + return ( +
+ {messageList.length === 0 ? ( + + ) : ( +
+ +
+ )} + +
+
+ {textContentScrollBarIsVisible ? ( +
+ + + +
+ ) : null} + +
+
+ {isLoading ? ( + + ) : ( + + )} +
+