From 07b85c032a978281f5820a8b4978a8b7947e5cd8 Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Mon, 11 Aug 2025 18:37:48 +0600 Subject: [PATCH] refactor: floating and topic ai --- package.json | 10 +- pnpm-lock.yaml | 474 +++++++++++++++--- src/components/ChatMessages/AIChat.css | 131 +++++ .../ChatMessages/RoadmapChatIntroMessage.tsx | 108 ++++ .../ChatMessages/RoadmapChatMessage.tsx | 148 ++++++ .../ChatMessages/RoadmapChatMessages.tsx | 110 ++++ .../ChatMessages/RoadmapRecommendations.tsx | 82 +++ .../ChatMessages/RoadmapTopicList.tsx | 106 ++++ .../ChatMessages/ShareResourceLink.tsx | 47 ++ .../ChatMessages/TopicChatMessage.tsx | 63 +++ .../ChatMessages/TopicChatMessages.tsx | 57 +++ .../ChatMessages/UserPrgressActionList.tsx | 330 ++++++++++++ .../ChatMessages/UserProgressList.tsx | 60 +++ .../FrameRenderer/RoadmapFloatingChat.tsx | 200 +++----- src/components/Global/CodeBlock.tsx | 160 ++++++ src/components/Global/Markdown.tsx | 99 ++++ src/components/TopicDetail/TopicDetail.tsx | 22 +- src/components/TopicDetail/TopicDetailAI.tsx | 199 ++------ src/hooks/use-ai-chat-scroll.tsx | 85 ++++ src/hooks/use-completion.ts | 119 +++++ src/hooks/use-is-thinking.ts | 23 + src/hooks/use-personalized-roadmap.ts | 2 +- src/lib/ai.ts | 57 +++ src/lib/message-part.ts | 133 +++++ src/lib/stream.ts | 100 ++++ src/queries/chat-history.ts | 35 +- 26 files changed, 2572 insertions(+), 388 deletions(-) create mode 100644 src/components/ChatMessages/AIChat.css create mode 100644 src/components/ChatMessages/RoadmapChatIntroMessage.tsx create mode 100644 src/components/ChatMessages/RoadmapChatMessage.tsx create mode 100644 src/components/ChatMessages/RoadmapChatMessages.tsx create mode 100644 src/components/ChatMessages/RoadmapRecommendations.tsx create mode 100644 src/components/ChatMessages/RoadmapTopicList.tsx create mode 100644 src/components/ChatMessages/ShareResourceLink.tsx create mode 100644 src/components/ChatMessages/TopicChatMessage.tsx create mode 100644 src/components/ChatMessages/TopicChatMessages.tsx create mode 100644 src/components/ChatMessages/UserPrgressActionList.tsx create mode 100644 src/components/ChatMessages/UserProgressList.tsx create mode 100644 src/components/Global/CodeBlock.tsx create mode 100644 src/components/Global/Markdown.tsx create mode 100644 src/hooks/use-ai-chat-scroll.tsx create mode 100644 src/hooks/use-completion.ts create mode 100644 src/hooks/use-is-thinking.ts create mode 100644 src/lib/message-part.ts create mode 100644 src/lib/stream.ts diff --git a/package.json b/package.json index 7cf2830ad..41a8b7922 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "test:e2e": "playwright test" }, "dependencies": { + "@ai-sdk/react": "2.0.0-beta.34", "@astrojs/node": "^9.2.1", "@astrojs/react": "^4.2.7", "@astrojs/sitemap": "^3.4.0", @@ -43,6 +44,7 @@ "@radix-ui/react-popover": "^1.1.14", "@resvg/resvg-js": "^2.6.2", "@roadmapsh/editor": "workspace:*", + "@shikijs/transformers": "^3.9.2", "@tailwindcss/vite": "^4.1.7", "@tanstack/react-query": "^5.76.1", "@tiptap/core": "^2.12.0", @@ -65,6 +67,7 @@ "image-size": "^2.0.2", "jose": "^6.0.11", "js-cookie": "^3.0.5", + "katex": "^0.16.22", "lucide-react": "^0.511.0", "luxon": "^3.6.1", "markdown-it-async": "^2.2.0", @@ -80,10 +83,14 @@ "react-confetti": "^6.4.0", "react-dom": "^19.1.0", "react-dropzone": "^14.3.8", + "react-markdown": "^10.1.0", "react-resizable-panels": "^3.0.2", "react-textarea-autosize": "^8.5.9", "react-tooltip": "^5.28.1", "rehype-external-links": "^3.0.0", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "remark-parse": "^11.0.0", "roadmap-renderer": "^1.0.7", "sanitize-html": "^2.17.0", @@ -98,6 +105,7 @@ "tiptap-markdown": "^0.8.10", "turndown": "^7.2.0", "unified": "^11.0.5", + "zod": "^4.0.17", "zustand": "^5.0.4" }, "devDependencies": { @@ -113,7 +121,7 @@ "@types/react-slick": "^0.23.13", "@types/sanitize-html": "^2.16.0", "@types/turndown": "^5.0.5", - "ai": "^4.3.16", + "ai": "5.0.0-beta.34", "csv-parser": "^3.2.0", "gh-pages": "^6.3.0", "js-yaml": "^4.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2aaa61835..3a4e363b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@ai-sdk/react': + specifier: 2.0.0-beta.34 + version: 2.0.0-beta.34(react@19.1.0)(zod@4.0.17) '@astrojs/node': specifier: ^9.2.1 version: 9.2.1(astro@5.7.13(@types/node@22.15.17)(jiti@2.4.2)(lightningcss@1.30.1)(rollup@4.40.2)(tsx@4.19.4)(typescript@5.8.3)) @@ -41,6 +44,9 @@ importers: '@roadmapsh/editor': specifier: workspace:* version: link:packages/editor + '@shikijs/transformers': + specifier: ^3.9.2 + version: 3.9.2 '@tailwindcss/vite': specifier: ^4.1.7 version: 4.1.7(vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)) @@ -107,6 +113,9 @@ importers: js-cookie: specifier: ^3.0.5 version: 3.0.5 + katex: + specifier: ^0.16.22 + version: 0.16.22 lucide-react: specifier: ^0.511.0 version: 0.511.0(react@19.1.0) @@ -152,6 +161,9 @@ importers: react-dropzone: specifier: ^14.3.8 version: 14.3.8(react@19.1.0) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@19.1.4)(react@19.1.0) react-resizable-panels: specifier: ^3.0.2 version: 3.0.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -164,6 +176,15 @@ importers: rehype-external-links: specifier: ^3.0.0 version: 3.0.0 + rehype-katex: + specifier: ^7.0.1 + version: 7.0.1 + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 + remark-math: + specifier: ^6.0.0 + version: 6.0.0 remark-parse: specifier: ^11.0.0 version: 11.0.0 @@ -206,13 +227,16 @@ importers: unified: specifier: ^11.0.5 version: 11.0.5 + zod: + specifier: ^4.0.17 + version: 4.0.17 zustand: specifier: ^5.0.4 version: 5.0.4(@types/react@19.1.4)(react@19.1.0)(use-sync-external-store@1.5.0(react@19.1.0)) devDependencies: '@ai-sdk/google': specifier: ^1.2.18 - version: 1.2.18(zod@3.24.4) + version: 1.2.18(zod@4.0.17) '@playwright/test': specifier: ^1.52.0 version: 1.52.0 @@ -247,8 +271,8 @@ importers: specifier: ^5.0.5 version: 5.0.5 ai: - specifier: ^4.3.16 - version: 4.3.16(react@19.1.0)(zod@3.24.4) + specifier: 5.0.0-beta.34 + version: 5.0.0-beta.34(zod@4.0.17) csv-parser: specifier: ^3.2.0 version: 3.2.0 @@ -263,7 +287,7 @@ importers: version: 14.1.0 openai: specifier: ^4.100.0 - version: 4.100.0(zod@3.24.4) + version: 4.100.0(zod@4.0.17) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -334,6 +358,12 @@ importers: packages: + '@ai-sdk/gateway@1.0.0-beta.19': + resolution: {integrity: sha512-felWPMuECZRGx8xnmvH5dW3jywKTkGnw/tXN8szphGzEDr/BfxywuXijfPBG2WBUS6frPXsvSLDRdCm5W38PXA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4 + '@ai-sdk/google@1.2.18': resolution: {integrity: sha512-8B70+i+uB12Ae6Sn6B9Oc6W0W/XorGgc88Nx0pyUrcxFOdytHBaAVhTPqYsO3LLClfjYN8pQ9GMxd5cpGEnUcA==} engines: {node: '>=18'} @@ -346,26 +376,30 @@ packages: peerDependencies: zod: ^3.23.8 + '@ai-sdk/provider-utils@3.0.0-beta.10': + resolution: {integrity: sha512-e6WSsgM01au04/1L/v5daXHn00eKjPBQXl3jq3BfvQbQ1jo8Rls2pvrdkyVc25jBW4TV4Zm+tw+v6NAh5NPXMA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4 + '@ai-sdk/provider@1.1.3': resolution: {integrity: sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==} engines: {node: '>=18'} - '@ai-sdk/react@1.2.12': - resolution: {integrity: sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g==} + '@ai-sdk/provider@2.0.0-beta.2': + resolution: {integrity: sha512-vqhtZA7R24q1XnmfmIb1fZSmHMIaJH1BVQ+0kFnNJgqWsc+V8i+yfetZ37gUc4fXATFmBuS/6O7+RPoHsZ2Fqg==} + engines: {node: '>=18'} + + '@ai-sdk/react@2.0.0-beta.34': + resolution: {integrity: sha512-6v55iQbJRJ42nFM7GPzmzaP3NxEgFamKQu2fYc8jl5McQyYka3gZ7jHpy4jTMy+b16HIXKgPqVXd/RN/+uHOEw==} engines: {node: '>=18'} peerDependencies: react: ^18 || ^19 || ^19.0.0-rc - zod: ^3.23.8 + zod: ^3.25.76 || ^4 peerDependenciesMeta: zod: optional: true - '@ai-sdk/ui-utils@1.2.11': - resolution: {integrity: sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.23.8 - '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -1915,6 +1949,9 @@ packages: '@shikijs/core@3.4.2': resolution: {integrity: sha512-AG8vnSi1W2pbgR2B911EfGqtLE9c4hQBYkv/x7Z+Kt0VxhgQKcW7UNDVYsu9YxwV6u+OJrvdJrMq6DNWoBjihQ==} + '@shikijs/core@3.9.2': + resolution: {integrity: sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA==} + '@shikijs/engine-javascript@3.4.2': resolution: {integrity: sha512-1/adJbSMBOkpScCE/SB6XkjJU17ANln3Wky7lOmrnpl+zBdQ1qXUJg2GXTYVHRq+2j3hd1DesmElTXYDgtfSOQ==} @@ -1927,9 +1964,15 @@ packages: '@shikijs/themes@3.4.2': resolution: {integrity: sha512-qAEuAQh+brd8Jyej2UDDf+b4V2g1Rm8aBIdvt32XhDPrHvDkEnpb7Kzc9hSuHUxz0Iuflmq7elaDuQAP9bHIhg==} + '@shikijs/transformers@3.9.2': + resolution: {integrity: sha512-MW5hT4TyUp6bNAgTExRYLk1NNasVQMTCw1kgbxHcEC0O5cbepPWaB+1k+JzW9r3SP2/R8kiens8/3E6hGKfgsA==} + '@shikijs/types@3.4.2': resolution: {integrity: sha512-zHC1l7L+eQlDXLnxvM9R91Efh2V4+rN3oMVS2swCBssbj2U/FBwybD1eeLaq8yl/iwT+zih8iUbTBCgGZOYlVg==} + '@shikijs/types@3.9.2': + resolution: {integrity: sha512-/M5L0Uc2ljyn2jKvj4Yiah7ow/W+DJSglVafvWAJ/b8AZDeeRAdMu3c2riDzB7N42VD+jSnWxeP9AKtd4TfYVw==} + '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} @@ -1938,6 +1981,9 @@ packages: engines: {node: '>= 8.0.0'} hasBin: true + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@swc/helpers@0.5.17': resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} @@ -2223,12 +2269,12 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} - '@types/diff-match-patch@1.0.36': - resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==} - '@types/dom-to-image@2.6.7': resolution: {integrity: sha512-me5VbCv+fcXozblWwG13krNBvuEOm6kA5xoa4RrjDJCNFOZSWR3/QLtOXimBHk1Fisq69Gx3JtOoXtg1N1tijg==} + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + '@types/estree@1.0.7': resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} @@ -2241,6 +2287,9 @@ packages: '@types/js-cookie@3.0.6': resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} + '@types/katex@0.16.7': + resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==} + '@types/linkify-it@3.0.5': resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==} @@ -2309,6 +2358,9 @@ packages: '@types/turndown@5.0.5': resolution: {integrity: sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -2346,15 +2398,11 @@ packages: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} - ai@4.3.16: - resolution: {integrity: sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g==} + ai@5.0.0-beta.34: + resolution: {integrity: sha512-AFJ4p35AxA+1KFtnoouePLaAUpoj0IxIAoq/xgIv88qzYajTg4Sac5KaV4CDHFRLoF0L2cwhlFXt/Ss/zyBKkA==} engines: {node: '>=18'} peerDependencies: - react: ^18 || ^19 || ^19.0.0-rc - zod: ^3.23.8 - peerDependenciesMeta: - react: - optional: true + zod: ^3.25.76 || ^4 ansi-align@3.0.1: resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} @@ -2506,6 +2554,9 @@ packages: character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -2565,6 +2616,10 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + common-ancestor-path@1.0.1: resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==} @@ -2734,9 +2789,6 @@ packages: dfa@1.2.0: resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} - diff-match-patch@1.0.5: - resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} - diff@5.2.0: resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} engines: {node: '>=0.3.1'} @@ -2864,6 +2916,9 @@ packages: engines: {node: '>=4'} hasBin: true + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -2881,6 +2936,10 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + eventsource-parser@3.0.3: + resolution: {integrity: sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA==} + engines: {node: '>=20.0.0'} + extend-shallow@2.0.1: resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} engines: {node: '>=0.10.0'} @@ -3052,6 +3111,12 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-from-dom@5.0.1: + resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==} + + hast-util-from-html-isomorphic@2.0.0: + resolution: {integrity: sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==} + hast-util-from-html@2.0.3: resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} @@ -3070,6 +3135,9 @@ packages: hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-parse5@8.0.0: resolution: {integrity: sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==} @@ -3096,6 +3164,9 @@ packages: html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -3127,6 +3198,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + inline-style-parser@0.2.4: + resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} + iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} @@ -3134,9 +3208,18 @@ packages: resolution: {integrity: sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-arrayish@0.3.2: resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3158,6 +3241,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} @@ -3224,14 +3310,13 @@ packages: engines: {node: '>=6'} hasBin: true - jsondiffpatch@0.6.0: - resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + katex@0.16.22: + resolution: {integrity: sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==} + hasBin: true + kind-of@6.0.3: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} @@ -3486,6 +3571,18 @@ packages: mdast-util-gfm@3.1.0: resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} @@ -3535,6 +3632,9 @@ packages: micromark-extension-gfm@3.0.0: resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} @@ -3776,6 +3876,9 @@ packages: parse-css-color@0.2.1: resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-latin@7.0.0: resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} @@ -4065,6 +4168,12 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -4137,6 +4246,9 @@ packages: rehype-external-links@3.0.0: resolution: {integrity: sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw==} + rehype-katex@7.0.1: + resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} + rehype-parse@9.0.1: resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} @@ -4152,6 +4264,9 @@ packages: remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-math@6.0.0: + resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} + remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} @@ -4354,6 +4469,12 @@ packages: resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} engines: {node: '>=0.10.0'} + style-to-js@1.1.17: + resolution: {integrity: sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA==} + + style-to-object@1.0.9: + resolution: {integrity: sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw==} + sucrase@3.35.0: resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} engines: {node: '>=16 || 14 >=14.17'} @@ -4362,8 +4483,8 @@ packages: suf-log@2.5.3: resolution: {integrity: sha512-KvC8OPjzdNOe+xQ4XWJV2whQA0aM1kGVczMQ8+dStAO6KfEB140JEVQ9dE76ONZ0/Ylf67ni4tILPJB41U0eow==} - swr@2.3.3: - resolution: {integrity: sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==} + swr@2.3.5: + resolution: {integrity: sha512-4e7pjTVulZTIL+b/S0RYFsgDcTcXPLUOvBPqyh9YdD+PkHeEMoaPwDmF9Kv6I1nnPg1OFKhiiEYpsYaaE2W2jA==} peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -4830,6 +4951,9 @@ packages: zod@3.24.4: resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==} + zod@4.0.17: + resolution: {integrity: sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==} + zustand@4.5.6: resolution: {integrity: sha512-ibr/n1hBzLLj5Y+yUcU7dYw8p6WnIVzdJbnX+1YpaScvZVF2ziugqHs+LAmHw4lWO9c/zRj+K1ncgWDQuthEdQ==} engines: {node: '>=12.7.0'} @@ -4868,39 +4992,50 @@ packages: snapshots: - '@ai-sdk/google@1.2.18(zod@3.24.4)': + '@ai-sdk/gateway@1.0.0-beta.19(zod@4.0.17)': + dependencies: + '@ai-sdk/provider': 2.0.0-beta.2 + '@ai-sdk/provider-utils': 3.0.0-beta.10(zod@4.0.17) + zod: 4.0.17 + + '@ai-sdk/google@1.2.18(zod@4.0.17)': dependencies: '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.24.4) - zod: 3.24.4 + '@ai-sdk/provider-utils': 2.2.8(zod@4.0.17) + zod: 4.0.17 - '@ai-sdk/provider-utils@2.2.8(zod@3.24.4)': + '@ai-sdk/provider-utils@2.2.8(zod@4.0.17)': dependencies: '@ai-sdk/provider': 1.1.3 nanoid: 3.3.11 secure-json-parse: 2.7.0 - zod: 3.24.4 + zod: 4.0.17 + + '@ai-sdk/provider-utils@3.0.0-beta.10(zod@4.0.17)': + dependencies: + '@ai-sdk/provider': 2.0.0-beta.2 + '@standard-schema/spec': 1.0.0 + eventsource-parser: 3.0.3 + zod: 4.0.17 + zod-to-json-schema: 3.24.5(zod@4.0.17) '@ai-sdk/provider@1.1.3': dependencies: json-schema: 0.4.0 - '@ai-sdk/react@1.2.12(react@19.1.0)(zod@3.24.4)': + '@ai-sdk/provider@2.0.0-beta.2': dependencies: - '@ai-sdk/provider-utils': 2.2.8(zod@3.24.4) - '@ai-sdk/ui-utils': 1.2.11(zod@3.24.4) + json-schema: 0.4.0 + + '@ai-sdk/react@2.0.0-beta.34(react@19.1.0)(zod@4.0.17)': + dependencies: + '@ai-sdk/provider-utils': 3.0.0-beta.10(zod@4.0.17) + ai: 5.0.0-beta.34(zod@4.0.17) react: 19.1.0 - swr: 2.3.3(react@19.1.0) + swr: 2.3.5(react@19.1.0) throttleit: 2.1.0 optionalDependencies: - zod: 3.24.4 - - '@ai-sdk/ui-utils@1.2.11(zod@3.24.4)': - dependencies: - '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.24.4) - zod: 3.24.4 - zod-to-json-schema: 3.24.5(zod@3.24.4) + zod: 4.0.17 '@alloc/quick-lru@5.2.0': {} @@ -6377,6 +6512,13 @@ snapshots: '@types/hast': 3.0.4 hast-util-to-html: 9.0.5 + '@shikijs/core@3.9.2': + dependencies: + '@shikijs/types': 3.9.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + '@shikijs/engine-javascript@3.4.2': dependencies: '@shikijs/types': 3.4.2 @@ -6396,11 +6538,21 @@ snapshots: dependencies: '@shikijs/types': 3.4.2 + '@shikijs/transformers@3.9.2': + dependencies: + '@shikijs/core': 3.9.2 + '@shikijs/types': 3.9.2 + '@shikijs/types@3.4.2': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + '@shikijs/types@3.9.2': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + '@shikijs/vscode-textmate@10.0.2': {} '@shuding/opentype.js@1.4.0-beta.0': @@ -6408,6 +6560,8 @@ snapshots: fflate: 0.7.4 string.prototype.codepointat: 0.2.1 + '@standard-schema/spec@1.0.0': {} + '@swc/helpers@0.5.17': dependencies: tslib: 2.8.1 @@ -6686,10 +6840,12 @@ snapshots: dependencies: '@types/ms': 2.1.0 - '@types/diff-match-patch@1.0.36': {} - '@types/dom-to-image@2.6.7': {} + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.7 + '@types/estree@1.0.7': {} '@types/fontkit@2.0.8': @@ -6702,6 +6858,8 @@ snapshots: '@types/js-cookie@3.0.6': {} + '@types/katex@0.16.7': {} + '@types/linkify-it@3.0.5': {} '@types/linkify-it@5.0.0': {} @@ -6775,6 +6933,8 @@ snapshots: '@types/turndown@5.0.5': {} + '@types/unist@2.0.11': {} + '@types/unist@3.0.3': {} '@types/use-sync-external-store@0.0.6': {} @@ -6823,17 +6983,13 @@ snapshots: dependencies: humanize-ms: 1.2.1 - ai@4.3.16(react@19.1.0)(zod@3.24.4): + ai@5.0.0-beta.34(zod@4.0.17): dependencies: - '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.24.4) - '@ai-sdk/react': 1.2.12(react@19.1.0)(zod@3.24.4) - '@ai-sdk/ui-utils': 1.2.11(zod@3.24.4) + '@ai-sdk/gateway': 1.0.0-beta.19(zod@4.0.17) + '@ai-sdk/provider': 2.0.0-beta.2 + '@ai-sdk/provider-utils': 3.0.0-beta.10(zod@4.0.17) '@opentelemetry/api': 1.9.0 - jsondiffpatch: 0.6.0 - zod: 3.24.4 - optionalDependencies: - react: 19.1.0 + zod: 4.0.17 ansi-align@3.0.1: dependencies: @@ -7053,6 +7209,8 @@ snapshots: character-entities@2.0.2: {} + character-reference-invalid@2.0.1: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -7097,6 +7255,8 @@ snapshots: commander@4.1.1: {} + commander@8.3.0: {} + common-ancestor-path@1.0.1: {} commondir@1.0.1: {} @@ -7236,8 +7396,6 @@ snapshots: dfa@1.2.0: {} - diff-match-patch@1.0.5: {} - diff@5.2.0: {} dir-glob@3.0.1: @@ -7360,6 +7518,8 @@ snapshots: esprima@4.0.1: {} + estree-util-is-identifier-name@3.0.0: {} + estree-walker@2.0.2: {} estree-walker@3.0.3: @@ -7372,6 +7532,8 @@ snapshots: eventemitter3@5.0.1: {} + eventsource-parser@3.0.3: {} + extend-shallow@2.0.1: dependencies: is-extendable: 0.1.1 @@ -7576,6 +7738,19 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-dom@5.0.1: + dependencies: + '@types/hast': 3.0.4 + hastscript: 9.0.1 + web-namespaces: 2.0.1 + + hast-util-from-html-isomorphic@2.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-from-dom: 5.0.1 + hast-util-from-html: 2.0.3 + unist-util-remove-position: 5.0.0 + hast-util-from-html@2.0.3: dependencies: '@types/hast': 3.0.4 @@ -7634,6 +7809,26 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.7 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.17 + unist-util-position: 5.0.0 + vfile-message: 4.0.2 + transitivePeerDependencies: + - supports-color + hast-util-to-parse5@8.0.0: dependencies: '@types/hast': 3.0.4 @@ -7671,6 +7866,8 @@ snapshots: html-escaper@3.0.3: {} + html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} htmlparser2@8.0.2: @@ -7702,12 +7899,23 @@ snapshots: inherits@2.0.4: {} + inline-style-parser@0.2.4: {} + iron-webcrypto@1.2.1: {} is-absolute-url@4.0.1: {} + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + is-arrayish@0.3.2: {} + is-decimal@2.0.1: {} + is-docker@3.0.0: {} is-extendable@0.1.1: {} @@ -7720,6 +7928,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 @@ -7767,18 +7977,16 @@ snapshots: json5@2.2.3: {} - jsondiffpatch@0.6.0: - dependencies: - '@types/diff-match-patch': 1.0.36 - chalk: 5.4.1 - diff-match-patch: 1.0.5 - jsonfile@6.1.0: dependencies: universalify: 2.0.1 optionalDependencies: graceful-fs: 4.2.11 + katex@0.16.22: + dependencies: + commander: 8.3.0 + kind-of@6.0.3: {} kleur@3.0.3: {} @@ -8045,6 +8253,57 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-math@3.0.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + longest-streak: 3.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + mdast-util-phrasing@4.1.0: dependencies: '@types/mdast': 4.0.4 @@ -8163,6 +8422,16 @@ snapshots: micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.7 + devlop: 1.1.0 + katex: 0.16.22 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -8377,7 +8646,7 @@ snapshots: regex: 6.0.1 regex-recursion: 6.0.2 - openai@4.100.0(zod@3.24.4): + openai@4.100.0(zod@4.0.17): dependencies: '@types/node': 18.19.100 '@types/node-fetch': 2.6.12 @@ -8387,7 +8656,7 @@ snapshots: formdata-node: 4.4.1 node-fetch: 2.7.0 optionalDependencies: - zod: 3.24.4 + zod: 4.0.17 transitivePeerDependencies: - encoding @@ -8425,6 +8694,16 @@ snapshots: color-name: 1.1.4 hex-rgb: 4.3.0 + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.1.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + parse-latin@7.0.0: dependencies: '@types/nlcst': 2.0.3 @@ -8736,6 +9015,24 @@ snapshots: react-is@16.13.1: {} + react-markdown@10.1.0(@types/react@19.1.4)(react@19.1.0): + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/react': 19.1.4 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.0 + react: 19.1.0 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + react-refresh@0.17.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.1.4)(react@19.1.0): @@ -8809,6 +9106,16 @@ snapshots: space-separated-tokens: 2.0.2 unist-util-visit: 5.0.0 + rehype-katex@7.0.1: + dependencies: + '@types/hast': 3.0.4 + '@types/katex': 0.16.7 + hast-util-from-html-isomorphic: 2.0.0 + hast-util-to-text: 4.0.2 + katex: 0.16.22 + unist-util-visit-parents: 6.0.1 + vfile: 6.0.3 + rehype-parse@9.0.1: dependencies: '@types/hast': 3.0.4 @@ -8845,6 +9152,15 @@ snapshots: transitivePeerDependencies: - supports-color + remark-math@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-math: 3.0.0 + micromark-extension-math: 3.1.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -9155,6 +9471,14 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 + style-to-js@1.1.17: + dependencies: + style-to-object: 1.0.9 + + style-to-object@1.0.9: + dependencies: + inline-style-parser: 0.2.4 + sucrase@3.35.0: dependencies: '@jridgewell/gen-mapping': 0.3.8 @@ -9169,7 +9493,7 @@ snapshots: dependencies: s.color: 0.0.15 - swr@2.3.3(react@19.1.0): + swr@2.3.5(react@19.1.0): dependencies: dequal: 2.0.3 react: 19.1.0 @@ -9547,6 +9871,10 @@ snapshots: dependencies: zod: 3.24.4 + zod-to-json-schema@3.24.5(zod@4.0.17): + dependencies: + zod: 4.0.17 + zod-to-ts@1.2.0(typescript@5.8.3)(zod@3.24.4): dependencies: typescript: 5.8.3 @@ -9554,6 +9882,8 @@ snapshots: zod@3.24.4: {} + zod@4.0.17: {} + zustand@4.5.6(@types/react@19.1.4)(react@19.1.0): dependencies: use-sync-external-store: 1.5.0(react@19.1.0) diff --git a/src/components/ChatMessages/AIChat.css b/src/components/ChatMessages/AIChat.css new file mode 100644 index 000000000..545f86d40 --- /dev/null +++ b/src/components/ChatMessages/AIChat.css @@ -0,0 +1,131 @@ +.ai-chat { + .prose ul li > code, + .prose ol li > code, + p code, + a > code, + strong > code, + em > code, + h1 > code, + h2 > code, + h3 > code { + background: #ebebeb !important; + color: currentColor !important; + font-size: 14px; + font-weight: normal !important; + } + + .message-markdown.prose ul li > code, + .message-markdown.prose ol li > code, + .message-markdown.prose p code, + .message-markdown.prose a > code, + .message-markdown.prose strong > code, + .message-markdown.prose em > code, + .message-markdown.prose h1 > code, + .message-markdown.prose h2 > code, + .message-markdown.prose h3 > code { + font-size: 12px !important; + } + + .message-markdown pre { + -ms-overflow-style: none; + scrollbar-width: none; + } + + .message-markdown pre::-webkit-scrollbar { + display: none; + } + + .message-markdown pre, + .message-markdown pre { + overflow: scroll; + } + + .prose ul li > code:before, + p > code:before, + .prose ul li > code:after, + .prose ol li > code:before, + p > code:before, + .prose ol li > code:after, + .message-markdown h1 > code:after, + .message-markdown h1 > code:before, + .message-markdown h2 > code:after, + .message-markdown h2 > code:before, + .message-markdown h3 > code:after, + .message-markdown h3 > code:before, + .message-markdown h4 > code:after, + .message-markdown h4 > code:before, + p > code:after, + a > code:after, + a > code:before { + content: '' !important; + } + + .message-markdown.prose ul li > code, + .message-markdown.prose ol li > code, + .message-markdown p code, + .message-markdown a > code, + .message-markdown strong > code, + .message-markdown em > code, + .message-markdown h1 > code, + .message-markdown h2 > code, + .message-markdown h3 > code, + .message-markdown table code { + background: #f4f4f5 !important; + border: 1px solid #282a36 !important; + color: #282a36 !important; + padding: 2px 4px; + border-radius: 5px; + white-space: pre; + font-weight: normal; + } + + .message-markdown blockquote { + font-style: normal; + } + + .message-markdown.prose blockquote h1, + .message-markdown.prose blockquote h2, + .message-markdown.prose blockquote h3, + .message-markdown.prose blockquote h4 { + font-style: normal; + margin-bottom: 8px; + } + + .message-markdown.prose ul li > code:before, + .message-markdown p > code:before, + .message-markdown.prose ul li > code:after, + .message-markdown p > code:after, + .message-markdown h2 > code:after, + .message-markdown h2 > code:before, + .message-markdown table code:before, + .message-markdown table code:after, + .message-markdown a > code:after, + .message-markdown a > code:before, + .message-markdown h2 code:after, + .message-markdown h2 code:before, + .message-markdown h2 code:after, + .message-markdown h2 code:before { + content: '' !important; + } + + .message-markdown table { + border-collapse: collapse; + border: 1px solid black; + border-radius: 5px; + } + + .message-markdown table td, + .message-markdown table th { + padding: 5px 10px; + } + + .chat-variable { + font-size: 12px; + font-weight: 500; + line-height: 1.5; + padding: 2px 4px; + border-radius: 8px; + background-color: #f0f5ff; + color: #2c5df1; + } +} diff --git a/src/components/ChatMessages/RoadmapChatIntroMessage.tsx b/src/components/ChatMessages/RoadmapChatIntroMessage.tsx new file mode 100644 index 000000000..08ec047af --- /dev/null +++ b/src/components/ChatMessages/RoadmapChatIntroMessage.tsx @@ -0,0 +1,108 @@ +import { useQuery } from '@tanstack/react-query'; +import { officialRoadmapOptions } from '../../queries/official-roadmap'; +import { queryClient } from '../../stores/query-client'; + +type RoadmapChatIntroMessageProps = { + roadmapId: string; +}; + +export function RoadmapChatIntroMessage(props: RoadmapChatIntroMessageProps) { + const { roadmapId } = props; + + const { data: roadmapDetail } = useQuery( + officialRoadmapOptions(roadmapId), + queryClient, + ); + const topicNodes = roadmapDetail?.nodes?.filter( + (node) => node.type === 'topic', + ); + + const firstTopicNode = topicNodes?.[0]; + const firstTopicTitle = firstTopicNode?.data?.label || 'XYZ'; + + const secondTopicNode = topicNodes?.[1]; + const secondTopicTitle = secondTopicNode?.data?.label || 'XYZ'; + + const capabilities = [ + { + icon: '📚', + title: 'Learn concepts:', + description: 'Ask me about any topics on the roadmap', + examples: + '"Explain what React hooks are" or "How does async/await work?"', + }, + { + icon: '📊', + title: 'Track progress:', + description: 'Mark topics as done, learning, or skipped', + examples: `"Mark ${firstTopicTitle} as done" or "Show my overall progress"`, + }, + { + icon: '🎯', + title: 'Recommendations:', + description: 'Find what to learn next or explore other roadmaps', + examples: `"What should I learn next?" or "Recommend roadmaps for backend development"`, + }, + { + icon: '🔍', + title: 'Find resources:', + description: 'Get learning materials for specific topics', + examples: `"Show me resources for learning ${secondTopicTitle}"`, + }, + { + icon: '🔗', + title: 'Share progress:', + description: 'Get a link to share your learning progress', + examples: '"Give me my shareable progress link"', + }, + ]; + + return ( +
+
+
+

+ Hi! I'm your AI learning assistant 👋 +

+

+ I'm here to guide you through your learning journey on this roadmap. + I can help you understand concepts, track your progress, and provide + personalized learning advice. +

+
+
+ +
+

+ Here's what I can help you with: +

+ +
+ {capabilities.map((capability, index) => ( +
+ {capability.icon} +
+ + {capability.title} + {' '} + {capability.description} +
+ Try: {capability.examples} +
+
+
+ ))} +
+
+ +
+

+ Tip: I can see your current + progress on the roadmap, so my advice will be personalized to your + learning journey. Just ask me anything about the topics you see on the + roadmap! +

+
+
+ ); +} diff --git a/src/components/ChatMessages/RoadmapChatMessage.tsx b/src/components/ChatMessages/RoadmapChatMessage.tsx new file mode 100644 index 000000000..aeb17cb9b --- /dev/null +++ b/src/components/ChatMessages/RoadmapChatMessage.tsx @@ -0,0 +1,148 @@ +import { Markdown } from '../Global/Markdown'; +import { BotIcon, User2Icon } from 'lucide-react'; +import type { UIMessage } from 'ai'; +import { parseMessageParts } from '../../lib/message-part'; +import { RoadmapChatUserProgressList } from './UserProgressList'; +import { + parseUserProgress, + UserProgressActionList, +} from './UserPrgressActionList'; +import { parseTopicList, RoadmapTopicList } from './RoadmapTopicList'; +import { ShareResourceLink } from './ShareResourceLink'; +import { + parseRoadmapSlugList, + RoadmapRecommendations, +} from './RoadmapRecommendations'; +import { cn } from '../../lib/classname'; + +type RoadmapMessageProps = { + roadmapId: string; + message: UIMessage; + isStreaming: boolean; + children?: React.ReactNode; + onTopicClick?: (topicId: string, topicTitle: string) => void; +}; + +export function RoadmapChatMessage(props: RoadmapMessageProps) { + const { roadmapId, message, isStreaming, children, onTopicClick } = props; + const { role } = message; + + return ( +
+
+
+ {role === 'user' ? ( + + ) : ( + + )} +
+ + {children || ( +
+ {message.parts.map((part) => { + const { type } = part; + + if (role === 'user' && type === 'text') { + return ( +
+ ); + } + + if (type === 'text') { + const text = part.text; + const parts = parseMessageParts(text, { + 'user-progress': () => { + return {}; + }, + 'update-progress': (opts) => { + return parseUserProgress(opts.content); + }, + 'roadmap-topics': (opts) => { + return parseTopicList(opts.content); + }, + 'resource-progress-link': () => { + return {}; + }, + 'roadmap-recommendations': (opts) => { + return parseRoadmapSlugList(opts.content); + }, + }); + + return parts.map((part, index) => { + const { type } = part; + const key = `message-${message.id}-part-${type}-${index}`; + + if (type === 'text') { + return ( + + {part.text ?? ''} + + ); + } else if (type === 'user-progress') { + return ( + + ); + } else if (type === 'update-progress') { + return ( + + ); + } else if (type === 'roadmap-topics') { + return ( + + ); + } else if (type === 'resource-progress-link') { + return ( + + ); + } else if (type === 'roadmap-recommendations') { + return ( + + ); + } + + return null; + }); + } + })} +
+ )} +
+
+ ); +} diff --git a/src/components/ChatMessages/RoadmapChatMessages.tsx b/src/components/ChatMessages/RoadmapChatMessages.tsx new file mode 100644 index 000000000..dfd8764e3 --- /dev/null +++ b/src/components/ChatMessages/RoadmapChatMessages.tsx @@ -0,0 +1,110 @@ +import type { ChatStatus, UIMessage } from 'ai'; +import { memo } from 'react'; +import { RoadmapChatMessage } from './RoadmapChatMessage'; +import { useIsThinking } from '../../hooks/use-is-thinking'; + +type MessagesProps = { + messages: UIMessage[]; + status: ChatStatus; + roadmapId: string; + onTopicClick?: (topicId: string, topicTitle: string) => void; + defaultQuestions?: string[]; + onDefaultQuestionClick?: (question: string) => void; +}; + +function _RoadmapChatMessages(props: MessagesProps) { + const { + messages, + status, + roadmapId, + defaultQuestions, + onTopicClick, + onDefaultQuestionClick, + } = props; + + const isStreaming = status === 'streaming'; + const isThinking = useIsThinking(messages, status); + + return ( +
+
+
+ + + {messages.length === 0 && + defaultQuestions && + defaultQuestions.length > 0 && ( +
+

+ Some questions you might have about this roadmap: +

+
+ {defaultQuestions.map((question, index) => ( + + ))} +
+
+ )} + + {messages.map((message, index) => { + const isLastMessage = index === messages.length - 1; + + // otherwise it will add an extra space at the end of the message + // because the last message is not rendered + if (isThinking && isLastMessage && message.role === 'assistant') { + return null; + } + + return ( + + ); + })} + + {isThinking && ( + + )} +
+
+
+ ); +} + +export const RoadmapChatMessages = memo(_RoadmapChatMessages); diff --git a/src/components/ChatMessages/RoadmapRecommendations.tsx b/src/components/ChatMessages/RoadmapRecommendations.tsx new file mode 100644 index 000000000..c08bc330d --- /dev/null +++ b/src/components/ChatMessages/RoadmapRecommendations.tsx @@ -0,0 +1,82 @@ +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { Loader2Icon, SquareArrowOutUpRightIcon } from 'lucide-react'; +import { listBuiltInRoadmaps } from '../../queries/roadmap'; +import { queryClient } from '../../stores/query-client'; + +type RoadmapSlugListType = { + roadmapSlug: string; +}; + +export function parseRoadmapSlugList(content: string): RoadmapSlugListType[] { + const items: RoadmapSlugListType[] = []; + + const roadmapSlugListRegex = /.*?<\/roadmap-slug>/gs; + const roadmapSlugListItems = content.match(roadmapSlugListRegex); + if (!roadmapSlugListItems) { + return items; + } + + for (const roadmapSlugListItem of roadmapSlugListItems) { + const roadmapSlugRegex = /(.*?)<\/roadmap-slug>/; + const roadmapSlug = roadmapSlugListItem + .match(roadmapSlugRegex)?.[1] + ?.trim(); + if (!roadmapSlug) { + continue; + } + + items.push({ + roadmapSlug, + }); + } + + return items; +} + +type RoadmapRecommendationsProps = { + roadmapSlugs: RoadmapSlugListType[]; +}; + +export function RoadmapRecommendations(props: RoadmapRecommendationsProps) { + const { roadmapSlugs } = props; + + const { data: roadmaps, isLoading } = useQuery( + listBuiltInRoadmaps(), + queryClient, + ); + + const progressItemWithText = useMemo(() => { + return roadmapSlugs.map((item) => { + const roadmap = roadmaps?.find( + (mapping) => mapping.id === item.roadmapSlug, + ); + + return { + ...item, + title: roadmap?.title, + }; + }); + }, [roadmapSlugs, roadmaps]); + + return ( +
+ {progressItemWithText.map((item) => ( + + {item.title} + {isLoading && ( + + )} + {!isLoading && ( + + )} + + ))} +
+ ); +} diff --git a/src/components/ChatMessages/RoadmapTopicList.tsx b/src/components/ChatMessages/RoadmapTopicList.tsx new file mode 100644 index 000000000..d1c3da39e --- /dev/null +++ b/src/components/ChatMessages/RoadmapTopicList.tsx @@ -0,0 +1,106 @@ +import { useQuery } from '@tanstack/react-query'; +import { Fragment, useMemo } from 'react'; +import { ChevronRightIcon } from 'lucide-react'; +import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree'; +import { queryClient } from '../../stores/query-client'; + +type TopicListType = { + topicId: string; +}; + +export function parseTopicList(content: string): TopicListType[] { + const items: TopicListType[] = []; + + const topicListRegex = /.*?<\/topic-id>/gs; + const topicListItems = content.match(topicListRegex); + if (!topicListItems) { + return items; + } + + for (const topicListItem of topicListItems) { + const topicIdRegex = /(.*?)<\/topic-id>/; + const topicId = topicListItem.match(topicIdRegex)?.[1]?.trim(); + if (!topicId) { + continue; + } + + items.push({ + topicId, + }); + } + + return items; +} + +type RoadmapTopicListProps = { + roadmapId: string; + onTopicClick?: (topicId: string, topicTitle: string) => void; + topics: TopicListType[]; +}; + +export function RoadmapTopicList(props: RoadmapTopicListProps) { + const { roadmapId, topics: topicListItems, onTopicClick } = props; + + const { data: roadmapTreeData } = useQuery( + roadmapTreeMappingOptions(roadmapId), + queryClient, + ); + + const progressItemWithText = useMemo(() => { + return topicListItems.map((item) => { + const roadmapTreeItem = roadmapTreeData?.find( + (mapping) => mapping.nodeId === item.topicId, + ); + + return { + ...item, + text: (roadmapTreeItem?.text || item.topicId) + ?.split(' > ') + .slice(1) + .join(' > '), + }; + }); + }, [topicListItems, roadmapTreeData]); + + return ( +
+ {progressItemWithText.map((item) => { + const labelParts = item.text.split(' > ').slice(-2); + const labelPartCount = labelParts.length; + + const title = item.text.split(' > ').pop(); + if (!title) { + return; + } + + return ( + + ); + })} +
+ ); +} diff --git a/src/components/ChatMessages/ShareResourceLink.tsx b/src/components/ChatMessages/ShareResourceLink.tsx new file mode 100644 index 000000000..a0108398a --- /dev/null +++ b/src/components/ChatMessages/ShareResourceLink.tsx @@ -0,0 +1,47 @@ +import { ShareIcon } from 'lucide-react'; +import { useCopyText } from '../../hooks/use-copy-text'; +import { cn } from '../../lib/classname'; +import { useAuth } from '../../hooks/use-auth'; +import { CheckIcon } from '../ReactIcons/CheckIcon'; + +type ShareResourceLinkProps = { + roadmapId: string; +}; + +export function ShareResourceLink(props: ShareResourceLinkProps) { + const { roadmapId } = props; + + const currentUser = useAuth(); + const { copyText, isCopied } = useCopyText(); + + const handleShareResourceLink = () => { + const url = `${import.meta.env.VITE_ASTRO_APP_URL}/${roadmapId}?s=${currentUser?.id}`; + copyText(url); + }; + + return ( +
+ +
+ ); +} diff --git a/src/components/ChatMessages/TopicChatMessage.tsx b/src/components/ChatMessages/TopicChatMessage.tsx new file mode 100644 index 000000000..b20e679b8 --- /dev/null +++ b/src/components/ChatMessages/TopicChatMessage.tsx @@ -0,0 +1,63 @@ +import { cn } from '../../lib/classname'; +import { Markdown } from '../Global/Markdown'; +import { BotIcon, User2Icon } from 'lucide-react'; +import type { UIMessage } from 'ai'; +import { promptLabelMapping } from '../TopicDetail/PredefinedActions'; + +type TopicChatMessageProps = { + message: UIMessage; +}; + +export function TopicChatMessage(props: TopicChatMessageProps) { + const { message } = props; + const { role } = message; + + return ( +
+
+
+ {role === 'user' ? ( + + ) : ( + + )} +
+ +
+ {message.parts.map((part) => { + const { type } = part; + const key = `message-${message.id}-part-${type}`; + + if (type === 'text') { + let content = part.text; + if (role === 'user' && promptLabelMapping[content]) { + content = promptLabelMapping[content]; + } + + return ( + + {content} + + ); + } + })} +
+
+
+ ); +} diff --git a/src/components/ChatMessages/TopicChatMessages.tsx b/src/components/ChatMessages/TopicChatMessages.tsx new file mode 100644 index 000000000..4cdc766e2 --- /dev/null +++ b/src/components/ChatMessages/TopicChatMessages.tsx @@ -0,0 +1,57 @@ +import type { ChatStatus, UIMessage } from 'ai'; +import { TopicChatMessage } from './TopicChatMessage'; +import { useIsThinking } from '../../hooks/use-is-thinking'; + +type TopicChatMessagesProps = { + messages: UIMessage[]; + status: ChatStatus; +}; + +export function TopicChatMessages(props: TopicChatMessagesProps) { + const { messages, status } = props; + + const isThinking = useIsThinking(messages, status); + + return ( +
+
+
+ + + {messages.map((message, index) => { + const isLastMessage = index === messages.length - 1; + + // otherwise it will add an extra space at the end of the message + // because the last message is not rendered + if (isThinking && isLastMessage && message.role === 'assistant') { + return null; + } + + return ; + })} + + {isThinking && ( + + )} +
+
+
+ ); +} diff --git a/src/components/ChatMessages/UserPrgressActionList.tsx b/src/components/ChatMessages/UserPrgressActionList.tsx new file mode 100644 index 000000000..f9d823fd2 --- /dev/null +++ b/src/components/ChatMessages/UserPrgressActionList.tsx @@ -0,0 +1,330 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { ChevronRightIcon, Loader2Icon } from 'lucide-react'; +import { CheckIcon } from '../ReactIcons/CheckIcon'; +import { Fragment, useMemo, useState } from 'react'; +import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree'; +import { queryClient } from '../../stores/query-client'; +import { httpPost } from '../../lib/query-http'; +import { + renderTopicProgress, + updateResourceProgress, + type ResourceProgressType, +} from '../../lib/resource-progress'; +import { userResourceProgressOptions } from '../../queries/resource-progress'; +import { useToast } from '../../hooks/use-toast'; +import { cn } from '../../lib/classname'; + +type UpdateUserProgress = { + id: string; + action: 'done' | 'learning' | 'skipped' | 'pending'; +}; + +export function parseUserProgress(content: string): UpdateUserProgress[] { + const items: UpdateUserProgress[] = []; + + const progressRegex = /.*?<\/update-progress-item>/gs; + const progressItems = content.match(progressRegex); + if (!progressItems) { + return items; + } + + for (const progressItem of progressItems) { + const progressItemRegex = /(.*?)<\/topic-id>/; + const topicId = progressItem.match(progressItemRegex)?.[1]?.trim(); + const topicActionRegex = /(.*?)<\/topic-action>/; + const topicAction = progressItem + .match(topicActionRegex)?.[1] + .trim() + ?.toLowerCase(); + + if (!topicId || !topicAction) { + continue; + } + + items.push({ + id: topicId, + action: topicAction as UpdateUserProgress['action'], + }); + } + + return items; +} + +type BulkUpdateResourceProgressBody = { + done: string[]; + learning: string[]; + skipped: string[]; + pending: string[]; +}; + +type BulkUpdateResourceProgressResponse = { + done: string[]; + learning: string[]; + skipped: string[]; +}; + +type UserProgressActionListProps = { + roadmapId: string; + isLoading?: boolean; + updateUserProgress: UpdateUserProgress[]; +}; + +export function UserProgressActionList(props: UserProgressActionListProps) { + const { roadmapId, updateUserProgress, isLoading = false } = props; + + const toast = useToast(); + const { data: roadmapTreeData } = useQuery( + roadmapTreeMappingOptions(roadmapId), + queryClient, + ); + + const { + mutate: bulkUpdateResourceProgress, + isPending: isBulkUpdating, + isSuccess: isBulkUpdateSuccess, + } = useMutation( + { + mutationFn: (body: BulkUpdateResourceProgressBody) => { + return httpPost( + `/v1-bulk-update-resource-progress/${roadmapId}`, + body, + ); + }, + onSuccess: () => { + updateUserProgress.forEach((item) => { + renderTopicProgress(item.id, item.action); + }); + + return queryClient.invalidateQueries( + userResourceProgressOptions('roadmap', roadmapId), + ); + }, + onError: (error) => { + toast.error( + error?.message ?? 'Something went wrong, please try again.', + ); + }, + }, + queryClient, + ); + + const progressItemWithText = useMemo(() => { + return updateUserProgress.map((item) => { + const roadmapTreeItem = roadmapTreeData?.find( + (mapping) => mapping.nodeId === item.id, + ); + + return { + ...item, + text: (roadmapTreeItem?.text || item.id) + ?.split(' > ') + .slice(1) + .join(' > '), + }; + }); + }, [updateUserProgress, roadmapTreeData]); + + const [showAll, setShowAll] = useState(false); + const itemCountToShow = 4; + const itemsToShow = showAll + ? progressItemWithText + : progressItemWithText.slice(0, itemCountToShow); + + const hasMoreItemsToShow = progressItemWithText.length > itemCountToShow; + + return ( +
+
+ {itemsToShow.map((item) => ( + + ))} + + {hasMoreItemsToShow && ( +
+ + + +
+ )} +
+
+ ); +} + +type ProgressItemProps = { + roadmapId: string; + topicId: string; + text: string; + action: UpdateUserProgress['action']; + isStreaming: boolean; + isBulkUpdating: boolean; + isBulkUpdateSuccess: boolean; +}; + +function ProgressItem(props: ProgressItemProps) { + const { + roadmapId, + topicId, + text, + action, + isStreaming, + isBulkUpdating, + isBulkUpdateSuccess, + } = props; + + const toast = useToast(); + const { + mutate: updateTopicStatus, + isSuccess, + isPending: isUpdating, + } = useMutation( + { + mutationFn: (action: ResourceProgressType) => { + return updateResourceProgress( + { + resourceId: roadmapId, + resourceType: 'roadmap', + topicId, + }, + action, + ); + }, + onMutate: () => {}, + onSuccess: () => { + renderTopicProgress(topicId, action); + }, + onError: () => { + toast.error('Something went wrong, please try again.'); + }, + onSettled: () => { + return queryClient.invalidateQueries( + userResourceProgressOptions('roadmap', roadmapId), + ); + }, + }, + queryClient, + ); + + const textParts = text.split(' > '); + const lastIndex = textParts.length - 1; + + return ( +
+ + {textParts.map((part, index) => { + return ( + + {part} + {index !== lastIndex && ( + + {' '} + + )} + + ); + })} + + {!isSuccess && !isBulkUpdateSuccess && ( + <> + {!isStreaming && ( + + )} + {isStreaming && ( + + + + )} + + )} + {(isSuccess || isBulkUpdateSuccess) && ( + + + + )} +
+ ); +} diff --git a/src/components/ChatMessages/UserProgressList.tsx b/src/components/ChatMessages/UserProgressList.tsx new file mode 100644 index 000000000..b8bce593e --- /dev/null +++ b/src/components/ChatMessages/UserProgressList.tsx @@ -0,0 +1,60 @@ +import { useQuery } from '@tanstack/react-query'; +import { getPercentage } from '../../lib/number'; +import { userResourceProgressOptions } from '../../queries/resource-progress'; +import { queryClient } from '../../stores/query-client'; + +type RoadmapChatUserProgressListProps = { + roadmapId: string; +}; + +export function RoadmapChatUserProgressList( + props: RoadmapChatUserProgressListProps, +) { + const { roadmapId } = props; + + const { data: userResourceProgressData } = useQuery( + userResourceProgressOptions('roadmap', roadmapId), + queryClient, + ); + + const doneCount = userResourceProgressData?.done?.length ?? 0; + const skippedCount = userResourceProgressData?.skipped?.length ?? 0; + + const totalTopicCount = userResourceProgressData?.totalTopicCount ?? 0; + const totalFinished = doneCount + skippedCount; + const progressPercentage = getPercentage(totalFinished, totalTopicCount); + + return ( +
+
+
+ Progress + + {progressPercentage}% + +
+ + {totalFinished} / {totalTopicCount} topics + +
+ +
+
+
+ +
+
+
+ Completed: {doneCount} +
+
+
+ Skipped: {skippedCount} +
+
+
+ ); +} diff --git a/src/components/FrameRenderer/RoadmapFloatingChat.tsx b/src/components/FrameRenderer/RoadmapFloatingChat.tsx index 271b3abf3..ae2768b93 100644 --- a/src/components/FrameRenderer/RoadmapFloatingChat.tsx +++ b/src/components/FrameRenderer/RoadmapFloatingChat.tsx @@ -1,3 +1,5 @@ +import '../ChatMessages/AIChat.css'; + import { useQuery } from '@tanstack/react-query'; import type { JSONContent } from '@tiptap/core'; import { @@ -14,13 +16,9 @@ import { Wand2, X, } from 'lucide-react'; -import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; import { useKeydown } from '../../hooks/use-keydown'; -import { - roadmapAIChatRenderer, - useRoadmapAIChat, -} from '../../hooks/use-roadmap-ai-chat'; import { cn } from '../../lib/classname'; import { lockBodyScroll } from '../../lib/dom'; import { isLoggedIn } from '../../lib/jwt'; @@ -33,10 +31,14 @@ import { roadmapJSONOptions } from '../../queries/roadmap'; import { roadmapQuestionsOptions } from '../../queries/roadmap-questions'; import { queryClient } from '../../stores/query-client'; import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; -import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard'; import { RoadmapAIChatHistory } from '../RoadmapAIChatHistory/RoadmapAIChatHistory'; import { CLOSE_TOPIC_DETAIL_EVENT } from '../TopicDetail/TopicDetail'; import { UpdatePersonaModal } from '../UserPersona/UpdatePersonaModal'; +import { shuffle } from '../../helper/shuffle'; +import { useChat } from '@ai-sdk/react'; +import { chatRoadmapTransport } from '../../lib/ai'; +import { useAIChatScroll } from '../../hooks/use-ai-chat-scroll'; +import { RoadmapChatMessages } from '../ChatMessages/RoadmapChatMessages'; type ChatHeaderButtonProps = { onClick?: () => void; @@ -158,10 +160,12 @@ type RoadmapChatProps = { export function RoadmapFloatingChat(props: RoadmapChatProps) { const { roadmapId } = props; + const [isOpen, setIsOpen] = useState(false); - const scrollareaRef = useRef(null); const [inputValue, setInputValue] = useState(''); + const inputRef = useRef(null); + const [isPersonalizeOpen, setIsPersonalizeOpen] = useState(false); const [showUpgradeModal, setShowUpgradeModal] = useState(false); @@ -176,9 +180,7 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) { if (!questionsData?.questions || questionsData.questions.length === 0) { return []; } - const shuffled = [...questionsData.questions].sort( - () => 0.5 - Math.random(), - ); + const shuffled = shuffle([...questionsData.questions]); return shuffled.slice(0, 4); }, [questionsData]); @@ -236,45 +238,36 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) { string | undefined >(); const { data: chatHistory } = useQuery( - chatHistoryOptions( - activeChatHistoryId, - roadmapAIChatRenderer({ - roadmapId, - totalTopicCount, - onSelectTopic, - }), - ), + chatHistoryOptions(activeChatHistoryId), queryClient, ); - const { - aiChatHistory, - isStreamingMessage, - streamedMessage, - showScrollToBottom, - setShowScrollToBottom, - handleChatSubmit, - handleAbort, - scrollToBottom, - clearChat, - setAiChatHistory, - } = useRoadmapAIChat({ - activeChatHistoryId, - roadmapId, - totalTopicCount, - scrollareaRef, - onSelectTopic, - onChatHistoryIdChange: (chatHistoryId) => { - setActiveChatHistoryId(chatHistoryId); + const { messages, sendMessage, status, stop, setMessages } = useChat({ + transport: chatRoadmapTransport, + onData: (data) => { + if (data.type === 'data-redirect') { + const { title, chatId } = data.data as { + title: string; + chatId: string; + }; + + document.title = title; + setActiveChatHistoryId(chatId); + } }, }); + const { scrollToBottom, scrollableContainerRef, showScrollToBottomButton } = + useAIChatScroll({ + messages, + }); + useEffect(() => { if (!chatHistory) { return; } - setAiChatHistory(chatHistory?.messages ?? []); + setMessages(chatHistory?.messages ?? []); setIsChatHistoryLoading(false); setTimeout(() => { scrollToBottom('instant'); @@ -286,9 +279,9 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) { return; } - setAiChatHistory([]); + setMessages([]); setIsChatHistoryLoading(false); - }, [activeChatHistoryId, setAiChatHistory, setIsChatHistoryLoading]); + }, [activeChatHistoryId]); useEffect(() => { lockBodyScroll(isOpen); @@ -320,26 +313,45 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) { }; } - const submitInput = () => { + const clearChat = () => { + setMessages([]); + setInputValue(''); + }; + + const submitInput = (message?: string) => { if (!isLoggedIn()) { setIsOpen(false); showLoginPopup(); return; } - const trimmed = inputValue.trim(); + const trimmed = (message ?? inputValue).trim(); if (!trimmed) { return; } - const json: JSONContent = textToJSON(trimmed); + sendMessage( + { text: trimmed, metadata: { json: textToJSON(trimmed) } }, + { + body: { + roadmapId, + ...(activeChatHistoryId + ? { chatHistoryId: activeChatHistoryId } + : {}), + }, + }, + ); - setInputValue(''); - handleChatSubmit(json, isRoadmapDetailLoading); + setTimeout(() => { + scrollToBottom('smooth'); + setInputValue(''); + inputRef.current?.focus(); + }, 0); }; - const hasMessages = aiChatHistory.length > 0; - const newTabUrl = `/${roadmapId}/ai${activeChatHistoryId ? `?chatId=${activeChatHistoryId}` : ''}`; + const isStreamingMessage = status !== 'ready'; + const hasMessages = messages.length > 0; + const newTabUrl = `/ai/r/${roadmapId}${activeChatHistoryId ? `?chatId=${activeChatHistoryId}` : ''}`; return ( <> @@ -371,7 +383,7 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
@@ -417,7 +429,6 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) { onChatHistoryClick={(chatHistoryId) => { setIsChatHistoryLoading(true); setActiveChatHistoryId(chatHistoryId); - setShowScrollToBottom(false); }} onDelete={(chatHistoryId) => { if (activeChatHistoryId === chatHistoryId) { @@ -443,82 +454,27 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) { />
-
-
- - Hey, I am your AI tutor. How can I help you today? 👋 - - } - isIntro +
+
+ - - {/* Show default questions only when there's no chat history */} - {aiChatHistory.length === 0 && - defaultQuestions.length > 0 && ( -
-

- Some questions you might have about this roadmap: -

-
- {defaultQuestions.map((question, index) => ( - - ))} -
-
- )} - - {aiChatHistory.map((chat, index) => ( - - - - ))} - - {isStreamingMessage && !streamedMessage && ( - - )} - - {streamedMessage && ( - - )}
- {/* Scroll to bottom button */} - {showScrollToBottom && ( + {showScrollToBottomButton && ( +
+
+ ); +} diff --git a/src/components/Global/Markdown.tsx b/src/components/Global/Markdown.tsx new file mode 100644 index 000000000..ac41ea507 --- /dev/null +++ b/src/components/Global/Markdown.tsx @@ -0,0 +1,99 @@ +import 'katex/dist/katex.min.css'; + +import { memo } from 'react'; +import ReactMarkdown, { type Options } from 'react-markdown'; +import rehypeKatex from 'rehype-katex'; +import remarkGfm from 'remark-gfm'; +import remarkMath from 'remark-math'; +import { cn } from '../../lib/classname'; +import { + CodeBlockContent, + CodeBlockHeader, + CodeBlockItem, + type BundledLanguage, +} from './CodeBlock'; + +function getLanguage(children: React.ReactNode) { + if ( + typeof children === 'object' && + children !== null && + 'type' in children && + children.type === 'code' && + 'props' in children && + typeof children.props === 'object' && + children.props !== null && + 'className' in children.props && + typeof children.props.className === 'string' + ) { + return children.props.className.replace('language-', '').trim(); + } + + return 'javascript'; +} + +const components: Options['components'] = { + pre: (props) => { + const { children } = props; + + const language = getLanguage(children); + const childrenIsCode = + typeof children === 'object' && + children !== null && + 'type' in children && + children.type === 'code'; + if (!childrenIsCode) { + return
{children}
; + } + + // it's fine to do it, because we only have one code block in the markdown + // so no worries, it will be fine + // we need to remove the last line because it always add a empty line at the end + // @see https://github.com/shikijs/shiki/pull/585 + const code = (children.props as { children: string })?.children?.slice( + 0, + -1 + ); + + return ( +
+ + + + + {code} + + +
+ ); + }, +}; + +type MarkdownProps = { + children: string; + className?: string; +}; + +function _Markdown(props: MarkdownProps) { + const { children, className } = props; + + return ( +
*:first-child]:mt-0 [&>*:last-child]:mb-0', + className + )} + > + + {children} + +
+ ); +} + +export const Markdown = memo(_Markdown, (prevProps, nextProps) => { + return prevProps.children === nextProps.children; +}); diff --git a/src/components/TopicDetail/TopicDetail.tsx b/src/components/TopicDetail/TopicDetail.tsx index 3cead482e..272d07453 100644 --- a/src/components/TopicDetail/TopicDetail.tsx +++ b/src/components/TopicDetail/TopicDetail.tsx @@ -25,9 +25,7 @@ import { Ban, FileText, HeartHandshake, Star, X } from 'lucide-react'; import { getUrlParams, parseUrl } from '../../lib/browser'; import { Spinner } from '../ReactIcons/Spinner'; import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx'; -import { - type AllowedRoadmapRenderer -} from '../../lib/roadmap.ts'; +import { type AllowedRoadmapRenderer } from '../../lib/roadmap.ts'; import { lockBodyScroll } from '../../lib/dom.ts'; import { TopicDetailLink } from './TopicDetailLink.tsx'; import { ResourceListSeparator } from './ResourceListSeparator.tsx'; @@ -42,6 +40,8 @@ import type { AIChatHistoryType } from '../GenerateCourse/AICourseLessonChat.tsx import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal.tsx'; import { TopicProgressButton } from './TopicProgressButton.tsx'; import { CreateCourseModal } from './CreateCourseModal.tsx'; +import { useChat } from '@ai-sdk/react'; +import { topicDetailAiChatTransport } from '../../lib/ai.ts'; type PaidResourceType = { _id?: string; @@ -134,8 +134,6 @@ export function TopicDetail(props: TopicDetailProps) { const [links, setLinks] = useState([]); const [activeTab, setActiveTab] = useState(defaultActiveTab); - const [aiChatHistory, setAiChatHistory] = - useState(defaultChatHistory); const [showUpgradeModal, setShowUpgradeModal] = useState(false); const [isCustomResource, setIsCustomResource] = useState(false); @@ -156,14 +154,20 @@ export function TopicDetail(props: TopicDetailProps) { const [resourceType, setResourceType] = useState('roadmap'); const [paidResources, setPaidResources] = useState([]); + const chatId = `${resourceType}-${resourceId}-${topicId}`; + const { messages, sendMessage, setMessages, status } = useChat({ + id: chatId, + transport: topicDetailAiChatTransport, + }); + const handleClose = () => { onClose?.(); setIsActive(false); setIsContributing(false); setShowUpgradeModal(false); - setAiChatHistory(defaultChatHistory); setActiveTab('content'); setShowSubjectSearchModal(false); + setMessages([]); lockBodyScroll(false); @@ -485,8 +489,10 @@ export function TopicDetail(props: TopicDetailProps) { resourceId={resourceId} resourceType={resourceType} topicId={topicId} - aiChatHistory={aiChatHistory} - setAiChatHistory={setAiChatHistory} + messages={messages} + setMessages={setMessages} + status={status} + sendMessage={sendMessage} hasUpgradeButtons={hasUpgradeButtons} onUpgrade={() => setShowUpgradeModal(true)} onLogin={() => { diff --git a/src/components/TopicDetail/TopicDetailAI.tsx b/src/components/TopicDetail/TopicDetailAI.tsx index a7898af9e..ad69c0347 100644 --- a/src/components/TopicDetail/TopicDetailAI.tsx +++ b/src/components/TopicDetail/TopicDetailAI.tsx @@ -1,3 +1,5 @@ +import '../ChatMessages/AIChat.css'; + import { useQuery } from '@tanstack/react-query'; import { BotIcon, @@ -9,14 +11,11 @@ import { Trash2, WandSparkles, } from 'lucide-react'; -import { Fragment, useCallback, useEffect, useRef, useState } from 'react'; -import { flushSync } from 'react-dom'; +import { useEffect, useRef, useState } from 'react'; import TextareaAutosize from 'react-textarea-autosize'; import { useToast } from '../../hooks/use-toast'; -import { readStream } from '../../lib/ai'; import { cn } from '../../lib/classname'; -import { isLoggedIn, removeAuthToken } from '../../lib/jwt'; -import { markdownToHtmlWithHighlighting } from '../../lib/markdown'; +import { isLoggedIn } from '../../lib/jwt'; import { getPercentage } from '../../lib/number'; import { showLoginPopup } from '../../lib/popup'; import type { ResourceType } from '../../lib/resource-progress'; @@ -24,14 +23,12 @@ import { aiLimitOptions } from '../../queries/ai-course'; import { billingDetailsOptions } from '../../queries/billing'; import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree'; import { queryClient } from '../../stores/query-client'; -import { - AIChatCard, - type AIChatHistoryType, -} from '../GenerateCourse/AICourseLessonChat'; -import '../GenerateCourse/AICourseLessonChat.css'; import { AILimitsPopup } from '../GenerateCourse/AILimitsPopup'; -import { PredefinedActions, promptLabelMapping } from './PredefinedActions'; -import { defaultChatHistory } from './TopicDetail'; +import { PredefinedActions } from './PredefinedActions'; +import type { ChatStatus, UIMessage } from 'ai'; +import type { UseChatHelpers } from '@ai-sdk/react'; +import { useAIChatScroll } from '../../hooks/use-ai-chat-scroll'; +import { TopicChatMessages } from '../ChatMessages/TopicChatMessages'; type TopicDetailAIProps = { resourceId: string; @@ -40,8 +37,10 @@ type TopicDetailAIProps = { hasUpgradeButtons?: boolean; - aiChatHistory: AIChatHistoryType[]; - setAiChatHistory: (history: AIChatHistoryType[]) => void; + messages: UIMessage[]; + sendMessage: UseChatHelpers['sendMessage']; + setMessages: UseChatHelpers['setMessages']; + status: ChatStatus; onUpgrade: () => void; onLogin: () => void; @@ -51,8 +50,11 @@ type TopicDetailAIProps = { export function TopicDetailAI(props: TopicDetailAIProps) { const { - aiChatHistory, - setAiChatHistory, + messages, + sendMessage, + setMessages, + status, + resourceId, resourceType, topicId, @@ -63,7 +65,6 @@ export function TopicDetailAI(props: TopicDetailAIProps) { } = props; const textareaRef = useRef(null); - const scrollareaRef = useRef(null); const formRef = useRef(null); const sanitizedTopicId = topicId?.includes('@') @@ -72,8 +73,6 @@ export function TopicDetailAI(props: TopicDetailAIProps) { const toast = useToast(); const [message, setMessage] = useState(''); - const [isStreamingMessage, setIsStreamingMessage] = useState(false); - const [streamedMessage, setStreamedMessage] = useState(''); const [showAILimitsPopup, setShowAILimitsPopup] = useState(false); const { data: tokenUsage, isLoading } = useQuery( aiLimitOptions(), @@ -105,7 +104,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) { if ( !trimmedMessage || - isStreamingMessage || + status !== 'ready' || !isLoggedIn() || isLimitExceeded || isLoading @@ -113,110 +112,30 @@ export function TopicDetailAI(props: TopicDetailAIProps) { return; } - const newMessages: AIChatHistoryType[] = [ - ...aiChatHistory, + sendMessage( { - role: 'user', - content: trimmedMessage, + text: trimmedMessage, }, - ]; + { + body: { + resourceId, + resourceType, + topicId: sanitizedTopicId, + }, + }, + ); - flushSync(() => { - setAiChatHistory(newMessages); - setMessage(''); - }); - - scrollToBottom(); - completeAITutorChat(newMessages); + setMessage(''); + setTimeout(() => { + scrollToBottom(); + textareaRef.current?.focus(); + }, 0); }; - const scrollToBottom = useCallback(() => { - scrollareaRef.current?.scrollTo({ - top: scrollareaRef.current.scrollHeight, - behavior: 'smooth', + const { scrollToBottom, scrollableContainerRef, showScrollToBottomButton } = + useAIChatScroll({ + messages, }); - }, [scrollareaRef]); - - const completeAITutorChat = async (messages: AIChatHistoryType[]) => { - try { - setIsStreamingMessage(true); - - const response = await fetch( - `${import.meta.env.PUBLIC_API_URL}/v1-topic-detail-chat`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - body: JSON.stringify({ - resourceId, - resourceType, - topicId: sanitizedTopicId, - messages: messages.slice(-10), - }), - }, - ); - - if (!response.ok) { - const data = await response.json(); - - toast.error(data?.message || 'Something went wrong'); - setAiChatHistory([...messages].slice(0, messages.length - 1)); - setIsStreamingMessage(false); - - if (data.status === 401) { - removeAuthToken(); - window.location.reload(); - } - - queryClient.invalidateQueries(aiLimitOptions()); - return; - } - - const reader = response.body?.getReader(); - - if (!reader) { - setIsStreamingMessage(false); - toast.error('Something went wrong'); - return; - } - - await readStream(reader, { - onStream: async (content) => { - flushSync(() => { - setStreamedMessage(content); - }); - - scrollToBottom(); - }, - onStreamEnd: async (content) => { - const newMessages: AIChatHistoryType[] = [ - ...messages, - { - role: 'assistant', - content, - html: await markdownToHtmlWithHighlighting(content), - }, - ]; - - flushSync(() => { - setStreamedMessage(''); - setIsStreamingMessage(false); - setAiChatHistory(newMessages); - }); - - queryClient.invalidateQueries(aiLimitOptions()); - scrollToBottom(); - }, - }); - - setIsStreamingMessage(false); - } catch (error) { - toast.error('Something went wrong'); - setIsStreamingMessage(false); - } - }; useEffect(() => { scrollToBottom(); @@ -228,7 +147,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) { tokenUsage?.used || 0, tokenUsage?.limit || 0, ); - const hasChatHistory = aiChatHistory.length > 1; + const hasChatHistory = messages.length > 0; const nodeTextParts = roadmapTreeMapping?.text?.split('>') || []; const hasSubjects = (roadmapTreeMapping?.subjects && @@ -236,7 +155,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) { nodeTextParts.length > 1; return ( -
+
{isDataLoading && (
@@ -279,7 +198,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) { } }} href={`/ai/course?term=${subject}&difficulty=beginner&src=topic`} - className="flex items-center gap-1 gap-2 rounded-md border border-gray-300 bg-gray-100 px-2 py-1 hover:bg-gray-200 hover:text-black" + className="flex items-center gap-1 rounded-md border border-gray-300 bg-gray-100 px-2 py-1 hover:bg-gray-200 hover:text-black" > {subject} @@ -349,7 +268,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) {