From 8b4887261dce9602145884a0c69514a6f8270907 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 1 May 2025 14:39:23 +0100 Subject: [PATCH] feat(site): add experimental chat UI Builds on https://github.com/coder/coder/pull/17570 Frontend portion of https://github.com/coder/coder/tree/chat originally authored by @kylecarbs Additional changes: - Addresses linter complaints - Brings `ChatToolInvocation` argument definitions in line with those defined in `codersdk/toolsdk` - Ensures chat-related features are not shown unless `ExperimentAgenticChat` is enabled. Co-authored-by: Kyle Carberry --- site/package.json | 3 + site/pnpm-lock.yaml | 216 +++ site/src/api/api.ts | 24 + site/src/api/queries/chats.ts | 25 + site/src/api/queries/deployment.ts | 7 + site/src/contexts/useAgenticChat.ts | 16 + .../modules/dashboard/Navbar/NavbarView.tsx | 31 +- site/src/pages/ChatPage/ChatLanding.tsx | 164 +++ site/src/pages/ChatPage/ChatLayout.tsx | 246 ++++ site/src/pages/ChatPage/ChatMessages.tsx | 491 +++++++ .../ChatPage/ChatToolInvocation.stories.tsx | 1211 +++++++++++++++++ .../src/pages/ChatPage/ChatToolInvocation.tsx | 872 ++++++++++++ .../pages/ChatPage/LanguageModelSelector.tsx | 73 + site/src/router.tsx | 8 + 14 files changed, 3381 insertions(+), 6 deletions(-) create mode 100644 site/src/api/queries/chats.ts create mode 100644 site/src/contexts/useAgenticChat.ts create mode 100644 site/src/pages/ChatPage/ChatLanding.tsx create mode 100644 site/src/pages/ChatPage/ChatLayout.tsx create mode 100644 site/src/pages/ChatPage/ChatMessages.tsx create mode 100644 site/src/pages/ChatPage/ChatToolInvocation.stories.tsx create mode 100644 site/src/pages/ChatPage/ChatToolInvocation.tsx create mode 100644 site/src/pages/ChatPage/LanguageModelSelector.tsx diff --git a/site/package.json b/site/package.json index 23c1cf9d22428..bc459ce79f7a1 100644 --- a/site/package.json +++ b/site/package.json @@ -35,6 +35,8 @@ "update-emojis": "cp -rf ./node_modules/emoji-datasource-apple/img/apple/64/* ./static/emojis" }, "dependencies": { + "@ai-sdk/provider-utils": "2.2.6", + "@ai-sdk/react": "1.2.6", "@emoji-mart/data": "1.2.1", "@emoji-mart/react": "1.1.1", "@emotion/cache": "11.14.0", @@ -111,6 +113,7 @@ "react-virtualized-auto-sizer": "1.0.24", "react-window": "1.8.11", "recharts": "2.15.0", + "rehype-raw": "7.0.0", "remark-gfm": "4.0.0", "resize-observer-polyfill": "1.5.1", "rollup-plugin-visualizer": "5.14.0", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 7b8e9c52ea4af..252d7809033ec 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -16,6 +16,12 @@ importers: .: dependencies: + '@ai-sdk/provider-utils': + specifier: 2.2.6 + version: 2.2.6(zod@3.24.3) + '@ai-sdk/react': + specifier: 1.2.6 + version: 1.2.6(react@18.3.1)(zod@3.24.3) '@emoji-mart/data': specifier: 1.2.1 version: 1.2.1 @@ -244,6 +250,9 @@ importers: recharts: specifier: 2.15.0 version: 2.15.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rehype-raw: + specifier: 7.0.0 + version: 7.0.0 remark-gfm: specifier: 4.0.0 version: 4.0.0 @@ -489,6 +498,42 @@ packages: '@adobe/css-tools@4.4.1': resolution: {integrity: sha512-12WGKBQzjUAI4ayyF4IAtfw2QR/IDoqk6jTddXDhtYTJF9ASmoE1zst7cVtP0aL/F1jUJL5r+JxKXKEgHNbEUQ==, tarball: https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.1.tgz} + '@ai-sdk/provider-utils@2.2.4': + resolution: {integrity: sha512-13sEGBxB6kgaMPGOgCLYibF6r8iv8mgjhuToFrOTU09bBxbFQd8ZoARarCfJN6VomCUbUvMKwjTBLb1vQnN+WA==, tarball: https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.4.tgz} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.23.8 + + '@ai-sdk/provider-utils@2.2.6': + resolution: {integrity: sha512-sUlZ7Gnq84DCGWMQRIK8XVbkzIBnvPR1diV4v6JwPgpn5armnLI/j+rqn62MpLrU5ZCQZlDKl/Lw6ed3ulYqaA==, tarball: https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.6.tgz} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.23.8 + + '@ai-sdk/provider@1.1.0': + resolution: {integrity: sha512-0M+qjp+clUD0R1E5eWQFhxEvWLNaOtGQRUaBn8CUABnSKredagq92hUS9VjOzGsTm37xLfpaxl97AVtbeOsHew==, tarball: https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.0.tgz} + engines: {node: '>=18'} + + '@ai-sdk/provider@1.1.2': + resolution: {integrity: sha512-ITdgNilJZwLKR7X5TnUr1BsQW6UTX5yFp0h66Nfx8XjBYkWD9W3yugr50GOz3CnE9m/U/Cd5OyEbTMI0rgi6ZQ==, tarball: https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.2.tgz} + engines: {node: '>=18'} + + '@ai-sdk/react@1.2.6': + resolution: {integrity: sha512-5BFChNbcYtcY9MBStcDev7WZRHf0NpTrk8yfSoedWctB3jfWkFd1HECBvdc8w3mUQshF2MumLHtAhRO7IFtGGQ==, tarball: https://registry.npmjs.org/@ai-sdk/react/-/react-1.2.6.tgz} + engines: {node: '>=18'} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + zod: ^3.23.8 + peerDependenciesMeta: + zod: + optional: true + + '@ai-sdk/ui-utils@1.2.5': + resolution: {integrity: sha512-XDgqnJcaCkDez7qolvk+PDbs/ceJvgkNkxkOlc9uDWqxfDJxtvCZ+14MP/1qr4IBwGIgKVHzMDYDXvqVhSWLzg==, tarball: https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.2.5.tgz} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.23.8 + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==, tarball: https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz} engines: {node: '>=10'} @@ -3942,18 +3987,33 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==, tarball: https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz} engines: {node: '>= 0.4'} + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==, tarball: https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz} + hast-util-parse-selector@2.2.5: resolution: {integrity: sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==, tarball: https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz} + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==, tarball: https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==, tarball: https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz} + hast-util-to-jsx-runtime@2.3.2: resolution: {integrity: sha512-1ngXYb+V9UT5h+PxNRa1O1FYguZK/XL+gkeqvp7EdHlB9oHUG0eYRo/vY5inBdcqo3RkPMC58/H94HvkbfGdyg==, tarball: https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.2.tgz} + hast-util-to-parse5@8.0.0: + resolution: {integrity: sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==, tarball: https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==, tarball: https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz} hastscript@6.0.0: resolution: {integrity: sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==, tarball: https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz} + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==, tarball: https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz} + headers-polyfill@4.0.3: resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==, tarball: https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz} @@ -3976,6 +4036,9 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==, tarball: https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==, tarball: https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz} + http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==, tarball: https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz} engines: {node: '>= 0.8'} @@ -4480,6 +4543,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==, tarball: https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==, tarball: https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==, tarball: https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz} @@ -5236,6 +5302,9 @@ packages: property-information@6.5.0: resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==, tarball: https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz} + property-information@7.0.0: + resolution: {integrity: sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==, tarball: https://registry.npmjs.org/property-information/-/property-information-7.0.0.tgz} + protobufjs@7.4.0: resolution: {integrity: sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==, tarball: https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz} engines: {node: '>=12.0.0'} @@ -5492,6 +5561,9 @@ packages: resolution: {integrity: sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==, tarball: https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz} engines: {node: '>= 0.4'} + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==, tarball: https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz} + remark-gfm@4.0.0: resolution: {integrity: sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA==, tarball: https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.0.tgz} @@ -5599,6 +5671,9 @@ packages: scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==, tarball: https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz} + secure-json-parse@2.7.0: + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==, tarball: https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz} + semver@7.6.2: resolution: {integrity: sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==, tarball: https://registry.npmjs.org/semver/-/semver-7.6.2.tgz} engines: {node: '>=10'} @@ -5840,6 +5915,11 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==, tarball: https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz} engines: {node: '>= 0.4'} + swr@2.3.3: + resolution: {integrity: sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==, tarball: https://registry.npmjs.org/swr/-/swr-2.3.3.tgz} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==, tarball: https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz} @@ -5877,6 +5957,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==, tarball: https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz} + throttleit@2.1.0: + resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==, tarball: https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz} + engines: {node: '>=18'} + tiny-case@1.0.3: resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==, tarball: https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz} @@ -6163,6 +6247,9 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==, tarball: https://registry.npmjs.org/vary/-/vary-1.1.2.tgz} engines: {node: '>= 0.8'} + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==, tarball: https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz} + vfile-message@4.0.2: resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==, tarball: https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz} @@ -6274,6 +6361,9 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==, tarball: https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz} + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==, tarball: https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==, tarball: https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz} engines: {node: '>=12'} @@ -6405,6 +6495,11 @@ packages: yup@1.6.1: resolution: {integrity: sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==, tarball: https://registry.npmjs.org/yup/-/yup-1.6.1.tgz} + zod-to-json-schema@3.24.5: + resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==, tarball: https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz} + peerDependencies: + zod: ^3.24.1 + zod-validation-error@3.4.0: resolution: {integrity: sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==, tarball: https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.4.0.tgz} engines: {node: '>=18.0.0'} @@ -6424,6 +6519,45 @@ snapshots: '@adobe/css-tools@4.4.1': {} + '@ai-sdk/provider-utils@2.2.4(zod@3.24.3)': + dependencies: + '@ai-sdk/provider': 1.1.0 + nanoid: 3.3.8 + secure-json-parse: 2.7.0 + zod: 3.24.3 + + '@ai-sdk/provider-utils@2.2.6(zod@3.24.3)': + dependencies: + '@ai-sdk/provider': 1.1.2 + nanoid: 3.3.8 + secure-json-parse: 2.7.0 + zod: 3.24.3 + + '@ai-sdk/provider@1.1.0': + dependencies: + json-schema: 0.4.0 + + '@ai-sdk/provider@1.1.2': + dependencies: + json-schema: 0.4.0 + + '@ai-sdk/react@1.2.6(react@18.3.1)(zod@3.24.3)': + dependencies: + '@ai-sdk/provider-utils': 2.2.4(zod@3.24.3) + '@ai-sdk/ui-utils': 1.2.5(zod@3.24.3) + react: 18.3.1 + swr: 2.3.3(react@18.3.1) + throttleit: 2.1.0 + optionalDependencies: + zod: 3.24.3 + + '@ai-sdk/ui-utils@1.2.5(zod@3.24.3)': + dependencies: + '@ai-sdk/provider': 1.1.0 + '@ai-sdk/provider-utils': 2.2.4(zod@3.24.3) + zod: 3.24.3 + zod-to-json-schema: 3.24.5(zod@3.24.3) + '@alloc/quick-lru@5.2.0': {} '@ampproject/remapping@2.3.0': @@ -10183,8 +10317,39 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.0.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + hast-util-parse-selector@2.2.5: {} + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.0 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.0 + parse5: 7.1.2 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + hast-util-to-jsx-runtime@2.3.2: dependencies: '@types/estree': 1.0.6 @@ -10205,6 +10370,16 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-parse5@8.0.0: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 @@ -10217,6 +10392,14 @@ snapshots: property-information: 5.6.0 space-separated-tokens: 1.1.5 + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.0.0 + space-separated-tokens: 2.0.2 + headers-polyfill@4.0.3: {} highlight.js@10.7.3: {} @@ -10235,6 +10418,8 @@ snapshots: html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} + http-errors@2.0.0: dependencies: depd: 2.0.0 @@ -10962,6 +11147,8 @@ snapshots: json-schema-traverse@0.4.1: optional: true + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: optional: true @@ -11986,6 +12173,8 @@ snapshots: property-information@6.5.0: {} + property-information@7.0.0: {} + protobufjs@7.4.0: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -12303,6 +12492,12 @@ snapshots: define-properties: 1.2.1 set-function-name: 2.0.1 + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + remark-gfm@4.0.0: dependencies: '@types/mdast': 4.0.3 @@ -12442,6 +12637,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + secure-json-parse@2.7.0: {} + semver@7.6.2: {} send@0.19.0: @@ -12695,6 +12892,12 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + swr@2.3.3(react@18.3.1): + dependencies: + dequal: 2.0.3 + react: 18.3.1 + use-sync-external-store: 1.4.0(react@18.3.1) + symbol-tree@3.2.4: {} tailwind-merge@2.6.0: {} @@ -12753,6 +12956,8 @@ snapshots: dependencies: any-promise: 1.3.0 + throttleit@2.1.0: {} + tiny-case@1.0.3: {} tiny-invariant@1.3.3: {} @@ -13043,6 +13248,11 @@ snapshots: vary@1.1.2: {} + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + vfile-message@4.0.2: dependencies: '@types/unist': 3.0.3 @@ -13139,6 +13349,8 @@ snapshots: dependencies: defaults: 1.0.4 + web-namespaces@2.0.1: {} + webidl-conversions@7.0.0: {} webpack-sources@3.2.3: {} @@ -13253,6 +13465,10 @@ snapshots: toposort: 2.0.2 type-fest: 2.19.0 + zod-to-json-schema@3.24.5(zod@3.24.3): + dependencies: + zod: 3.24.3 + zod-validation-error@3.4.0(zod@3.24.3): dependencies: zod: 3.24.3 diff --git a/site/src/api/api.ts b/site/src/api/api.ts index ef15beb8166f5..688ba0432e22b 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -827,6 +827,13 @@ class ApiMethods { return response.data; }; + getDeploymentLLMs = async (): Promise => { + const response = await this.axios.get( + "/api/v2/deployment/llms", + ); + return response.data; + }; + getOrganizationIdpSyncClaimFieldValues = async ( organization: string, field: string, @@ -2489,6 +2496,23 @@ class ApiMethods { markAllInboxNotificationsAsRead = async () => { await this.axios.put("/api/v2/notifications/inbox/mark-all-as-read"); }; + + createChat = async () => { + const res = await this.axios.post("/api/v2/chats"); + return res.data; + }; + + getChats = async () => { + const res = await this.axios.get("/api/v2/chats"); + return res.data; + }; + + getChatMessages = async (chatId: string) => { + const res = await this.axios.get( + `/api/v2/chats/${chatId}/messages`, + ); + return res.data; + }; } // This is a hard coded CSRF token/cookie pair for local development. In prod, diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts new file mode 100644 index 0000000000000..196bf4c603597 --- /dev/null +++ b/site/src/api/queries/chats.ts @@ -0,0 +1,25 @@ +import { API } from "api/api"; +import type { QueryClient } from "react-query"; + +export const createChat = (queryClient: QueryClient) => { + return { + mutationFn: API.createChat, + onSuccess: async () => { + await queryClient.invalidateQueries(["chats"]); + }, + }; +}; + +export const getChats = () => { + return { + queryKey: ["chats"], + queryFn: API.getChats, + }; +}; + +export const getChatMessages = (chatID: string) => { + return { + queryKey: ["chatMessages", chatID], + queryFn: () => API.getChatMessages(chatID), + }; +}; diff --git a/site/src/api/queries/deployment.ts b/site/src/api/queries/deployment.ts index 999dd2ee4cbd5..463f555d57761 100644 --- a/site/src/api/queries/deployment.ts +++ b/site/src/api/queries/deployment.ts @@ -36,3 +36,10 @@ export const deploymentIdpSyncFieldValues = (field: string) => { queryFn: () => API.getDeploymentIdpSyncFieldValues(field), }; }; + +export const deploymentLanguageModels = () => { + return { + queryKey: ["deployment", "llms"], + queryFn: API.getDeploymentLLMs, + }; +}; diff --git a/site/src/contexts/useAgenticChat.ts b/site/src/contexts/useAgenticChat.ts new file mode 100644 index 0000000000000..97194b4512340 --- /dev/null +++ b/site/src/contexts/useAgenticChat.ts @@ -0,0 +1,16 @@ +import { experiments } from "api/queries/experiments"; + +import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; +import { useQuery } from "react-query"; + +interface AgenticChat { + readonly enabled: boolean; +} + +export const useAgenticChat = (): AgenticChat => { + const { metadata } = useEmbeddedMetadata(); + const enabledExperimentsQuery = useQuery(experiments(metadata.experiments)); + return { + enabled: enabledExperimentsQuery.data?.includes("agentic-chat") ?? false, + }; +}; diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index 0447e762ed67e..8cefde8cb86e3 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -4,6 +4,7 @@ import { Button } from "components/Button/Button"; import { ExternalImage } from "components/ExternalImage/ExternalImage"; import { CoderIcon } from "components/Icons/CoderIcon"; import type { ProxyContextValue } from "contexts/ProxyContext"; +import { useAgenticChat } from "contexts/useAgenticChat"; import { useWebpushNotifications } from "contexts/useWebpushNotifications"; import { NotificationsInbox } from "modules/notifications/NotificationsInbox/NotificationsInbox"; import type { FC } from "react"; @@ -45,8 +46,7 @@ export const NavbarView: FC = ({ canViewAuditLog, proxyContextValue, }) => { - const { subscribed, enabled, loading, subscribe, unsubscribe } = - useWebpushNotifications(); + const webPush = useWebpushNotifications(); return (
@@ -76,13 +76,21 @@ export const NavbarView: FC = ({ />
- {enabled ? ( - subscribed ? ( - ) : ( - ) @@ -132,6 +140,7 @@ interface NavItemsProps { const NavItems: FC = ({ className }) => { const location = useLocation(); + const agenticChat = useAgenticChat(); return ( ); }; diff --git a/site/src/pages/ChatPage/ChatLanding.tsx b/site/src/pages/ChatPage/ChatLanding.tsx new file mode 100644 index 0000000000000..060752f895313 --- /dev/null +++ b/site/src/pages/ChatPage/ChatLanding.tsx @@ -0,0 +1,164 @@ +import { useTheme } from "@emotion/react"; +import SendIcon from "@mui/icons-material/Send"; +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import { createChat } from "api/queries/chats"; +import type { Chat } from "api/typesGenerated"; +import { Margins } from "components/Margins/Margins"; +import { useAuthenticated } from "hooks"; +import { type FC, type FormEvent, useState } from "react"; +import { useMutation, useQueryClient } from "react-query"; +import { useNavigate } from "react-router-dom"; +import { LanguageModelSelector } from "./LanguageModelSelector"; + +export interface ChatLandingLocationState { + chat: Chat; + message: string; +} + +const ChatLanding: FC = () => { + const { user } = useAuthenticated(); + const theme = useTheme(); + const [input, setInput] = useState(""); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const createChatMutation = useMutation(createChat(queryClient)); + + return ( + +
+ {/* Initial Welcome Message Area */} +
+

+ Good evening, {user?.name.split(" ")[0]} +

+

+ How can I help you today? +

+
+ + {/* Input Form and Suggestions - Always Visible */} +
+ + + + + + + ) => { + e.preventDefault(); + setInput(""); + const chat = await createChatMutation.mutateAsync(); + navigate(`/chat/${chat.id}`, { + state: { + chat, + message: input, + }, + }); + }} + elevation={2} + css={{ + padding: "16px", + display: "flex", + alignItems: "center", + width: "100%", + borderRadius: "12px", + border: `1px solid ${theme.palette.divider}`, + }} + > + ) => { + setInput(event.target.value); + }} + placeholder="Ask Coder..." + required + fullWidth + variant="outlined" + multiline + maxRows={5} + css={{ + marginRight: theme.spacing(1), + "& .MuiOutlinedInput-root": { + borderRadius: "8px", + padding: "10px 14px", + }, + }} + autoFocus + /> + + + + +
+
+
+ ); +}; + +export default ChatLanding; diff --git a/site/src/pages/ChatPage/ChatLayout.tsx b/site/src/pages/ChatPage/ChatLayout.tsx new file mode 100644 index 0000000000000..77de96af01595 --- /dev/null +++ b/site/src/pages/ChatPage/ChatLayout.tsx @@ -0,0 +1,246 @@ +import { useTheme } from "@emotion/react"; +import AddIcon from "@mui/icons-material/Add"; +import Button from "@mui/material/Button"; +import List from "@mui/material/List"; +import ListItem from "@mui/material/ListItem"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemText from "@mui/material/ListItemText"; +import Paper from "@mui/material/Paper"; +import { createChat, getChats } from "api/queries/chats"; +import { deploymentLanguageModels } from "api/queries/deployment"; +import type { LanguageModelConfig } from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { Loader } from "components/Loader/Loader"; +import { Margins } from "components/Margins/Margins"; +import { useAgenticChat } from "contexts/useAgenticChat"; +import { + type FC, + type PropsWithChildren, + createContext, + useContext, + useEffect, + useState, +} from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { Link, Outlet, useNavigate, useParams } from "react-router-dom"; + +export interface ChatContext { + selectedModel: string; + modelConfig: LanguageModelConfig; + + setSelectedModel: (model: string) => void; +} +export const useChatContext = (): ChatContext => { + const context = useContext(ChatContext); + if (!context) { + throw new Error("useChatContext must be used within a ChatProvider"); + } + return context; +}; + +export const ChatContext = createContext(undefined); + +const SELECTED_MODEL_KEY = "coder_chat_selected_model"; + +const ChatProvider: FC = ({ children }) => { + const [selectedModel, setSelectedModel] = useState(() => { + const savedModel = localStorage.getItem(SELECTED_MODEL_KEY); + return savedModel || ""; + }); + const modelConfigQuery = useQuery(deploymentLanguageModels()); + useEffect(() => { + if (!modelConfigQuery.data) { + return; + } + if (selectedModel === "") { + const firstModel = modelConfigQuery.data.models[0]?.id; // Handle empty models array + if (firstModel) { + setSelectedModel(firstModel); + localStorage.setItem(SELECTED_MODEL_KEY, firstModel); + } + } + }, [modelConfigQuery.data, selectedModel]); + + if (modelConfigQuery.error) { + return ; + } + + if (!modelConfigQuery.data) { + return ; + } + + const handleSetSelectedModel = (model: string) => { + setSelectedModel(model); + localStorage.setItem(SELECTED_MODEL_KEY, model); + }; + + return ( + + {children} + + ); +}; + +export const ChatLayout: FC = () => { + const agenticChat = useAgenticChat(); + const queryClient = useQueryClient(); + const { data: chats, isLoading: chatsLoading } = useQuery(getChats()); + const createChatMutation = useMutation(createChat(queryClient)); + const theme = useTheme(); + const navigate = useNavigate(); + const { chatID } = useParams<{ chatID?: string }>(); + + const handleNewChat = () => { + navigate("/chat"); + }; + + if (!agenticChat.enabled) { + return ( + +
+

Agentic Chat is not enabled

+

+ Agentic Chat is an experimental feature and is not enabled by + default. Please contact your administrator for more information. +

+
+
+ ); + } + + return ( + // Outermost container: controls height and prevents page scroll +
+ {/* Sidebar Container (using Paper for background/border) */} + + {/* Sidebar Header */} +
+ {/* Replaced Typography with div + styling */} +
+ Chats +
+ +
+ {/* Sidebar Scrollable List Area */} +
+ {chatsLoading ? ( + + ) : chats && chats.length > 0 ? ( + + {chats.map((chat) => ( + + + + + + ))} + + ) : ( + // Replaced Typography with div + styling +
+ No chats yet. Start a new one! +
+ )} +
+
+ + {/* Main Content Area Container */} +
+ + {/* Outlet renders ChatMessages, which should have its own internal scroll */} + + +
+
+ ); +}; diff --git a/site/src/pages/ChatPage/ChatMessages.tsx b/site/src/pages/ChatPage/ChatMessages.tsx new file mode 100644 index 0000000000000..928b3c9ee2724 --- /dev/null +++ b/site/src/pages/ChatPage/ChatMessages.tsx @@ -0,0 +1,491 @@ +import { type Message, useChat } from "@ai-sdk/react"; +import { type Theme, keyframes, useTheme } from "@emotion/react"; +import SendIcon from "@mui/icons-material/Send"; +import IconButton from "@mui/material/IconButton"; +import Paper from "@mui/material/Paper"; +import TextField from "@mui/material/TextField"; +import { getChatMessages } from "api/queries/chats"; +import type { ChatMessage, CreateChatMessageRequest } from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { Loader } from "components/Loader/Loader"; +import { + type FC, + type KeyboardEvent, + memo, + useCallback, + useEffect, + useRef, +} from "react"; +import ReactMarkdown from "react-markdown"; +import { useQuery } from "react-query"; +import { useLocation, useParams } from "react-router-dom"; +import rehypeRaw from "rehype-raw"; +import remarkGfm from "remark-gfm"; +import type { ChatLandingLocationState } from "./ChatLanding"; +import { useChatContext } from "./ChatLayout"; +import { ChatToolInvocation } from "./ChatToolInvocation"; +import { LanguageModelSelector } from "./LanguageModelSelector"; + +const fadeIn = keyframes` + from { + opacity: 0; + transform: translateY(5px); + } + to { + opacity: 1; + transform: translateY(0); + } +`; + +const renderReasoning = (reasoning: string, theme: Theme) => ( +
+
+ 💭 Reasoning: +
+
+ {reasoning} +
+
+); + +interface MessageBubbleProps { + message: Message; +} + +const MessageBubble: FC = memo(({ message }) => { + const theme = useTheme(); + const isUser = message.role === "user"; + + return ( +
+ code)": { + backgroundColor: isUser + ? theme.palette.grey[700] + : theme.palette.action.hover, + color: isUser ? theme.palette.grey[50] : theme.palette.text.primary, + padding: theme.spacing(0.25, 0.75), + borderRadius: "4px", + fontSize: "0.875em", + fontFamily: "monospace", + }, + "& pre": { + backgroundColor: isUser + ? theme.palette.common.black + : theme.palette.grey[100], + color: isUser + ? theme.palette.grey[100] + : theme.palette.text.primary, + padding: theme.spacing(1.5), + borderRadius: "8px", + overflowX: "auto", + margin: theme.spacing(1.5, 0), + width: "100%", + "& code": { + backgroundColor: "transparent", + padding: 0, + fontSize: "0.875em", + fontFamily: "monospace", + color: "inherit", + }, + }, + "& a": { + color: isUser + ? theme.palette.grey[100] + : theme.palette.primary.main, + textDecoration: "underline", + fontWeight: 500, + "&:hover": { + textDecoration: "none", + color: isUser + ? theme.palette.grey[300] + : theme.palette.primary.dark, + }, + }, + }} + > + {message.role === "assistant" && message.parts ? ( +
+ {message.parts.map((part) => { + switch (part.type) { + case "text": + return ( + + {part.text} + + ); + case "tool-invocation": + return ( +
+ +
+ ); + case "reasoning": + return ( +
+ {renderReasoning(part.reasoning, theme)} +
+ ); + default: + return null; + } + })} +
+ ) : ( + + {message.content} + + )} +
+
+ ); +}); + +interface ChatViewProps { + messages: Message[]; + input: string; + handleInputChange: React.ChangeEventHandler< + HTMLInputElement | HTMLTextAreaElement + >; + handleSubmit: (e?: React.FormEvent) => void; + isLoading: boolean; + chatID: string; +} + +const ChatView: FC = ({ + messages, + input, + handleInputChange, + handleSubmit, + isLoading, +}) => { + const theme = useTheme(); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + const chatContext = useChatContext(); + + useEffect(() => { + const timer = setTimeout(() => { + messagesEndRef.current?.scrollIntoView({ + behavior: "smooth", + block: "end", + }); + }, 50); + return () => clearTimeout(timer); + }, []); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + handleSubmit(); + } + }; + + return ( +
+
+
+ {messages.map((message) => ( + + ))} +
+
+
+ +
+ +
+ +
+ + + + +
+
+
+ ); +}; + +export const ChatMessages: FC = () => { + const { chatID } = useParams(); + if (!chatID) { + throw new Error("Chat ID is required in URL path /chat/:chatID"); + } + + const { state } = useLocation(); + const transferredState = state as ChatLandingLocationState | undefined; + + const messagesQuery = useQuery(getChatMessages(chatID)); + + const chatContext = useChatContext(); + + const { + messages, + input, + handleInputChange, + handleSubmit: originalHandleSubmit, + isLoading, + setInput, + setMessages, + } = useChat({ + id: chatID, + api: `/api/v2/chats/${chatID}/messages`, + experimental_prepareRequestBody: (options): CreateChatMessageRequest => { + const userMessages = options.messages.filter( + (message) => message.role === "user", + ); + const mostRecentUserMessage = userMessages.at(-1); + return { + model: chatContext.selectedModel, + message: mostRecentUserMessage, + thinking: false, + }; + }, + initialInput: transferredState?.message, + initialMessages: messagesQuery.data as Message[] | undefined, + }); + + // Update messages from query data when it loads + useEffect(() => { + if (messagesQuery.data && messages.length === 0) { + setMessages(messagesQuery.data as Message[]); + } + }, [messagesQuery.data, messages.length, setMessages]); + + const handleSubmitCallback = useCallback( + (e?: React.FormEvent) => { + if (e) e.preventDefault(); + if (!input.trim()) return; + originalHandleSubmit(); + setInput(""); // Clear input after submit + }, + [input, originalHandleSubmit, setInput], + ); + + // Clear input and potentially submit on initial load with message + useEffect(() => { + if (transferredState?.message && input === transferredState.message) { + // Prevent submitting if messages already exist (e.g., browser back/forward) + if (messages.length === (messagesQuery.data?.length ?? 0)) { + handleSubmitCallback(); // Use the correct callback name + } + // Clear the state to prevent re-submission on subsequent renders/navigation + window.history.replaceState({}, document.title); + } + }, [ + transferredState?.message, + input, + handleSubmitCallback, + messages.length, + messagesQuery.data?.length, + ]); // Use the correct callback name + + useEffect(() => { + if (transferredState?.message) { + // Logic potentially related to transferredState can go here if needed, + } + }, [transferredState?.message]); + + if (messagesQuery.error) { + return ; + } + + if (messagesQuery.isLoading && messages.length === 0) { + return ; + } + + return ( + + ); +}; diff --git a/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx b/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx new file mode 100644 index 0000000000000..03bf31cb095fb --- /dev/null +++ b/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx @@ -0,0 +1,1211 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + MockStartingWorkspace, + MockStoppedWorkspace, + MockStoppingWorkspace, + MockTemplate, + MockTemplateVersion, + MockUserMember, + MockWorkspace, + MockWorkspaceBuild, +} from "testHelpers/entities"; +import { ChatToolInvocation } from "./ChatToolInvocation"; + +const meta: Meta = { + title: "pages/ChatPage/ChatToolInvocation", + component: ChatToolInvocation, +}; + +export default meta; +type Story = StoryObj; + +export const GetWorkspace: Story = { + render: () => + renderInvocations( + "coder_get_workspace", + { + workspace_id: MockWorkspace.id, + }, + MockWorkspace, + ), +}; + +export const CreateWorkspace: Story = { + render: () => + renderInvocations( + "coder_create_workspace", + { + name: MockWorkspace.name, + rich_parameters: {}, + template_version_id: MockWorkspace.template_active_version_id, + user: MockWorkspace.owner_name, + }, + MockWorkspace, + ), +}; + +export const ListWorkspaces: Story = { + render: () => + renderInvocations( + "coder_list_workspaces", + { + owner: "me", + }, + [ + MockWorkspace, + MockStoppedWorkspace, + MockStoppingWorkspace, + MockStartingWorkspace, + ], + ), +}; + +export const ListTemplates: Story = { + render: () => + renderInvocations("coder_list_templates", {}, [ + { + id: MockTemplate.id, + name: MockTemplate.name, + description: MockTemplate.description, + active_version_id: MockTemplate.active_version_id, + active_user_count: MockTemplate.active_user_count, + }, + { + id: "another-template", + name: "Another Template", + description: "A different template for testing purposes.", + active_version_id: "v2.0", + active_user_count: 5, + }, + ]), +}; + +export const TemplateVersionParameters: Story = { + render: () => + renderInvocations( + "coder_template_version_parameters", + { + template_version_id: MockTemplateVersion.id, + }, + [ + { + name: "region", + display_name: "Region", + description: "Select the deployment region.", + description_plaintext: "Select the deployment region.", + type: "string", + mutable: false, + default_value: "us-west-1", + icon: "", + options: [ + { name: "US West", description: "", value: "us-west-1", icon: "" }, + { name: "US East", description: "", value: "us-east-1", icon: "" }, + ], + required: true, + ephemeral: false, + }, + { + name: "cpu_cores", + display_name: "CPU Cores", + description: "Number of CPU cores.", + description_plaintext: "Number of CPU cores.", + type: "number", + mutable: true, + default_value: "4", + icon: "", + options: [], + required: false, + ephemeral: false, + }, + ], + ), +}; + +export const GetAuthenticatedUser: Story = { + render: () => + renderInvocations("coder_get_authenticated_user", {}, MockUserMember), +}; + +export const CreateWorkspaceBuild: Story = { + render: () => + renderInvocations( + "coder_create_workspace_build", + { + workspace_id: MockWorkspace.id, + transition: "start", + }, + MockWorkspaceBuild, + ), +}; + +export const CreateTemplateVersion: Story = { + render: () => + renderInvocations( + "coder_create_template_version", + { + template_id: MockTemplate.id, + file_id: "file-123", + }, + MockTemplateVersion, + ), +}; + +const mockLogs = [ + "[INFO] Starting build process...", + "[DEBUG] Reading configuration file.", + "[WARN] Deprecated setting detected.", + "[INFO] Applying changes...", + "[ERROR] Failed to connect to database.", +]; + +export const GetWorkspaceAgentLogs: Story = { + render: () => + renderInvocations( + "coder_get_workspace_agent_logs", + { + workspace_agent_id: "agent-456", + }, + mockLogs, + ), +}; + +export const GetWorkspaceBuildLogs: Story = { + render: () => + renderInvocations( + "coder_get_workspace_build_logs", + { + workspace_build_id: MockWorkspaceBuild.id, + }, + mockLogs, + ), +}; + +export const GetTemplateVersionLogs: Story = { + render: () => + renderInvocations( + "coder_get_template_version_logs", + { + template_version_id: MockTemplateVersion.id, + }, + mockLogs, + ), +}; + +export const UpdateTemplateActiveVersion: Story = { + render: () => + renderInvocations( + "coder_update_template_active_version", + { + template_id: MockTemplate.id, + template_version_id: MockTemplateVersion.id, + }, + `Successfully updated active version for template ${MockTemplate.name}.`, + ), +}; + +export const UploadTarFile: Story = { + render: () => + renderInvocations( + "coder_upload_tar_file", + { + files: { "main.tf": templateTerraform, Dockerfile: templateDockerfile }, + }, + { + hash: "sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + }, + ), +}; + +export const CreateTemplate: Story = { + render: () => + renderInvocations( + "coder_create_template", + { + name: "new-template", + }, + MockTemplate, + ), +}; + +export const DeleteTemplate: Story = { + render: () => + renderInvocations( + "coder_delete_template", + { + template_id: MockTemplate.id, + }, + `Successfully deleted template ${MockTemplate.name}.`, + ), +}; + +export const GetTemplateVersion: Story = { + render: () => + renderInvocations( + "coder_get_template_version", + { + template_version_id: MockTemplateVersion.id, + }, + MockTemplateVersion, + ), +}; + +export const DownloadTarFile: Story = { + render: () => + renderInvocations( + "coder_download_tar_file", + { + file_id: "file-789", + }, + { "main.tf": templateTerraform, "README.md": "# My Template\n" }, + ), +}; + +const renderInvocations = ( + toolName: T, + args: Extract["args"], + result: Extract< + ChatToolInvocation, + { toolName: T; state: "result" } + >["result"], + error?: string, +) => { + return ( + <> + + + + + + ); +}; + +const templateDockerfile = `FROM rust:slim@sha256:9abf10cc84dfad6ace1b0aae3951dc5200f467c593394288c11db1e17bb4d349 AS rust-utils +# Install rust helper programs +# ENV CARGO_NET_GIT_FETCH_WITH_CLI=true +ENV CARGO_INSTALL_ROOT=/tmp/ +RUN cargo install typos-cli watchexec-cli && \ + # Reduce image size. + rm -rf /usr/local/cargo/registry + +FROM ubuntu:jammy@sha256:0e5e4a57c2499249aafc3b40fcd541e9a456aab7296681a3994d631587203f97 AS go + +# Install Go manually, so that we can control the version +ARG GO_VERSION=1.24.1 + +# Boring Go is needed to build FIPS-compliant binaries. +RUN apt-get update && \ + apt-get install --yes curl && \ + curl --silent --show-error --location \ + "https://go.dev/dl/go\${GO_VERSION}.linux-amd64.tar.gz" \ + -o /usr/local/go.tar.gz && \ + rm -rf /var/lib/apt/lists/* + +ENV PATH=$PATH:/usr/local/go/bin +ARG GOPATH="/tmp/" +# Install Go utilities. +RUN apt-get update && \ + apt-get install --yes gcc && \ + mkdir --parents /usr/local/go && \ + tar --extract --gzip --directory=/usr/local/go --file=/usr/local/go.tar.gz --strip-components=1 && \ + mkdir --parents "$GOPATH" && \ + # moq for Go tests. + go install github.com/matryer/moq@v0.2.3 && \ + # swag for Swagger doc generation + go install github.com/swaggo/swag/cmd/swag@v1.7.4 && \ + # go-swagger tool to generate the go coder api client + go install github.com/go-swagger/go-swagger/cmd/swagger@v0.28.0 && \ + # goimports for updating imports + go install golang.org/x/tools/cmd/goimports@v0.31.0 && \ + # protoc-gen-go is needed to build sysbox from source + go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30 && \ + # drpc support for v2 + go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.34 && \ + # migrate for migration support for v2 + go install github.com/golang-migrate/migrate/v4/cmd/migrate@v4.15.1 && \ + # goreleaser for compiling v2 binaries + go install github.com/goreleaser/goreleaser@v1.6.1 && \ + # Install the latest version of gopls for editors that support + # the language server protocol + go install golang.org/x/tools/gopls@v0.18.1 && \ + # gotestsum makes test output more readable + go install gotest.tools/gotestsum@v1.9.0 && \ + # goveralls collects code coverage metrics from tests + # and sends to Coveralls + go install github.com/mattn/goveralls@v0.0.11 && \ + # kind for running Kubernetes-in-Docker, needed for tests + go install sigs.k8s.io/kind@v0.10.0 && \ + # helm-docs generates our Helm README based on a template and the + # charts and values files + go install github.com/norwoodj/helm-docs/cmd/helm-docs@v1.5.0 && \ + # sqlc for Go code generation + (CGO_ENABLED=1 go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.27.0) && \ + # gcr-cleaner-cli used by CI to prune unused images + go install github.com/sethvargo/gcr-cleaner/cmd/gcr-cleaner-cli@v0.5.1 && \ + # ruleguard for checking custom rules, without needing to run all of + # golangci-lint. Check the go.mod in the release of golangci-lint that + # we're using for the version of go-critic that it embeds, then check + # the version of ruleguard in go-critic for that tag. + go install github.com/quasilyte/go-ruleguard/cmd/ruleguard@v0.3.13 && \ + # go-releaser for building 'fat binaries' that work cross-platform + go install github.com/goreleaser/goreleaser@v1.6.1 && \ + go install mvdan.cc/sh/v3/cmd/shfmt@v3.7.0 && \ + # nfpm is used with \`make build\` to make release packages + go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.35.1 && \ + # yq v4 is used to process yaml files in coder v2. Conflicts with + # yq v3 used in v1. + go install github.com/mikefarah/yq/v4@v4.44.3 && \ + mv /tmp/bin/yq /tmp/bin/yq4 && \ + go install go.uber.org/mock/mockgen@v0.5.0 && \ + # Reduce image size. + apt-get remove --yes gcc && \ + apt-get autoremove --yes && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* && \ + rm -rf /usr/local/go && \ + rm -rf /tmp/go/pkg && \ + rm -rf /tmp/go/src + +# alpine:3.18 +FROM gcr.io/coder-dev-1/alpine@sha256:25fad2a32ad1f6f510e528448ae1ec69a28ef81916a004d3629874104f8a7f70 AS proto +WORKDIR /tmp +RUN apk add curl unzip +RUN curl -L -o protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v23.4/protoc-23.4-linux-x86_64.zip && \ + unzip protoc.zip && \ + rm protoc.zip + +FROM ubuntu:jammy@sha256:0e5e4a57c2499249aafc3b40fcd541e9a456aab7296681a3994d631587203f97 + +SHELL ["/bin/bash", "-c"] + +# Install packages from apt repositories +ARG DEBIAN_FRONTEND="noninteractive" + +# Updated certificates are necessary to use the teraswitch mirror. +# This must be ran before copying in configuration since the config replaces +# the default mirror with teraswitch. +# Also enable the en_US.UTF-8 locale so that we don't generate multiple locales +# and unminimize to include man pages. +RUN apt-get update && \ + apt-get install --yes ca-certificates locales && \ + echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen && \ + locale-gen && \ + yes | unminimize + +COPY files / + +# We used to copy /etc/sudoers.d/* in from files/ but this causes issues with +# permissions and layer caching. Instead, create the file directly. +RUN mkdir -p /etc/sudoers.d && \ + echo 'coder ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/nopasswd && \ + chmod 750 /etc/sudoers.d/ && \ + chmod 640 /etc/sudoers.d/nopasswd + +RUN apt-get update --quiet && apt-get install --yes \ + ansible \ + apt-transport-https \ + apt-utils \ + asciinema \ + bash \ + bash-completion \ + bat \ + bats \ + bind9-dnsutils \ + build-essential \ + ca-certificates \ + cargo \ + cmake \ + containerd.io \ + crypto-policies \ + curl \ + docker-ce \ + docker-ce-cli \ + docker-compose-plugin \ + exa \ + fd-find \ + file \ + fish \ + gettext-base \ + git \ + gnupg \ + google-cloud-sdk \ + google-cloud-sdk-datastore-emulator \ + graphviz \ + helix \ + htop \ + httpie \ + inetutils-tools \ + iproute2 \ + iputils-ping \ + iputils-tracepath \ + jq \ + kubectl \ + language-pack-en \ + less \ + libgbm-dev \ + libssl-dev \ + lsb-release \ + lsof \ + man \ + meld \ + ncdu \ + neovim \ + net-tools \ + openjdk-11-jdk-headless \ + openssh-server \ + openssl \ + packer \ + pkg-config \ + postgresql-16 \ + python3 \ + python3-pip \ + ripgrep \ + rsync \ + screen \ + shellcheck \ + strace \ + sudo \ + tcptraceroute \ + termshark \ + traceroute \ + unzip \ + vim \ + wget \ + xauth \ + zip \ + zsh \ + zstd && \ + # Delete package cache to avoid consuming space in layer + apt-get clean && \ + # Configure FIPS-compliant policies + update-crypto-policies --set FIPS + +# NOTE: In scripts/Dockerfile.base we specifically install Terraform version 1.11.3. +# Installing the same version here to match. +RUN wget -O /tmp/terraform.zip "https://releases.hashicorp.com/terraform/1.11.3/terraform_1.11.3_linux_amd64.zip" && \ + unzip /tmp/terraform.zip -d /usr/local/bin && \ + rm -f /tmp/terraform.zip && \ + chmod +x /usr/local/bin/terraform && \ + terraform --version + +# Install the docker buildx component. +RUN DOCKER_BUILDX_VERSION=$(curl -s "https://api.github.com/repos/docker/buildx/releases/latest" | grep '"tag_name":' | sed -E 's/.*"(v[^"]+)".*/\\1/') && \ + mkdir -p /usr/local/lib/docker/cli-plugins && \ + curl -Lo /usr/local/lib/docker/cli-plugins/docker-buildx "https://github.com/docker/buildx/releases/download/\${DOCKER_BUILDX_VERSION}/buildx-\${DOCKER_BUILDX_VERSION}.linux-amd64" && \ + chmod a+x /usr/local/lib/docker/cli-plugins/docker-buildx + +# See https://github.com/cli/cli/issues/6175#issuecomment-1235984381 for proof +# the apt repository is unreliable +RUN GH_CLI_VERSION=$(curl -s "https://api.github.com/repos/cli/cli/releases/latest" | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\\1/') && \ + curl -L https://github.com/cli/cli/releases/download/v\${GH_CLI_VERSION}/gh_\${GH_CLI_VERSION}_linux_amd64.deb -o gh.deb && \ + dpkg -i gh.deb && \ + rm gh.deb + +# Install Lazygit +# See https://github.com/jesseduffield/lazygit#ubuntu +RUN LAZYGIT_VERSION=$(curl -s "https://api.github.com/repos/jesseduffield/lazygit/releases/latest" | grep '"tag_name":' | sed -E 's/.*"v*([^"]+)".*/\\1/') && \ + curl -Lo lazygit.tar.gz "https://github.com/jesseduffield/lazygit/releases/latest/download/lazygit_\${LAZYGIT_VERSION}_Linux_x86_64.tar.gz" && \ + tar xf lazygit.tar.gz -C /usr/local/bin lazygit && \ + rm lazygit.tar.gz + +# Install doctl +# See https://docs.digitalocean.com/reference/doctl/how-to/install +RUN DOCTL_VERSION=$(curl -s "https://api.github.com/repos/digitalocean/doctl/releases/latest" | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\\1/') && \ + curl -L https://github.com/digitalocean/doctl/releases/download/v\${DOCTL_VERSION}/doctl-\${DOCTL_VERSION}-linux-amd64.tar.gz -o doctl.tar.gz && \ + tar xf doctl.tar.gz -C /usr/local/bin doctl && \ + rm doctl.tar.gz + +ARG NVM_INSTALL_SHA=bdea8c52186c4dd12657e77e7515509cda5bf9fa5a2f0046bce749e62645076d +# Install frontend utilities +ENV NVM_DIR=/usr/local/nvm +ENV NODE_VERSION=20.16.0 +RUN mkdir -p $NVM_DIR +RUN curl -o nvm_install.sh https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh && \ + echo "\${NVM_INSTALL_SHA} nvm_install.sh" | sha256sum -c && \ + bash nvm_install.sh && \ + rm nvm_install.sh +RUN source $NVM_DIR/nvm.sh && \ + nvm install $NODE_VERSION && \ + nvm use $NODE_VERSION +ENV PATH=$NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH +# Allow patch updates for npm and pnpm +RUN npm install -g npm@10.8.1 --integrity=sha512-Dp1C6SvSMYQI7YHq/y2l94uvI+59Eqbu1EpuKQHQ8p16txXRuRit5gH3Lnaagk2aXDIjg/Iru9pd05bnneKgdw== +RUN npm install -g pnpm@9.15.1 --integrity=sha512-GstWXmGT7769p3JwKVBGkVDPErzHZCYudYfnHRncmKQj3/lTblfqRMSb33kP9pToPCe+X6oj1n4MAztYO+S/zw== + +RUN pnpx playwright@1.47.0 install --with-deps chromium + +# Ensure PostgreSQL binaries are in the users $PATH. +RUN update-alternatives --install /usr/local/bin/initdb initdb /usr/lib/postgresql/16/bin/initdb 100 && \ + update-alternatives --install /usr/local/bin/postgres postgres /usr/lib/postgresql/16/bin/postgres 100 + +# Create links for injected dependencies +RUN ln --symbolic /var/tmp/coder/coder-cli/coder /usr/local/bin/coder && \ + ln --symbolic /var/tmp/coder/code-server/bin/code-server /usr/local/bin/code-server + +# Disable the PostgreSQL systemd service. +# Coder uses a custom timescale container to test the database instead. +RUN systemctl disable \ + postgresql + +# Configure systemd services for CVMs +RUN systemctl enable \ + docker \ + ssh && \ + # Workaround for envbuilder cache probing not working unless the filesystem is modified. + touch /tmp/.envbuilder-systemctl-enable-docker-ssh-workaround + +# Install tools with published releases, where that is the +# preferred/recommended installation method. +ARG CLOUD_SQL_PROXY_VERSION=2.2.0 \ + DIVE_VERSION=0.10.0 \ + DOCKER_GCR_VERSION=2.1.8 \ + GOLANGCI_LINT_VERSION=1.64.8 \ + GRYPE_VERSION=0.61.1 \ + HELM_VERSION=3.12.0 \ + KUBE_LINTER_VERSION=0.6.3 \ + KUBECTX_VERSION=0.9.4 \ + STRIPE_VERSION=1.14.5 \ + TERRAGRUNT_VERSION=0.45.11 \ + TRIVY_VERSION=0.41.0 \ + SYFT_VERSION=1.20.0 \ + COSIGN_VERSION=2.4.3 + +# cloud_sql_proxy, for connecting to cloudsql instances +# the upstream go.mod prevents this from being installed with go install +RUN curl --silent --show-error --location --output /usr/local/bin/cloud_sql_proxy "https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v\${CLOUD_SQL_PROXY_VERSION}/cloud-sql-proxy.linux.amd64" && \ + chmod a=rx /usr/local/bin/cloud_sql_proxy && \ + # dive for scanning image layer utilization metrics in CI + curl --silent --show-error --location "https://github.com/wagoodman/dive/releases/download/v\${DIVE_VERSION}/dive_\${DIVE_VERSION}_linux_amd64.tar.gz" | \ + tar --extract --gzip --directory=/usr/local/bin --file=- dive && \ + # docker-credential-gcr is a Docker credential helper for pushing/pulling + # images from Google Container Registry and Artifact Registry + curl --silent --show-error --location "https://github.com/GoogleCloudPlatform/docker-credential-gcr/releases/download/v\${DOCKER_GCR_VERSION}/docker-credential-gcr_linux_amd64-\${DOCKER_GCR_VERSION}.tar.gz" | \ + tar --extract --gzip --directory=/usr/local/bin --file=- docker-credential-gcr && \ + # golangci-lint performs static code analysis for our Go code + curl --silent --show-error --location "https://github.com/golangci/golangci-lint/releases/download/v\${GOLANGCI_LINT_VERSION}/golangci-lint-\${GOLANGCI_LINT_VERSION}-linux-amd64.tar.gz" | \ + tar --extract --gzip --directory=/usr/local/bin --file=- --strip-components=1 "golangci-lint-\${GOLANGCI_LINT_VERSION}-linux-amd64/golangci-lint" && \ + # Anchore Grype for scanning container images for security issues + curl --silent --show-error --location "https://github.com/anchore/grype/releases/download/v\${GRYPE_VERSION}/grype_\${GRYPE_VERSION}_linux_amd64.tar.gz" | \ + tar --extract --gzip --directory=/usr/local/bin --file=- grype && \ + # Helm is necessary for deploying Coder + curl --silent --show-error --location "https://get.helm.sh/helm-v\${HELM_VERSION}-linux-amd64.tar.gz" | \ + tar --extract --gzip --directory=/usr/local/bin --file=- --strip-components=1 linux-amd64/helm && \ + # kube-linter for linting Kubernetes objects, including those + # that Helm generates from our charts + curl --silent --show-error --location "https://github.com/stackrox/kube-linter/releases/download/\${KUBE_LINTER_VERSION}/kube-linter-linux" --output /usr/local/bin/kube-linter && \ + # kubens and kubectx for managing Kubernetes namespaces and contexts + curl --silent --show-error --location "https://github.com/ahmetb/kubectx/releases/download/v\${KUBECTX_VERSION}/kubectx_v\${KUBECTX_VERSION}_linux_x86_64.tar.gz" | \ + tar --extract --gzip --directory=/usr/local/bin --file=- kubectx && \ + curl --silent --show-error --location "https://github.com/ahmetb/kubectx/releases/download/v\${KUBECTX_VERSION}/kubens_v\${KUBECTX_VERSION}_linux_x86_64.tar.gz" | \ + tar --extract --gzip --directory=/usr/local/bin --file=- kubens && \ + # stripe for coder.com billing API + curl --silent --show-error --location "https://github.com/stripe/stripe-cli/releases/download/v\${STRIPE_VERSION}/stripe_\${STRIPE_VERSION}_linux_x86_64.tar.gz" | \ + tar --extract --gzip --directory=/usr/local/bin --file=- stripe && \ + # terragrunt for running Terraform and Terragrunt files + curl --silent --show-error --location --output /usr/local/bin/terragrunt "https://github.com/gruntwork-io/terragrunt/releases/download/v\${TERRAGRUNT_VERSION}/terragrunt_linux_amd64" && \ + chmod a=rx /usr/local/bin/terragrunt && \ + # AquaSec Trivy for scanning container images for security issues + curl --silent --show-error --location "https://github.com/aquasecurity/trivy/releases/download/v\${TRIVY_VERSION}/trivy_\${TRIVY_VERSION}_Linux-64bit.tar.gz" | \ + tar --extract --gzip --directory=/usr/local/bin --file=- trivy && \ + # Anchore Syft for SBOM generation + curl --silent --show-error --location "https://github.com/anchore/syft/releases/download/v\${SYFT_VERSION}/syft_\${SYFT_VERSION}_linux_amd64.tar.gz" | \ + tar --extract --gzip --directory=/usr/local/bin --file=- syft && \ + # Sigstore Cosign for artifact signing and attestation + curl --silent --show-error --location --output /usr/local/bin/cosign "https://github.com/sigstore/cosign/releases/download/v\${COSIGN_VERSION}/cosign-linux-amd64" && \ + chmod a=rx /usr/local/bin/cosign + +# We use yq during "make deploy" to manually substitute out fields in +# our helm values.yaml file. See https://github.com/helm/helm/issues/3141 +# +# TODO: update to 4.x, we can't do this now because it included breaking +# changes (yq w doesn't work anymore) +# RUN curl --silent --show-error --location "https://github.com/mikefarah/yq/releases/download/v4.9.0/yq_linux_amd64.tar.gz" | \ +# tar --extract --gzip --directory=/usr/local/bin --file=- ./yq_linux_amd64 && \ +# mv /usr/local/bin/yq_linux_amd64 /usr/local/bin/yq + +RUN curl --silent --show-error --location --output /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/3.3.0/yq_linux_amd64" && \ + chmod a=rx /usr/local/bin/yq + +# Install GoLand. +RUN mkdir --parents /usr/local/goland && \ + curl --silent --show-error --location "https://download.jetbrains.com/go/goland-2021.2.tar.gz" | \ + tar --extract --gzip --directory=/usr/local/goland --file=- --strip-components=1 && \ + ln --symbolic /usr/local/goland/bin/goland.sh /usr/local/bin/goland + +# Install Antlrv4, needed to generate paramlang lexer/parser +RUN curl --silent --show-error --location --output /usr/local/lib/antlr-4.9.2-complete.jar "https://www.antlr.org/download/antlr-4.9.2-complete.jar" +ENV CLASSPATH="/usr/local/lib/antlr-4.9.2-complete.jar:\${PATH}" + +# Add coder user and allow use of docker/sudo +RUN useradd coder \ + --create-home \ + --shell=/bin/bash \ + --groups=docker \ + --uid=1000 \ + --user-group + +# Adjust OpenSSH config +RUN echo "PermitUserEnvironment yes" >>/etc/ssh/sshd_config && \ + echo "X11Forwarding yes" >>/etc/ssh/sshd_config && \ + echo "X11UseLocalhost no" >>/etc/ssh/sshd_config + +# We avoid copying the extracted directory since COPY slows to minutes when there +# are a lot of small files. +COPY --from=go /usr/local/go.tar.gz /usr/local/go.tar.gz +RUN mkdir /usr/local/go && \ + tar --extract --gzip --directory=/usr/local/go --file=/usr/local/go.tar.gz --strip-components=1 + +ENV PATH=$PATH:/usr/local/go/bin + +RUN update-alternatives --install /usr/local/bin/gofmt gofmt /usr/local/go/bin/gofmt 100 + +COPY --from=go /tmp/bin /usr/local/bin +COPY --from=rust-utils /tmp/bin /usr/local/bin +COPY --from=proto /tmp/bin /usr/local/bin +COPY --from=proto /tmp/include /usr/local/bin/include + +USER coder + +# Ensure go bins are in the 'coder' user's path. Note that no go bins are +# installed in this docker file, as they'd be mounted over by the persistent +# home volume. +ENV PATH="/home/coder/go/bin:\${PATH}" + +# This setting prevents Go from using the public checksum database for +# our module path prefixes. It is required because these are in private +# repositories that require authentication. +# +# For details, see: https://golang.org/ref/mod#private-modules +ENV GOPRIVATE="coder.com,cdr.dev,go.coder.com,github.com/cdr,github.com/coder" + +# Increase memory allocation to NodeJS +ENV NODE_OPTIONS="--max-old-space-size=8192" +`; + +const templateTerraform = `terraform { + required_providers { + coder = { + source = "coder/coder" + version = "2.2.0-pre0" + } + docker = { + source = "kreuzwerker/docker" + version = "~> 3.0.0" + } + } +} + +locals { + // These are cluster service addresses mapped to Tailscale nodes. Ask Dean or + // Kyle for help. + docker_host = { + "" = "tcp://dogfood-ts-cdr-dev.tailscale.svc.cluster.local:2375" + "us-pittsburgh" = "tcp://dogfood-ts-cdr-dev.tailscale.svc.cluster.local:2375" + // For legacy reasons, this host is labelled \`eu-helsinki\` but it's + // actually in Germany now. + "eu-helsinki" = "tcp://katerose-fsn-cdr-dev.tailscale.svc.cluster.local:2375" + "ap-sydney" = "tcp://wolfgang-syd-cdr-dev.tailscale.svc.cluster.local:2375" + "sa-saopaulo" = "tcp://oberstein-sao-cdr-dev.tailscale.svc.cluster.local:2375" + "za-cpt" = "tcp://schonkopf-cpt-cdr-dev.tailscale.svc.cluster.local:2375" + } + + repo_base_dir = data.coder_parameter.repo_base_dir.value == "~" ? "/home/coder" : replace(data.coder_parameter.repo_base_dir.value, "/^~\\//", "/home/coder/") + repo_dir = replace(try(module.git-clone[0].repo_dir, ""), "/^~\\//", "/home/coder/") + container_name = "coder-\${data.coder_workspace_owner.me.name}-\${lower(data.coder_workspace.me.name)}" +} + +data "coder_parameter" "repo_base_dir" { + type = "string" + name = "Coder Repository Base Directory" + default = "~" + description = "The directory specified will be created (if missing) and [coder/coder](https://github.com/coder/coder) will be automatically cloned into [base directory]/coder 🪄." + mutable = true +} + +data "coder_parameter" "image_type" { + type = "string" + name = "Coder Image" + default = "codercom/oss-dogfood:latest" + description = "The Docker image used to run your workspace. Choose between nix and non-nix images." + option { + icon = "/icon/coder.svg" + name = "Dogfood (Default)" + value = "codercom/oss-dogfood:latest" + } + option { + icon = "/icon/nix.svg" + name = "Dogfood Nix (Experimental)" + value = "codercom/oss-dogfood-nix:latest" + } +} + +data "coder_parameter" "region" { + type = "string" + name = "Region" + icon = "/emojis/1f30e.png" + default = "us-pittsburgh" + option { + icon = "/emojis/1f1fa-1f1f8.png" + name = "Pittsburgh" + value = "us-pittsburgh" + } + option { + icon = "/emojis/1f1e9-1f1ea.png" + name = "Falkenstein" + // For legacy reasons, this host is labelled \`eu-helsinki\` but it's + // actually in Germany now. + value = "eu-helsinki" + } + option { + icon = "/emojis/1f1e6-1f1fa.png" + name = "Sydney" + value = "ap-sydney" + } + option { + icon = "/emojis/1f1e7-1f1f7.png" + name = "São Paulo" + value = "sa-saopaulo" + } + option { + icon = "/emojis/1f1ff-1f1e6.png" + name = "Cape Town" + value = "za-cpt" + } +} + +data "coder_parameter" "res_mon_memory_threshold" { + type = "number" + name = "Memory usage threshold" + default = 80 + description = "The memory usage threshold used in resources monitoring to trigger notifications." + mutable = true + validation { + min = 0 + max = 100 + } +} + +data "coder_parameter" "res_mon_volume_threshold" { + type = "number" + name = "Volume usage threshold" + default = 90 + description = "The volume usage threshold used in resources monitoring to trigger notifications." + mutable = true + validation { + min = 0 + max = 100 + } +} + +data "coder_parameter" "res_mon_volume_path" { + type = "string" + name = "Volume path" + default = "/home/coder" + description = "The path monitored in resources monitoring to trigger notifications." + mutable = true +} + +provider "docker" { + host = lookup(local.docker_host, data.coder_parameter.region.value) +} + +provider "coder" {} + +data "coder_external_auth" "github" { + id = "github" +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} +data "coder_workspace_tags" "tags" { + tags = { + "cluster" : "dogfood-v2" + "env" : "gke" + } +} + +module "slackme" { + count = data.coder_workspace.me.start_count + source = "dev.registry.coder.com/modules/slackme/coder" + version = ">= 1.0.0" + agent_id = coder_agent.dev.id + auth_provider_id = "slack" +} + +module "dotfiles" { + count = data.coder_workspace.me.start_count + source = "dev.registry.coder.com/modules/dotfiles/coder" + version = ">= 1.0.0" + agent_id = coder_agent.dev.id +} + +module "git-clone" { + count = data.coder_workspace.me.start_count + source = "dev.registry.coder.com/modules/git-clone/coder" + version = ">= 1.0.0" + agent_id = coder_agent.dev.id + url = "https://github.com/coder/coder" + base_dir = local.repo_base_dir +} + +module "personalize" { + count = data.coder_workspace.me.start_count + source = "dev.registry.coder.com/modules/personalize/coder" + version = ">= 1.0.0" + agent_id = coder_agent.dev.id +} + +module "code-server" { + count = data.coder_workspace.me.start_count + source = "dev.registry.coder.com/modules/code-server/coder" + version = ">= 1.0.0" + agent_id = coder_agent.dev.id + folder = local.repo_dir + auto_install_extensions = true +} + +module "vscode-web" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vscode-web/coder" + version = ">= 1.0.0" + agent_id = coder_agent.dev.id + folder = local.repo_dir + extensions = ["github.copilot"] + auto_install_extensions = true # will install extensions from the repos .vscode/extensions.json file + accept_license = true +} + +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "dev.registry.coder.com/modules/jetbrains-gateway/coder" + version = ">= 1.0.0" + agent_id = coder_agent.dev.id + agent_name = "dev" + folder = local.repo_dir + jetbrains_ides = ["GO", "WS"] + default = "GO" + latest = true +} + +module "filebrowser" { + count = data.coder_workspace.me.start_count + source = "dev.registry.coder.com/modules/filebrowser/coder" + version = ">= 1.0.0" + agent_id = coder_agent.dev.id + agent_name = "dev" +} + +module "coder-login" { + count = data.coder_workspace.me.start_count + source = "dev.registry.coder.com/modules/coder-login/coder" + version = ">= 1.0.0" + agent_id = coder_agent.dev.id +} + +module "cursor" { + count = data.coder_workspace.me.start_count + source = "dev.registry.coder.com/modules/cursor/coder" + version = ">= 1.0.0" + agent_id = coder_agent.dev.id + folder = local.repo_dir +} + +module "zed" { + count = data.coder_workspace.me.start_count + source = "./zed" + agent_id = coder_agent.dev.id + folder = local.repo_dir +} + +resource "coder_agent" "dev" { + arch = "amd64" + os = "linux" + dir = local.repo_dir + env = { + OIDC_TOKEN : data.coder_workspace_owner.me.oidc_access_token, + } + startup_script_behavior = "blocking" + + # The following metadata blocks are optional. They are used to display + # information about your workspace in the dashboard. You can remove them + # if you don't want to display any information. + metadata { + display_name = "CPU Usage" + key = "cpu_usage" + order = 0 + script = "coder stat cpu" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "RAM Usage" + key = "ram_usage" + order = 1 + script = "coder stat mem" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "CPU Usage (Host)" + key = "cpu_usage_host" + order = 2 + script = "coder stat cpu --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "RAM Usage (Host)" + key = "ram_usage_host" + order = 3 + script = "coder stat mem --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Swap Usage (Host)" + key = "swap_usage_host" + order = 4 + script = <&1 | awk ' $0 ~ "Word of the Day: [A-z]+" { print $5; exit }' + EOT + interval = 86400 + timeout = 5 + } + + resources_monitoring { + memory { + enabled = true + threshold = data.coder_parameter.res_mon_memory_threshold.value + } + volume { + enabled = true + threshold = data.coder_parameter.res_mon_volume_threshold.value + path = data.coder_parameter.res_mon_volume_path.value + } + } + + startup_script = <<-EOT + #!/usr/bin/env bash + set -eux -o pipefail + + # Allow synchronization between scripts. + trap 'touch /tmp/.coder-startup-script.done' EXIT + + # Start Docker service + sudo service docker start + # Install playwright dependencies + # We want to use the playwright version from site/package.json + # Check if the directory exists At workspace creation as the coder_script runs in parallel so clone might not exist yet. + while ! [[ -f "\${local.repo_dir}/site/package.json" ]]; do + sleep 1 + done + cd "\${local.repo_dir}" && make clean + cd "\${local.repo_dir}/site" && pnpm install + EOT + + shutdown_script = <<-EOT + #!/usr/bin/env bash + set -eux -o pipefail + + # Stop the Docker service to prevent errors during workspace destroy. + sudo service docker stop + EOT +} + +# Add a cost so we get some quota usage in dev.coder.com +resource "coder_metadata" "home_volume" { + resource_id = docker_volume.home_volume.id + daily_cost = 1 +} + +resource "docker_volume" "home_volume" { + name = "coder-\${data.coder_workspace.me.id}-home" + # Protect the volume from being deleted due to changes in attributes. + lifecycle { + ignore_changes = all + } + # Add labels in Docker to keep track of orphan resources. + labels { + label = "coder.owner" + value = data.coder_workspace_owner.me.name + } + labels { + label = "coder.owner_id" + value = data.coder_workspace_owner.me.id + } + labels { + label = "coder.workspace_id" + value = data.coder_workspace.me.id + } + # This field becomes outdated if the workspace is renamed but can + # be useful for debugging or cleaning out dangling volumes. + labels { + label = "coder.workspace_name_at_creation" + value = data.coder_workspace.me.name + } +} + +data "docker_registry_image" "dogfood" { + name = data.coder_parameter.image_type.value +} + +resource "docker_image" "dogfood" { + name = "\${data.coder_parameter.image_type.value}@\${data.docker_registry_image.dogfood.sha256_digest}" + pull_triggers = [ + data.docker_registry_image.dogfood.sha256_digest, + sha1(join("", [for f in fileset(path.module, "files/*") : filesha1(f)])), + filesha1("Dockerfile"), + filesha1("nix.hash"), + ] + keep_locally = true +} + +resource "docker_container" "workspace" { + count = data.coder_workspace.me.start_count + image = docker_image.dogfood.name + name = local.container_name + # Hostname makes the shell more user friendly: coder@my-workspace:~$ + hostname = data.coder_workspace.me.name + # Use the docker gateway if the access URL is 127.0.0.1 + entrypoint = ["sh", "-c", coder_agent.dev.init_script] + # CPU limits are unnecessary since Docker will load balance automatically + memory = data.coder_workspace_owner.me.name == "code-asher" ? 65536 : 32768 + runtime = "sysbox-runc" + # Ensure the workspace is given time to execute shutdown scripts. + destroy_grace_seconds = 60 + stop_timeout = 60 + stop_signal = "SIGINT" + env = [ + "CODER_AGENT_TOKEN=\${coder_agent.dev.token}", + "USE_CAP_NET_ADMIN=true", + "CODER_PROC_PRIO_MGMT=1", + "CODER_PROC_OOM_SCORE=10", + "CODER_PROC_NICE_SCORE=1", + "CODER_AGENT_DEVCONTAINERS_ENABLE=1", + ] + host { + host = "host.docker.internal" + ip = "host-gateway" + } + volumes { + container_path = "/home/coder/" + volume_name = docker_volume.home_volume.name + read_only = false + } + capabilities { + add = ["CAP_NET_ADMIN", "CAP_SYS_NICE"] + } + # Add labels in Docker to keep track of orphan resources. + labels { + label = "coder.owner" + value = data.coder_workspace_owner.me.name + } + labels { + label = "coder.owner_id" + value = data.coder_workspace_owner.me.id + } + labels { + label = "coder.workspace_id" + value = data.coder_workspace.me.id + } + labels { + label = "coder.workspace_name" + value = data.coder_workspace.me.name + } +} + +resource "coder_metadata" "container_info" { + count = data.coder_workspace.me.start_count + resource_id = docker_container.workspace[0].id + item { + key = "memory" + value = docker_container.workspace[0].memory + } + item { + key = "runtime" + value = docker_container.workspace[0].runtime + } + item { + key = "region" + value = data.coder_parameter.region.option[index(data.coder_parameter.region.option.*.value, data.coder_parameter.region.value)].name + } +} +`; diff --git a/site/src/pages/ChatPage/ChatToolInvocation.tsx b/site/src/pages/ChatPage/ChatToolInvocation.tsx new file mode 100644 index 0000000000000..6f418edabb4a5 --- /dev/null +++ b/site/src/pages/ChatPage/ChatToolInvocation.tsx @@ -0,0 +1,872 @@ +import type { ToolCall, ToolResult } from "@ai-sdk/provider-utils"; +import { useTheme } from "@emotion/react"; +import ArticleIcon from "@mui/icons-material/Article"; +import BuildIcon from "@mui/icons-material/Build"; +import CheckCircle from "@mui/icons-material/CheckCircle"; +import CodeIcon from "@mui/icons-material/Code"; +import DeleteIcon from "@mui/icons-material/Delete"; +import ErrorIcon from "@mui/icons-material/Error"; +import FileUploadIcon from "@mui/icons-material/FileUpload"; +import PersonIcon from "@mui/icons-material/Person"; +import SettingsIcon from "@mui/icons-material/Settings"; +import CircularProgress from "@mui/material/CircularProgress"; +import Tooltip from "@mui/material/Tooltip"; +import type * as TypesGen from "api/typesGenerated"; +import { Avatar } from "components/Avatar/Avatar"; +import { InfoIcon } from "lucide-react"; +import type React from "react"; +import { type FC, memo, useMemo, useState } from "react"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { dracula } from "react-syntax-highlighter/dist/cjs/styles/prism"; +import { vscDarkPlus } from "react-syntax-highlighter/dist/cjs/styles/prism"; +import { TabLink, Tabs, TabsList } from "../../components/Tabs/Tabs"; + +interface ChatToolInvocationProps { + toolInvocation: ChatToolInvocation; +} + +export const ChatToolInvocation: FC = ({ + toolInvocation, +}) => { + const theme = useTheme(); + const friendlyName = useMemo(() => { + return toolInvocation.toolName + .replace("coder_", "") + .replace(/_/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()); + }, [toolInvocation.toolName]); + + const hasError = useMemo(() => { + if (toolInvocation.state !== "result") { + return false; + } + return ( + typeof toolInvocation.result === "object" && + toolInvocation.result !== null && + "error" in toolInvocation.result + ); + }, [toolInvocation]); + const statusColor = useMemo(() => { + if (toolInvocation.state !== "result") { + return theme.palette.info.main; + } + return hasError ? theme.palette.error.main : theme.palette.success.main; + }, [toolInvocation, hasError, theme]); + const tooltipContent = useMemo(() => { + return ( + + {JSON.stringify(toolInvocation, null, 2)} + + ); + }, [toolInvocation, theme.shape.borderRadius, theme.spacing]); + + return ( +
+
+ {toolInvocation.state !== "result" && ( + + )} + {toolInvocation.state === "result" ? ( + hasError ? ( + + ) : ( + + ) + ) : null} +
+ {friendlyName} +
+ + + +
+ {toolInvocation.state === "result" ? ( + + ) : ( + + )} +
+ ); +}; + +const ChatToolInvocationCallPreview: FC<{ + toolInvocation: Extract< + ChatToolInvocation, + { state: "call" | "partial-call" } + >; +}> = memo(({ toolInvocation }) => { + const theme = useTheme(); + + let content: React.ReactNode; + switch (toolInvocation.toolName) { + case "coder_upload_tar_file": + content = ( + + ); + break; + } + + if (!content) { + return null; + } + + return
{content}
; +}); + +const ChatToolInvocationResultPreview: FC<{ + toolInvocation: Extract; +}> = memo(({ toolInvocation }) => { + const theme = useTheme(); + + if (!toolInvocation.result) { + return null; + } + + if ( + typeof toolInvocation.result === "object" && + "error" in toolInvocation.result + ) { + return null; + } + + let content: React.ReactNode; + switch (toolInvocation.toolName) { + case "coder_get_workspace": + case "coder_create_workspace": + content = ( +
+ {toolInvocation.result.template_icon && ( + {toolInvocation.result.template_display_name + )} +
+
+ {toolInvocation.result.name} +
+
+ {toolInvocation.result.template_display_name} +
+
+
+ ); + break; + case "coder_list_workspaces": + content = ( +
+ {toolInvocation.result.map((workspace) => ( +
+ {workspace.template_icon && ( + {workspace.template_display_name + )} +
+
+ {workspace.name} +
+
+ {workspace.template_display_name} +
+
+
+ ))} +
+ ); + break; + case "coder_list_templates": { + const templates = toolInvocation.result; + content = ( +
+ {templates.map((template) => ( +
+ +
+
+ {template.name} +
+
+ {template.description} +
+
+
+ ))} + {templates.length === 0 &&
No templates found.
} +
+ ); + break; + } + case "coder_template_version_parameters": { + const params = toolInvocation.result; + content = ( +
+ + {params.length > 0 + ? `${params.length} parameter(s)` + : "No parameters"} +
+ ); + break; + } + case "coder_get_authenticated_user": { + const user = toolInvocation.result; + content = ( +
+ + + +
+
+ {user.username} +
+
+ {user.email} +
+
+
+ ); + break; + } + case "coder_create_workspace_build": { + const build = toolInvocation.result; + content = ( +
+ + Build #{build.build_number} ({build.transition}) status:{" "} + {build.status} +
+ ); + break; + } + case "coder_create_template_version": { + const version = toolInvocation.result; + content = ( +
+ +
+
{version.name}
+ {version.message && ( +
+ {version.message} +
+ )} +
+
+ ); + break; + } + case "coder_get_workspace_agent_logs": + case "coder_get_workspace_build_logs": + case "coder_get_template_version_logs": { + const logs = toolInvocation.result; + const totalLines = logs.length; + const maxLinesToShow = 5; + const lastLogs = logs.slice(-maxLinesToShow); + const hiddenLines = totalLines - lastLogs.length; + + const totalLinesText = `${totalLines} log line${totalLines !== 1 ? "s" : ""}`; + const hiddenLinesText = + hiddenLines > 0 + ? `... hiding ${hiddenLines} more line${hiddenLines !== 1 ? "s" : ""} ...` + : null; + + const logsToShow = hiddenLinesText + ? [hiddenLinesText, ...lastLogs] + : lastLogs; + + content = ( +
+
+ + Retrieved {totalLinesText}. +
+ {logsToShow.length > 0 && ( + + {logsToShow.join("\n")} + + )} +
+ ); + break; + } + case "coder_update_template_active_version": + content = ( +
+ + {toolInvocation.result} +
+ ); + break; + case "coder_upload_tar_file": + content = ( + + ); + break; + case "coder_create_template": { + const template = toolInvocation.result; + content = ( +
+ {template.display_name +
+
+ {template.name} +
+
+ {template.display_name} +
+
+
+ ); + break; + } + case "coder_delete_template": + content = ( +
+ + {toolInvocation.result} +
+ ); + break; + case "coder_get_template_version": { + const version = toolInvocation.result; + content = ( +
+ +
+
{version.name}
+ {version.message && ( +
+ {version.message} +
+ )} +
+
+ ); + break; + } + case "coder_download_tar_file": { + const files = toolInvocation.result; + content = ; + break; + } + // Add default case or handle other tools if necessary + } + return ( +
+ {content} +
+ ); +}); + +// New component to preview files with tabs +const FilePreview: FC<{ files: Record; prefix?: string }> = + memo(({ files, prefix }) => { + const theme = useTheme(); + const [selectedTab, setSelectedTab] = useState(0); + const fileEntries = useMemo(() => Object.entries(files), [files]); + + if (fileEntries.length === 0) { + return null; + } + + const handleTabChange = (index: number) => { + setSelectedTab(index); + }; + + const getLanguage = (filename: string): string => { + if (filename.includes("Dockerfile")) { + return "dockerfile"; + } + const extension = filename.split(".").pop()?.toLowerCase(); + switch (extension) { + case "tf": + return "hcl"; + case "json": + return "json"; + case "yaml": + case "yml": + return "yaml"; + case "js": + case "jsx": + return "javascript"; + case "ts": + case "tsx": + return "typescript"; + case "py": + return "python"; + case "go": + return "go"; + case "rb": + return "ruby"; + case "java": + return "java"; + case "sh": + return "bash"; + case "md": + return "markdown"; + default: + return "plaintext"; + } + }; + + // Get filename and content based on the selectedTab index + const [selectedFilename, selectedContent] = fileEntries[selectedTab] ?? [ + "", + "", + ]; + + return ( +
+ {prefix && ( +
+ + {prefix} +
+ )} + {/* Use custom Tabs component with active prop */} + + + {fileEntries.map(([filename], index) => ( + { + e.preventDefault(); // Prevent any potential default link behavior + handleTabChange(index); + }} + > + {filename} + + ))} + + + + {selectedContent} + +
+ ); + }); + +// TODO: generate these from codersdk/toolsdk.go. +export type ChatToolInvocation = + | ToolInvocation< + "coder_get_workspace", + { + workspace_id: string; + }, + TypesGen.Workspace + > + | ToolInvocation< + "coder_create_workspace", + { + user: string; + template_version_id: string; + name: string; + rich_parameters: Record; + }, + TypesGen.Workspace + > + | ToolInvocation< + "coder_list_workspaces", + { + owner: string; + }, + Pick< + TypesGen.Workspace, + | "id" + | "name" + | "template_id" + | "template_name" + | "template_display_name" + | "template_icon" + | "template_active_version_id" + | "outdated" + >[] + > + | ToolInvocation< + "coder_list_templates", + Record, + Pick< + TypesGen.Template, + | "id" + | "name" + | "description" + | "active_version_id" + | "active_user_count" + >[] + > + | ToolInvocation< + "coder_template_version_parameters", + { + template_version_id: string; + }, + TypesGen.TemplateVersionParameter[] + > + | ToolInvocation< + "coder_get_authenticated_user", + Record, + TypesGen.User + > + | ToolInvocation< + "coder_create_workspace_build", + { + workspace_id: string; + template_version_id?: string; + transition: "start" | "stop" | "delete"; + }, + TypesGen.WorkspaceBuild + > + | ToolInvocation< + "coder_create_template_version", + { + template_id?: string; + file_id: string; + }, + TypesGen.TemplateVersion + > + | ToolInvocation< + "coder_get_workspace_agent_logs", + { + workspace_agent_id: string; + }, + string[] + > + | ToolInvocation< + "coder_get_workspace_build_logs", + { + workspace_build_id: string; + }, + string[] + > + | ToolInvocation< + "coder_get_template_version_logs", + { + template_version_id: string; + }, + string[] + > + | ToolInvocation< + "coder_get_template_version", + { + template_version_id: string; + }, + TypesGen.TemplateVersion + > + | ToolInvocation< + "coder_download_tar_file", + { + file_id: string; + }, + Record + > + | ToolInvocation< + "coder_update_template_active_version", + { + template_id: string; + template_version_id: string; + }, + string + > + | ToolInvocation< + "coder_upload_tar_file", + { + files: Record; + }, + TypesGen.UploadResponse + > + | ToolInvocation< + "coder_create_template", + { + name: string; + }, + TypesGen.Template + > + | ToolInvocation< + "coder_delete_template", + { + template_id: string; + }, + string + >; + +type ToolInvocation = + | ({ + state: "partial-call"; + step?: number; + } & ToolCall) + | ({ + state: "call"; + step?: number; + } & ToolCall) + | ({ + state: "result"; + step?: number; + } & ToolResult< + N, + A, + | R + | { + error: string; + } + >); diff --git a/site/src/pages/ChatPage/LanguageModelSelector.tsx b/site/src/pages/ChatPage/LanguageModelSelector.tsx new file mode 100644 index 0000000000000..2170be22b3196 --- /dev/null +++ b/site/src/pages/ChatPage/LanguageModelSelector.tsx @@ -0,0 +1,73 @@ +import { useTheme } from "@emotion/react"; +import FormControl from "@mui/material/FormControl"; +import InputLabel from "@mui/material/InputLabel"; +import MenuItem from "@mui/material/MenuItem"; +import Select from "@mui/material/Select"; +import { deploymentLanguageModels } from "api/queries/deployment"; +import type { LanguageModel } from "api/typesGenerated"; // Assuming types live here based on project structure +import { Loader } from "components/Loader/Loader"; +import type { FC } from "react"; +import { useQuery } from "react-query"; +import { useChatContext } from "./ChatLayout"; + +export const LanguageModelSelector: FC = () => { + const theme = useTheme(); + const { setSelectedModel, modelConfig, selectedModel } = useChatContext(); + const { + data: languageModelConfig, + isLoading, + error, + } = useQuery(deploymentLanguageModels()); + + if (isLoading) { + return ; + } + + if (error || !languageModelConfig) { + console.error("Failed to load language models:", error); + return ( +
Error loading models.
+ ); + } + + const models = Array.from(languageModelConfig.models).toSorted((a, b) => { + // Sort by provider first, then by display name + const compareProvider = a.provider.localeCompare(b.provider); + if (compareProvider !== 0) { + return compareProvider; + } + return a.display_name.localeCompare(b.display_name); + }); + + if (models.length === 0) { + return ( +
+ No language models available. +
+ ); + } + + return ( + + Model + + + ); +}; diff --git a/site/src/router.tsx b/site/src/router.tsx index 76e9adfd00b09..534d4037d02b3 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -1,4 +1,6 @@ import { GlobalErrorBoundary } from "components/ErrorBoundary/GlobalErrorBoundary"; +import { ChatLayout } from "pages/ChatPage/ChatLayout"; +import { ChatMessages } from "pages/ChatPage/ChatMessages"; import { TemplateRedirectController } from "pages/TemplatePage/TemplateRedirectController"; import { Suspense, lazy } from "react"; import { @@ -31,6 +33,7 @@ const NotFoundPage = lazy(() => import("./pages/404Page/404Page")); const DeploymentSettingsLayout = lazy( () => import("./modules/management/DeploymentSettingsLayout"), ); +const ChatLanding = lazy(() => import("./pages/ChatPage/ChatLanding")); const DeploymentConfigProvider = lazy( () => import("./modules/management/DeploymentConfigProvider"), ); @@ -422,6 +425,11 @@ export const router = createBrowserRouter( } /> + }> + } /> + } /> + + }> } />