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) => (
+ onDefaultQuestionClick?.(question)}
+ >
+ {question}
+
+ ))}
+
+
+ )}
+
+ {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 (
+
+ );
+}
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 (
+ {
+ if (!title) {
+ return;
+ }
+
+ onTopicClick?.(item.topicId, title);
+ }}
+ >
+ {labelParts.map((part, index) => {
+ return (
+
+ {part}
+ {index < labelPartCount - 1 && (
+
+ )}
+
+ );
+ })}
+
+ );
+ })}
+
+ );
+}
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 (
+
+
+ {!isCopied && (
+ <>
+
+ Share Progress
+ >
+ )}
+
+ {isCopied && (
+ <>
+
+ Copied
+ >
+ )}
+
+
+ );
+}
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 && (
+
+ setShowAll(!showAll)}
+ disabled={isLoading}
+ >
+ {isLoading && (
+ <>
+
+ {progressItemWithText.length} loaded ..
+ >
+ )}
+
+ {!isLoading && (
+ <>
+ {showAll
+ ? '- Show Less'
+ : `+ Show ${progressItemWithText.length - itemCountToShow} More`}
+ >
+ )}
+
+
+ {
+ const done = updateUserProgress
+ .filter((item) => item.action === 'done')
+ .map((item) => item.id);
+ const learning = updateUserProgress
+ .filter((item) => item.action === 'learning')
+ .map((item) => item.id);
+ const skipped = updateUserProgress
+ .filter((item) => item.action === 'skipped')
+ .map((item) => item.id);
+ const pending = updateUserProgress
+ .filter((item) => item.action === 'pending')
+ .map((item) => item.id);
+
+ bulkUpdateResourceProgress({
+ done,
+ learning,
+ skipped,
+ pending,
+ });
+ }}
+ >
+ {isBulkUpdating && (
+
+ )}
+ {!isBulkUpdating && }
+ Apply All
+
+
+ )}
+
+
+ );
+}
+
+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 && (
+ updateTopicStatus(action)}
+ disabled={isStreaming || isUpdating || isBulkUpdating}
+ >
+ {(isUpdating || isBulkUpdating) && (
+
+ )}
+ {!isUpdating && !isBulkUpdating && (
+ <>
+
+ Mark it as {action}
+ >
+ )}
+
+ )}
+ {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) => (
- {
- if (!isLoggedIn()) {
- setIsOpen(false);
- showLoginPopup();
- return;
- }
-
- if (isLimitExceeded) {
- setShowUpgradeModal(true);
- setIsOpen(false);
- return;
- }
-
- handleChatSubmit(
- textToJSON(question),
- isRoadmapDetailLoading,
- );
- }}
- >
- {question}
-
- ))}
-
-
- )}
-
- {aiChatHistory.map((chat, index) => (
-
-
-
- ))}
-
- {isStreamingMessage && !streamedMessage && (
-
- )}
-
- {streamedMessage && (
-
- )}
- {/* Scroll to bottom button */}
- {showScrollToBottom && (
+ {showScrollToBottomButton && (
{
scrollToBottom('instant');
- setShowScrollToBottom(false);
}}
- className="sticky bottom-0 mx-auto mt-2 flex items-center gap-1.5 rounded-full bg-gray-900 px-3 py-1.5 text-xs text-white shadow-lg transition-all hover:bg-gray-800"
+ className="absolute inset-x-0 bottom-2 mx-auto mt-2 flex w-fit items-center gap-1.5 rounded-full bg-gray-900 px-3 py-1.5 text-xs text-white shadow-lg transition-all hover:bg-gray-800"
>
Scroll to bottom
@@ -534,6 +490,7 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
}}
/>
)}
+
{!isLimitExceeded && (
<>
@@ -587,9 +544,10 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
- if (isStreamingMessage) {
+ if (status !== 'ready') {
return;
}
+
submitInput();
}
}}
@@ -609,9 +567,10 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
disabled={isRoadmapDetailLoading || isLimitExceeded}
onClick={() => {
if (isStreamingMessage) {
- handleAbort();
+ stop();
return;
}
+
submitInput();
}}
>
@@ -637,7 +596,6 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
setIsOpen(true);
setTimeout(() => {
scrollToBottom('instant');
- setShowScrollToBottom(false);
}, 0);
}}
>
diff --git a/src/components/Global/CodeBlock.tsx b/src/components/Global/CodeBlock.tsx
new file mode 100644
index 000000000..2b07086bb
--- /dev/null
+++ b/src/components/Global/CodeBlock.tsx
@@ -0,0 +1,160 @@
+import {
+ transformerNotationDiff,
+ transformerNotationErrorLevel,
+ transformerNotationFocus,
+ transformerNotationHighlight,
+ transformerNotationWordHighlight,
+} from '@shikijs/transformers';
+import { CheckIcon, CopyIcon } from 'lucide-react';
+import type { HTMLAttributes } from 'react';
+import { useLayoutEffect, useState } from 'react';
+import { type BundledLanguage, codeToHtml } from 'shiki';
+import { cn } from '../../lib/classname';
+import { useCopyText } from '../../hooks/use-copy-text';
+export type { BundledLanguage } from 'shiki';
+
+const codeBlockClassName = cn(
+ 'mt-0 text-sm',
+ '[&_pre]:py-0',
+ '[&_pre]:grid',
+ '[&_code]:py-4',
+ '[&_code]:w-full',
+ '[&_code]:grid',
+ '[&_code]:overflow-x-auto',
+ '[&_code]:no-scrollbar',
+ '[&_code]:bg-transparent',
+ '[&_.line]:px-3',
+ '[&_.line]:w-full',
+ '[&_.line]:relative',
+ '[&_.line]:min-h-5',
+);
+
+function highlight(html: string, language?: BundledLanguage) {
+ return codeToHtml(html, {
+ lang: language ?? 'typescript',
+ theme: 'github-light',
+ transformers: [
+ transformerNotationDiff({
+ matchAlgorithm: 'v3',
+ }),
+ transformerNotationHighlight({
+ matchAlgorithm: 'v3',
+ }),
+ transformerNotationWordHighlight({
+ matchAlgorithm: 'v3',
+ }),
+ transformerNotationFocus({
+ matchAlgorithm: 'v3',
+ }),
+ transformerNotationErrorLevel({
+ matchAlgorithm: 'v3',
+ }),
+ ],
+ });
+}
+
+type CodeBlockFallbackProps = HTMLAttributes
;
+
+const CodeBlockFallback = ({ children, ...props }: CodeBlockFallbackProps) => (
+
+
+
+ {children
+ ?.toString()
+ .split('\n')
+ .map((line, i) => (
+
+ {line}
+
+ ))}
+
+
+
+);
+
+export type CodeBlockItemProps = HTMLAttributes & {
+ value: string;
+ lineNumbers?: boolean;
+};
+
+export const CodeBlockItem = ({
+ children,
+ lineNumbers = true,
+ className,
+ value,
+ ...props
+}: CodeBlockItemProps) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export type CodeBlockContentProps = HTMLAttributes & {
+ language?: BundledLanguage;
+ syntaxHighlighting?: boolean;
+ children: string;
+};
+
+export const CodeBlockContent = ({
+ children,
+ language,
+ syntaxHighlighting = true,
+ ...props
+}: CodeBlockContentProps) => {
+ const [html, setHtml] = useState(null);
+ useLayoutEffect(() => {
+ if (!syntaxHighlighting) {
+ return;
+ }
+
+ if (typeof children !== 'string') {
+ return;
+ }
+
+ highlight(children, language).then(setHtml).catch(console.error);
+ }, [children, syntaxHighlighting, language]);
+
+ if (!(syntaxHighlighting && html)) {
+ return {children} ;
+ }
+
+ return
;
+};
+
+type CodeBlockHeaderProps = HTMLAttributes & {
+ language: string;
+ code: string;
+};
+
+export function CodeBlockHeader(props: CodeBlockHeaderProps) {
+ const { language, code, className, ...rest } = props;
+
+ const { copyText, isCopied } = useCopyText();
+
+ return (
+
+
{language}
+
+
+ copyText(code)}
+ className="flex size-6 items-center justify-center gap-2 rounded-md text-gray-400 hover:bg-zinc-200 hover:text-black focus:outline-none"
+ >
+ {isCopied ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
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) {
{
- setAiChatHistory(defaultChatHistory);
+ setMessages([]);
}}
>
@@ -416,39 +335,9 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
-
-
-
- {aiChatHistory.map((chat, index) => {
- let content = chat.content;
-
- if (chat.role === 'user' && promptLabelMapping[chat.content]) {
- content = promptLabelMapping[chat.content];
- }
-
- return (
-
-
-
- );
- })}
-
- {isStreamingMessage && !streamedMessage && (
-
- )}
-
- {streamedMessage && (
-
- )}
-
-
-
+
diff --git a/src/hooks/use-ai-chat-scroll.tsx b/src/hooks/use-ai-chat-scroll.tsx
new file mode 100644
index 000000000..f1a9cd42a
--- /dev/null
+++ b/src/hooks/use-ai-chat-scroll.tsx
@@ -0,0 +1,85 @@
+import type { UIMessage } from 'ai';
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+type UseAIChatScrollProps = {
+ messages: UIMessage[];
+ threshold?: number;
+};
+
+export function useAIChatScroll(props: UseAIChatScrollProps) {
+ const { messages, threshold = 80 } = props;
+
+ const scrollableContainerRef = useRef(null);
+ const [showScrollToBottomButton, setShowScrollToBottomButton] =
+ useState(false);
+
+ const canScrollToBottom = useCallback(() => {
+ const scrollableContainer = scrollableContainerRef?.current;
+ if (!scrollableContainer) {
+ return false;
+ }
+
+ const paddingBottom = parseInt(
+ getComputedStyle(scrollableContainer).paddingBottom
+ );
+
+ const distanceFromBottom =
+ scrollableContainer.scrollHeight -
+ (scrollableContainer.scrollTop + scrollableContainer.clientHeight) -
+ paddingBottom;
+
+ return distanceFromBottom > -(paddingBottom - threshold);
+ }, [threshold]);
+
+ const scrollToBottom = useCallback(
+ (behavior: 'instant' | 'smooth' = 'smooth') => {
+ const scrollableContainer = scrollableContainerRef?.current;
+ if (!scrollableContainer) {
+ return;
+ }
+
+ scrollableContainer.scrollTo({
+ top: scrollableContainer.scrollHeight,
+ behavior: behavior === 'instant' ? 'instant' : 'smooth',
+ });
+ },
+ [scrollableContainerRef]
+ );
+
+ useEffect(() => {
+ const scrollableContainer = scrollableContainerRef.current;
+ if (!scrollableContainer) {
+ return;
+ }
+
+ const abortController = new AbortController();
+ let timeoutId: NodeJS.Timeout;
+ const debouncedHandleScroll = () => {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+
+ timeoutId = setTimeout(() => {
+ setShowScrollToBottomButton(canScrollToBottom());
+ }, 100);
+ };
+
+ debouncedHandleScroll();
+ scrollableContainer.addEventListener('scroll', debouncedHandleScroll, {
+ signal: abortController.signal,
+ });
+
+ return () => {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ abortController.abort();
+ };
+ }, [messages]);
+
+ return {
+ scrollableContainerRef,
+ showScrollToBottomButton,
+ scrollToBottom,
+ };
+}
diff --git a/src/hooks/use-completion.ts b/src/hooks/use-completion.ts
new file mode 100644
index 000000000..c7bd86bda
--- /dev/null
+++ b/src/hooks/use-completion.ts
@@ -0,0 +1,119 @@
+import { useCallback, useRef, useState } from 'react';
+import { fetchWithAuthHandling } from '../lib/ai';
+import type { CompletionPart } from '../lib/stream';
+import { readDataStream } from '../lib/stream';
+
+type CompleteOptions = {
+ headers?: Record;
+ body?: Record;
+};
+
+type CompletionStatus = 'idle' | 'loading' | 'streaming' | 'success' | 'error';
+
+export type CompletionContext = {
+ content: string;
+};
+
+type CompletionParams> = {
+ endpoint: string;
+ onStart?: (options?: CompleteOptions) => Promise | void;
+ onData?: (
+ part: CompletionPart,
+ context: CompletionContext
+ ) => Promise | void;
+ onFinish?: (
+ result: string,
+ context: CompletionContext
+ ) => Promise | void;
+ onError?: (error: Error) => void;
+};
+
+export function useCompletion<
+ D extends Record = Record,
+>(params: CompletionParams) {
+ const { endpoint, onData, onFinish, onError, onStart } = params;
+
+ const [status, setStatus] = useState('idle');
+
+ const [error, setError] = useState(null);
+ const [completion, setCompletion] = useState('');
+
+ const abortControllerRef = useRef(null);
+
+ const complete = useCallback(
+ async (options?: CompleteOptions) => {
+ setStatus('loading');
+ setError(null);
+ setCompletion('');
+
+ const controller = new AbortController();
+ abortControllerRef.current = controller;
+
+ try {
+ await onStart?.(options);
+
+ const url = endpoint.startsWith('http')
+ ? endpoint
+ : `${import.meta.env.VITE_API_URL}${endpoint}`;
+ const response = await fetchWithAuthHandling(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options?.headers,
+ },
+ credentials: 'include',
+ ...(options?.body ? { body: JSON.stringify(options.body) } : {}),
+ signal: controller.signal,
+ });
+
+ const stream = response.body;
+ if (!stream) {
+ throw new Error('No stream found');
+ }
+
+ setStatus('streaming');
+ let result = '';
+ await readDataStream(stream, {
+ onData: async (part) => {
+ if (part.type === 'text') {
+ result += part.content;
+ setCompletion(result);
+ }
+ await onData?.(part, { content: result });
+ },
+ });
+
+ await onFinish?.(result, { content: result });
+
+ abortControllerRef.current = null;
+ setStatus('success');
+ setCompletion(result);
+
+ return result;
+ } catch (error) {
+ // we can ignore abort errors
+ // as they are expected when the user cancels the request
+ if (error instanceof Error && error.name === 'AbortError') {
+ return null;
+ }
+
+ setError(error as Error);
+ onError?.(error as Error);
+ setStatus('error');
+ return null;
+ }
+ },
+ [endpoint, onData, onFinish, onError, onStart]
+ );
+
+ const stop = useCallback(() => {
+ if (!abortControllerRef.current) {
+ return;
+ }
+
+ abortControllerRef.current.abort();
+ abortControllerRef.current = null;
+ }, [abortControllerRef]);
+
+ return { status, error, stop, complete, completion, setCompletion };
+}
diff --git a/src/hooks/use-is-thinking.ts b/src/hooks/use-is-thinking.ts
new file mode 100644
index 000000000..2a7f0c4b9
--- /dev/null
+++ b/src/hooks/use-is-thinking.ts
@@ -0,0 +1,23 @@
+import { useMemo } from 'react';
+import type { ChatStatus, UIMessage } from 'ai';
+
+export function useIsThinking(messages: UIMessage[], status: ChatStatus) {
+ return useMemo(() => {
+ const lastMessage = messages.at(-1);
+ if (!lastMessage) {
+ return false;
+ }
+
+ if (status === 'submitted' && lastMessage.role === 'user') {
+ return true;
+ }
+
+ const hasText =
+ lastMessage.role === 'assistant' &&
+ lastMessage.parts.some(
+ (part) => part.type === 'text' && part.text?.trim(),
+ );
+
+ return (status === 'submitted' || status === 'streaming') && !hasText;
+ }, [messages, status]);
+}
diff --git a/src/hooks/use-personalized-roadmap.ts b/src/hooks/use-personalized-roadmap.ts
index b309a9fc2..27455b2a5 100644
--- a/src/hooks/use-personalized-roadmap.ts
+++ b/src/hooks/use-personalized-roadmap.ts
@@ -16,7 +16,7 @@ type UsePersonalizedRoadmapOptions = {
onFinish?: (data: PersonalizedRoadmapResponse) => void;
};
-export function usePersonalizedRoadmap(options: UsePersonalizedRoadmapOptions) {
+export function usePersonalizedRoadmap(options: UsePersonalizedRoadmapOptions) {
const { roadmapId, onError, onStart, onData, onFinish } = options;
const abortControllerRef = useRef(null);
diff --git a/src/lib/ai.ts b/src/lib/ai.ts
index 0afb4c05f..4e2f2b67f 100644
--- a/src/lib/ai.ts
+++ b/src/lib/ai.ts
@@ -1,4 +1,8 @@
+import Cookies from 'js-cookie';
import { nanoid } from 'nanoid';
+import { TOKEN_COOKIE_NAME } from './jwt';
+import { FetchError } from './query-http';
+import { DefaultChatTransport, type UIMessage } from 'ai';
export const IS_KEY_ONLY_ROADMAP_GENERATION = false;
@@ -353,3 +357,56 @@ export function generateAICourseRoadmapStructure(
return result;
}
+
+export type ChatUIMessage = UIMessage<
+ never,
+ {
+ redirect: {
+ title: string;
+ chatId: string;
+ };
+ }
+>;
+
+export const chatRoadmapTransport = new DefaultChatTransport({
+ api: import.meta.env.PUBLIC_API_URL + '/v1-chat-roadmap',
+ credentials: 'include',
+ fetch: fetchWithAuthHandling,
+});
+
+export const topicDetailAiChatTransport = new DefaultChatTransport({
+ api: import.meta.env.PUBLIC_API_URL + '/v1-topic-detail-chat',
+ credentials: 'include',
+ fetch: fetchWithAuthHandling,
+});
+
+export async function fetchWithAuthHandling(
+ input: RequestInfo | URL,
+ init?: RequestInit,
+) {
+ try {
+ const response = await fetch(input, init);
+ if (response.status === 401) {
+ Cookies.remove(TOKEN_COOKIE_NAME);
+ window?.location?.reload();
+ return null as unknown as Response;
+ }
+
+ if (!response.ok) {
+ const data = await response.json();
+ if (data?.errors) {
+ throw new FetchError(response.status, data.message);
+ } else {
+ throw new Error('An unexpected error occurred');
+ }
+ }
+
+ return response;
+ } catch (error: unknown) {
+ if (typeof navigator !== 'undefined' && !navigator.onLine) {
+ throw new FetchError(503, 'Service Unavailable');
+ }
+
+ throw error;
+ }
+}
diff --git a/src/lib/message-part.ts b/src/lib/message-part.ts
new file mode 100644
index 000000000..0ab0619c0
--- /dev/null
+++ b/src/lib/message-part.ts
@@ -0,0 +1,133 @@
+import { nanoid } from 'nanoid';
+
+type MessagePart = {
+ id: string;
+ type: string;
+ text?: string;
+ data?: any;
+};
+
+type MessagePartRendererProps = {
+ content: string;
+};
+
+export type MessagePartRenderer = (props: MessagePartRendererProps) => any;
+
+export function parseMessageParts(
+ content: string,
+ renderer: Record,
+) {
+ const parts: MessagePart[] = [];
+ const tagNames = Object.keys(renderer);
+
+ // Remove codeblocks around custom tags
+ if (tagNames.length > 0) {
+ const tagPattern = tagNames.join('|');
+
+ // Remove opening codeblock before tags: ```lang\n ->
+ // It sometimes puts codeblocks around our tags (despite us asking it not to)
+ // so we manually remove them here
+ content = content.replace(
+ new RegExp(`\`\`\`\\w*?\\n+?<(${tagPattern})>`, 'g'),
+ '<$1>',
+ );
+
+ // Remove closing codeblock after tags: \n``` ->
+ content = content.replace(
+ new RegExp(`<\\/(${tagPattern})>\\n+?\`\`\``, 'g'),
+ '$1>',
+ );
+ }
+
+ // If no renderers, just return the content as markdown
+ if (tagNames.length === 0) {
+ parts.push({
+ id: nanoid(),
+ type: 'text',
+ text: content,
+ });
+ return parts;
+ }
+
+ const tagPattern = tagNames.join('|');
+ const regex = new RegExp(`<(${tagPattern})>(.*?)<\/\\1>`, 'gs');
+
+ let lastIndex = 0;
+ let match;
+
+ // we will match only tags that have renderers
+ // and then we will render each tag with the corresponding renderer
+ // and then we will push the rendered content to the parts array
+ while ((match = regex.exec(content)) !== null) {
+ const [_, tag, innerContent] = match;
+
+ // push the text before the tag
+ // so that we can render it later
+ if (match.index > lastIndex) {
+ const rawBefore = content.slice(lastIndex, match.index);
+ parts.push({
+ id: nanoid(),
+ type: 'text',
+ text: rawBefore,
+ });
+ }
+
+ const data = renderer[tag]({
+ content: innerContent,
+ });
+ parts.push({
+ id: nanoid(),
+ type: tag,
+ data,
+ });
+
+ // update the last index
+ // so that we can render the next tag
+ lastIndex = regex.lastIndex;
+ }
+
+ // if there was an opening tag that never closed, check manually
+ // search for any known tag that starts but wasn't matched
+ for (const tag of tagNames) {
+ const openingTag = `<${tag}>`;
+ const openingIndex = content.indexOf(openingTag, lastIndex);
+ const closingTag = `${tag}>`;
+ const closingIndex = content.indexOf(closingTag, lastIndex);
+
+ if (openingIndex !== -1 && closingIndex === -1) {
+ if (openingIndex > lastIndex) {
+ const rawBefore = content.slice(lastIndex, openingIndex);
+ parts.push({
+ id: nanoid(),
+ type: 'text',
+ text: rawBefore,
+ });
+ }
+
+ const innerContent = content.slice(openingIndex + openingTag.length);
+ const data = renderer[tag]({
+ content: innerContent,
+ });
+ parts.push({
+ id: nanoid(),
+ type: tag,
+ data,
+ });
+
+ return parts;
+ }
+ }
+
+ // add the remaining content
+ if (lastIndex < content.length) {
+ const rawRemaining = content.slice(lastIndex);
+
+ parts.push({
+ id: nanoid(),
+ type: 'text',
+ text: rawRemaining,
+ });
+ }
+
+ return parts;
+}
diff --git a/src/lib/stream.ts b/src/lib/stream.ts
new file mode 100644
index 000000000..535b595c9
--- /dev/null
+++ b/src/lib/stream.ts
@@ -0,0 +1,100 @@
+export const CHAT_RESPONSE_PREFIX = {
+ message: '0',
+ details: 'd',
+} as const;
+
+const NEWLINE = '\n'.charCodeAt(0);
+
+function concatChunks(chunks: Uint8Array[], totalLength: number) {
+ const concatenatedChunks = new Uint8Array(totalLength);
+
+ let offset = 0;
+ for (const chunk of chunks) {
+ concatenatedChunks.set(chunk, offset);
+ offset += chunk.length;
+ }
+ chunks.length = 0;
+
+ return concatenatedChunks;
+}
+
+type CompletionTextPart = {
+ type: 'text';
+ content: string;
+};
+
+type CompletionDetailsPart> = {
+ type: 'details';
+ data: D;
+};
+
+export type CompletionPart<
+ D extends Record = Record,
+> = CompletionTextPart | CompletionDetailsPart;
+
+export async function readDataStream>(
+ stream: ReadableStream,
+ {
+ onData,
+ onFinish,
+ }: {
+ onData?: (part: CompletionPart) => Promise | void;
+ onFinish?: () => Promise | void;
+ },
+) {
+ const reader = stream.getReader();
+ const decoder = new TextDecoder('utf-8');
+ const chunks: Uint8Array[] = [];
+
+ let totalLength = 0;
+
+ while (true) {
+ const { value } = await reader.read();
+ if (value) {
+ chunks.push(value);
+ totalLength += value.length;
+ if (value[value.length - 1] !== NEWLINE) {
+ // if the last character is not a new line, we need to wait for the next chunk
+ continue;
+ }
+ }
+
+ if (chunks.length === 0) {
+ // end of stream
+ break;
+ }
+
+ const concatenatedChunks = concatChunks(chunks, totalLength);
+ totalLength = 0;
+
+ const streamParts: CompletionPart[] = decoder
+ .decode(concatenatedChunks, { stream: true })
+ .split('\n')
+ .filter((line) => line !== '')
+ .map((line) => {
+ const separatorIndex = line.indexOf(':');
+ if (separatorIndex === -1) {
+ throw new Error('Invalid line: ' + line + '. No separator found.');
+ }
+
+ const prefix = line.slice(0, separatorIndex);
+ const content = line.slice(separatorIndex + 1);
+
+ switch (prefix) {
+ case CHAT_RESPONSE_PREFIX.message:
+ return { type: 'text', content: JSON.parse(content) };
+ case CHAT_RESPONSE_PREFIX.details:
+ return { type: 'details', data: JSON.parse(content) };
+ default:
+ throw new Error('Invalid prefix: ' + prefix);
+ }
+ });
+
+ for (const part of streamParts) {
+ await onData?.(part);
+ }
+ }
+
+ await onFinish?.();
+ reader.releaseLock();
+}
diff --git a/src/queries/chat-history.ts b/src/queries/chat-history.ts
index 927f44ce4..dd5ec1848 100644
--- a/src/queries/chat-history.ts
+++ b/src/queries/chat-history.ts
@@ -12,6 +12,8 @@ import {
type RoadmapAIChatHistoryType,
} from '../hooks/use-roadmap-ai-chat';
import type { JSONContent } from '@tiptap/core';
+import type { UIMessage } from 'ai';
+import type { ChatUIMessage } from '../lib/ai';
export type ChatHistoryMessage = {
_id: string;
@@ -26,16 +28,13 @@ export interface ChatHistoryDocument {
userId: string;
roadmapId?: string;
title: string;
- messages: ChatHistoryMessage[];
+ messages: ChatUIMessage[];
createdAt: Date;
updatedAt: Date;
}
-export function chatHistoryOptions(
- chatHistoryId?: string,
- renderer?: Record,
-) {
+export function chatHistoryOptions(chatHistoryId?: string) {
return queryOptions({
queryKey: ['chat-history-details', chatHistoryId],
queryFn: async () => {
@@ -47,31 +46,7 @@ export function chatHistoryOptions(
document.title = data.title;
}
- const messages: RoadmapAIChatHistoryType[] = [];
- for (const message of data.messages) {
- messages.push({
- role: message.role,
- content: message.content,
- ...(message.role === 'user' &&
- !message?.json && {
- html: markdownToHtml(message.content),
- }),
- ...(message.role === 'user' &&
- message?.json && {
- html: htmlFromTiptapJSON(message.json),
- }),
- ...(message.role === 'assistant' && {
- jsx: await renderMessage(message.content, renderer ?? {}, {
- isLoading: false,
- }),
- }),
- });
- }
-
- return {
- ...data,
- messages,
- };
+ return data;
},
enabled: !!isLoggedIn() && !!chatHistoryId,
});