mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-19 07:31:24 +02:00
refactor: floating and topic ai
This commit is contained in:
committed by
Kamran Ahmed
parent
20c1a54198
commit
07b85c032a
10
package.json
10
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",
|
||||
|
474
pnpm-lock.yaml
generated
474
pnpm-lock.yaml
generated
@@ -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)
|
||||
|
131
src/components/ChatMessages/AIChat.css
Normal file
131
src/components/ChatMessages/AIChat.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
108
src/components/ChatMessages/RoadmapChatIntroMessage.tsx
Normal file
108
src/components/ChatMessages/RoadmapChatIntroMessage.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-2 text-sm text-gray-700">
|
||||
<div className="flex items-start gap-3">
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium text-gray-900">
|
||||
Hi! I'm your AI learning assistant 👋
|
||||
</h3>
|
||||
<p className="mb-3">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-gray-900">
|
||||
Here's what I can help you with:
|
||||
</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
{capabilities.map((capability, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<span className={`font-medium`}>{capability.icon}</span>
|
||||
<div>
|
||||
<span className="font-medium text-black">
|
||||
{capability.title}
|
||||
</span>{' '}
|
||||
{capability.description}
|
||||
<div className="mt-1 text-xs text-gray-600">
|
||||
Try: {capability.examples}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-lg bg-gray-50 p-3">
|
||||
<p className="text-xs text-black">
|
||||
<span className="font-medium">Tip:</span> 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!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
148
src/components/ChatMessages/RoadmapChatMessage.tsx
Normal file
148
src/components/ChatMessages/RoadmapChatMessage.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col rounded-lg',
|
||||
role === 'user' ? 'bg-gray-300/30' : 'bg-yellow-500/30',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2.5 p-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-6 shrink-0 items-center justify-center rounded-full',
|
||||
role === 'user'
|
||||
? 'bg-gray-200 text-black'
|
||||
: 'bg-yellow-400 text-black',
|
||||
)}
|
||||
>
|
||||
{role === 'user' ? (
|
||||
<User2Icon className="size-4 stroke-[2.5]" />
|
||||
) : (
|
||||
<BotIcon className="size-4 stroke-[2.5]" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{children || (
|
||||
<div>
|
||||
{message.parts.map((part) => {
|
||||
const { type } = part;
|
||||
|
||||
if (role === 'user' && type === 'text') {
|
||||
return (
|
||||
<div
|
||||
key={`message-${message.id}-part-${type}`}
|
||||
className="prose prose-sm message-markdown max-w-full text-sm"
|
||||
dangerouslySetInnerHTML={{ __html: part.text ?? '' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Markdown
|
||||
key={key}
|
||||
className="prose prose-sm message-markdown max-w-full text-sm"
|
||||
>
|
||||
{part.text ?? ''}
|
||||
</Markdown>
|
||||
);
|
||||
} else if (type === 'user-progress') {
|
||||
return (
|
||||
<RoadmapChatUserProgressList
|
||||
key={key}
|
||||
roadmapId={roadmapId}
|
||||
/>
|
||||
);
|
||||
} else if (type === 'update-progress') {
|
||||
return (
|
||||
<UserProgressActionList
|
||||
key={key}
|
||||
roadmapId={roadmapId}
|
||||
updateUserProgress={part.data}
|
||||
isLoading={isStreaming}
|
||||
/>
|
||||
);
|
||||
} else if (type === 'roadmap-topics') {
|
||||
return (
|
||||
<RoadmapTopicList
|
||||
key={key}
|
||||
roadmapId={roadmapId}
|
||||
topics={part.data}
|
||||
onTopicClick={onTopicClick}
|
||||
/>
|
||||
);
|
||||
} else if (type === 'resource-progress-link') {
|
||||
return (
|
||||
<ShareResourceLink key={key} roadmapId={roadmapId} />
|
||||
);
|
||||
} else if (type === 'roadmap-recommendations') {
|
||||
return (
|
||||
<RoadmapRecommendations
|
||||
key={key}
|
||||
roadmapSlugs={part.data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
110
src/components/ChatMessages/RoadmapChatMessages.tsx
Normal file
110
src/components/ChatMessages/RoadmapChatMessages.tsx
Normal file
@@ -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 (
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<div className="relative flex grow flex-col justify-end">
|
||||
<div className="flex flex-col justify-end gap-2 px-3 py-2">
|
||||
<RoadmapChatMessage
|
||||
roadmapId={roadmapId}
|
||||
message={{
|
||||
id: '__welcome_message__',
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Hello, how can I help you today?',
|
||||
},
|
||||
],
|
||||
}}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
|
||||
{messages.length === 0 &&
|
||||
defaultQuestions &&
|
||||
defaultQuestions.length > 0 && (
|
||||
<div className="mt-0.5 mb-1">
|
||||
<p className="mb-2 text-xs font-normal text-gray-500">
|
||||
Some questions you might have about this roadmap:
|
||||
</p>
|
||||
<div className="flex flex-col justify-end gap-1">
|
||||
{defaultQuestions.map((question, index) => (
|
||||
<button
|
||||
key={`default-question-${index}`}
|
||||
className="flex h-full self-start rounded-md bg-yellow-500/10 px-3 py-2 text-left text-sm text-black hover:bg-yellow-500/20"
|
||||
onClick={() => onDefaultQuestionClick?.(question)}
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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 (
|
||||
<RoadmapChatMessage
|
||||
key={message.id}
|
||||
roadmapId={roadmapId}
|
||||
message={message}
|
||||
isStreaming={isStreaming}
|
||||
onTopicClick={onTopicClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{isThinking && (
|
||||
<RoadmapChatMessage
|
||||
roadmapId={roadmapId}
|
||||
message={{
|
||||
id: '__thinking_message__',
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Thinking...',
|
||||
},
|
||||
],
|
||||
}}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const RoadmapChatMessages = memo(_RoadmapChatMessages);
|
82
src/components/ChatMessages/RoadmapRecommendations.tsx
Normal file
82
src/components/ChatMessages/RoadmapRecommendations.tsx
Normal file
@@ -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>.*?<\/roadmap-slug>/gs;
|
||||
const roadmapSlugListItems = content.match(roadmapSlugListRegex);
|
||||
if (!roadmapSlugListItems) {
|
||||
return items;
|
||||
}
|
||||
|
||||
for (const roadmapSlugListItem of roadmapSlugListItems) {
|
||||
const roadmapSlugRegex = /<roadmap-slug>(.*?)<\/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 (
|
||||
<div className="relative my-6 flex flex-wrap gap-1 first:mt-0 last:mb-0">
|
||||
{progressItemWithText.map((item) => (
|
||||
<a
|
||||
href={`/${item.roadmapSlug}/ai`}
|
||||
target="_blank"
|
||||
key={item.roadmapSlug}
|
||||
className="group flex h-[34px] items-center gap-2 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-left text-sm text-gray-700 transition-all hover:border-gray-400 hover:text-black active:bg-gray-100"
|
||||
>
|
||||
{item.title}
|
||||
{isLoading && (
|
||||
<Loader2Icon className="size-3.5 animate-spin text-gray-400 group-hover:text-gray-600" />
|
||||
)}
|
||||
{!isLoading && (
|
||||
<SquareArrowOutUpRightIcon className="ml-1 size-3.5 text-gray-400 transition-transform group-hover:text-gray-600" />
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
106
src/components/ChatMessages/RoadmapTopicList.tsx
Normal file
106
src/components/ChatMessages/RoadmapTopicList.tsx
Normal file
@@ -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>.*?<\/topic-id>/gs;
|
||||
const topicListItems = content.match(topicListRegex);
|
||||
if (!topicListItems) {
|
||||
return items;
|
||||
}
|
||||
|
||||
for (const topicListItem of topicListItems) {
|
||||
const topicIdRegex = /<topic-id>(.*?)<\/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 (
|
||||
<div className="relative my-6 flex flex-wrap gap-1 first:mt-0 last:mb-0">
|
||||
{progressItemWithText.map((item) => {
|
||||
const labelParts = item.text.split(' > ').slice(-2);
|
||||
const labelPartCount = labelParts.length;
|
||||
|
||||
const title = item.text.split(' > ').pop();
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.topicId}
|
||||
className="collapse-if-empty flex items-center gap-1 rounded-lg border border-gray-200 bg-white p-1 px-2 text-left text-sm hover:bg-gray-50"
|
||||
onClick={() => {
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
|
||||
onTopicClick?.(item.topicId, title);
|
||||
}}
|
||||
>
|
||||
{labelParts.map((part, index) => {
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
<span>{part}</span>
|
||||
{index < labelPartCount - 1 && (
|
||||
<ChevronRightIcon
|
||||
className="size-3 text-gray-400"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
47
src/components/ChatMessages/ShareResourceLink.tsx
Normal file
47
src/components/ChatMessages/ShareResourceLink.tsx
Normal file
@@ -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 (
|
||||
<div className="relative my-6 flex flex-wrap gap-1 first:mt-0 last:mb-0">
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white p-1 px-1.5 text-left text-sm',
|
||||
isCopied && 'text-green-500',
|
||||
)}
|
||||
onClick={handleShareResourceLink}
|
||||
>
|
||||
{!isCopied && (
|
||||
<>
|
||||
<ShareIcon className="h-4 w-4" />
|
||||
Share Progress
|
||||
</>
|
||||
)}
|
||||
|
||||
{isCopied && (
|
||||
<>
|
||||
<CheckIcon additionalClasses="size-4" />
|
||||
Copied
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
63
src/components/ChatMessages/TopicChatMessage.tsx
Normal file
63
src/components/ChatMessages/TopicChatMessage.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col rounded-lg',
|
||||
role === 'user' ? 'bg-gray-300/30' : 'bg-yellow-500/30',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2.5 p-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-6 shrink-0 items-center justify-center rounded-full',
|
||||
role === 'user'
|
||||
? 'bg-gray-200 text-black'
|
||||
: 'bg-yellow-400 text-black',
|
||||
)}
|
||||
>
|
||||
{role === 'user' ? (
|
||||
<User2Icon className="size-4 stroke-[2.5]" />
|
||||
) : (
|
||||
<BotIcon className="size-4 stroke-[2.5]" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{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 (
|
||||
<Markdown
|
||||
key={key}
|
||||
className="prose prose-sm message-markdown max-w-full text-sm"
|
||||
>
|
||||
{content}
|
||||
</Markdown>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
57
src/components/ChatMessages/TopicChatMessages.tsx
Normal file
57
src/components/ChatMessages/TopicChatMessages.tsx
Normal file
@@ -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 (
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<div className="relative flex grow flex-col justify-end">
|
||||
<div className="flex flex-col justify-end gap-2 px-3 py-2">
|
||||
<TopicChatMessage
|
||||
message={{
|
||||
id: '__welcome_message__',
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Hey, I am your AI instructor. How can I help you today? 🤖',
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
||||
{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 <TopicChatMessage key={message.id} message={message} />;
|
||||
})}
|
||||
|
||||
{isThinking && (
|
||||
<TopicChatMessage
|
||||
message={{
|
||||
id: '__thinking_message__',
|
||||
role: 'assistant',
|
||||
parts: [{ type: 'text', text: 'Thinking...' }],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
330
src/components/ChatMessages/UserPrgressActionList.tsx
Normal file
330
src/components/ChatMessages/UserPrgressActionList.tsx
Normal file
@@ -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>.*?<\/update-progress-item>/gs;
|
||||
const progressItems = content.match(progressRegex);
|
||||
if (!progressItems) {
|
||||
return items;
|
||||
}
|
||||
|
||||
for (const progressItem of progressItems) {
|
||||
const progressItemRegex = /<topic-id>(.*?)<\/topic-id>/;
|
||||
const topicId = progressItem.match(progressItemRegex)?.[1]?.trim();
|
||||
const topicActionRegex = /<topic-action>(.*?)<\/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<BulkUpdateResourceProgressResponse>(
|
||||
`/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 (
|
||||
<div className="relative my-6 w-full first:mt-0 last:mb-0">
|
||||
<div className="relative flex flex-col gap-0.5">
|
||||
{itemsToShow.map((item) => (
|
||||
<ProgressItem
|
||||
key={item.id}
|
||||
roadmapId={roadmapId}
|
||||
topicId={item.id}
|
||||
text={item.text}
|
||||
action={item.action}
|
||||
isStreaming={isLoading}
|
||||
isBulkUpdating={isBulkUpdating}
|
||||
isBulkUpdateSuccess={isBulkUpdateSuccess}
|
||||
/>
|
||||
))}
|
||||
|
||||
{hasMoreItemsToShow && (
|
||||
<div className="relative mt-1 flex items-center justify-between gap-2">
|
||||
<button
|
||||
className="z-50 flex items-center gap-1 rounded-md bg-gray-400 px-2 py-1 text-xs font-medium text-white hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && (
|
||||
<>
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
{progressItemWithText.length} loaded ..
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<>
|
||||
{showAll
|
||||
? '- Show Less'
|
||||
: `+ Show ${progressItemWithText.length - itemCountToShow} More`}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="z-50 flex items-center gap-1 rounded-md bg-green-600 px-2 py-1 text-xs font-medium text-white hover:bg-green-700 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-70"
|
||||
disabled={isBulkUpdating || isLoading || isBulkUpdateSuccess}
|
||||
onClick={() => {
|
||||
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 && (
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
)}
|
||||
{!isBulkUpdating && <CheckIcon additionalClasses="size-3" />}
|
||||
Apply All
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex min-h-[40px] items-center justify-between gap-2 rounded-lg border border-gray-200 bg-white py-1 pr-1 pl-3">
|
||||
<span className="flex items-center gap-1 truncate text-sm text-gray-500">
|
||||
{textParts.map((part, index) => {
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
{part}
|
||||
{index !== lastIndex && (
|
||||
<span className="text-gray-500">
|
||||
<ChevronRightIcon className="size-3 shrink-0" />{' '}
|
||||
</span>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
{!isSuccess && !isBulkUpdateSuccess && (
|
||||
<>
|
||||
{!isStreaming && (
|
||||
<button
|
||||
className={cn(
|
||||
`flex shrink-0 items-center gap-1.5 rounded-md border border-gray-200 px-2 py-1 text-xs disabled:pointer-events-none disabled:opacity-40`,
|
||||
{
|
||||
'bg-green-100 hover:border-green-300 hover:bg-green-200':
|
||||
action === 'done',
|
||||
'bg-yellow-100 hover:border-yellow-300 hover:bg-yellow-200':
|
||||
action === 'learning',
|
||||
'bg-gray-800 text-white hover:border-black hover:bg-black':
|
||||
action === 'skipped',
|
||||
'bg-gray-100 hover:border-gray-300 hover:bg-gray-200':
|
||||
action === 'pending',
|
||||
},
|
||||
)}
|
||||
onClick={() => updateTopicStatus(action)}
|
||||
disabled={isStreaming || isUpdating || isBulkUpdating}
|
||||
>
|
||||
{(isUpdating || isBulkUpdating) && (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
)}
|
||||
{!isUpdating && !isBulkUpdating && (
|
||||
<>
|
||||
<CheckIcon additionalClasses="size-3" />
|
||||
Mark it as {action}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{isStreaming && (
|
||||
<span className="flex size-[30px] items-center justify-center text-gray-300">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{(isSuccess || isBulkUpdateSuccess) && (
|
||||
<span className="flex size-[30px] items-center justify-center text-green-500">
|
||||
<CheckIcon additionalClasses="size-4" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
60
src/components/ChatMessages/UserProgressList.tsx
Normal file
60
src/components/ChatMessages/UserProgressList.tsx
Normal file
@@ -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 (
|
||||
<div className="relative my-6 flex flex-col gap-3 rounded-xl border border-gray-200 bg-white p-4 first:mt-0 last:mb-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-600">Progress</span>
|
||||
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700">
|
||||
{progressPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
<span className="hidden text-sm font-medium text-gray-600 md:block">
|
||||
{totalFinished} / {totalTopicCount} topics
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative h-2 w-full overflow-hidden rounded-full bg-gray-100">
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-r from-green-500 to-green-600 transition-all duration-300"
|
||||
style={{ width: `${progressPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<span>Completed: {doneCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-full bg-gray-400" />
|
||||
<span>Skipped: {skippedCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -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<HTMLDivElement>(null);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(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) {
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'animate-fade-slide-up fixed bottom-5 left-1/2 max-h-[95vh] max-w-[968px] -translate-x-1/4 transform flex-col gap-1.5 overflow-hidden px-4 duration-300 sm:max-h-[50vh] lg:flex',
|
||||
'animate-fade-slide-up ai-chat fixed bottom-5 left-1/2 max-h-[95vh] max-w-[968px] -translate-x-1/4 transform flex-col gap-1.5 overflow-hidden px-4 duration-300 sm:max-h-[50vh] lg:flex',
|
||||
isOpen ? 'z-91 h-full w-full' : 'z-40 w-auto',
|
||||
)}
|
||||
>
|
||||
@@ -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) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-1 flex-grow flex-col overflow-y-auto px-3 py-2"
|
||||
ref={scrollareaRef}
|
||||
>
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<RoadmapAIChatCard
|
||||
role="assistant"
|
||||
jsx={
|
||||
<span className="mt-[2px]">
|
||||
Hey, I am your AI tutor. How can I help you today? 👋
|
||||
</span>
|
||||
}
|
||||
isIntro
|
||||
<div className="relative flex grow flex-col">
|
||||
<div
|
||||
className="relative grow overflow-y-auto"
|
||||
ref={scrollableContainerRef}
|
||||
>
|
||||
<RoadmapChatMessages
|
||||
messages={messages}
|
||||
status={status}
|
||||
roadmapId={roadmapId}
|
||||
defaultQuestions={defaultQuestions}
|
||||
onTopicClick={onSelectTopic}
|
||||
onDefaultQuestionClick={submitInput}
|
||||
/>
|
||||
|
||||
{/* Show default questions only when there's no chat history */}
|
||||
{aiChatHistory.length === 0 &&
|
||||
defaultQuestions.length > 0 && (
|
||||
<div className="mt-0.5 mb-1">
|
||||
<p className="mb-2 text-xs font-normal text-gray-500">
|
||||
Some questions you might have about this roadmap:
|
||||
</p>
|
||||
<div className="flex flex-col justify-end gap-1">
|
||||
{defaultQuestions.map((question, index) => (
|
||||
<button
|
||||
key={`default-question-${index}`}
|
||||
className="flex h-full self-start rounded-md bg-yellow-500/10 px-3 py-2 text-left text-sm text-black hover:bg-yellow-500/20"
|
||||
onClick={() => {
|
||||
if (!isLoggedIn()) {
|
||||
setIsOpen(false);
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLimitExceeded) {
|
||||
setShowUpgradeModal(true);
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
handleChatSubmit(
|
||||
textToJSON(question),
|
||||
isRoadmapDetailLoading,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{aiChatHistory.map((chat, index) => (
|
||||
<Fragment key={`chat-${index}`}>
|
||||
<RoadmapAIChatCard {...chat} />
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{isStreamingMessage && !streamedMessage && (
|
||||
<RoadmapAIChatCard role="assistant" html="Thinking..." />
|
||||
)}
|
||||
|
||||
{streamedMessage && (
|
||||
<RoadmapAIChatCard role="assistant" jsx={streamedMessage} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scroll to bottom button */}
|
||||
{showScrollToBottom && (
|
||||
{showScrollToBottomButton && (
|
||||
<button
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
Scroll to bottom
|
||||
@@ -534,6 +490,7 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLimitExceeded && (
|
||||
<>
|
||||
<div className="flex flex-row justify-between border-t border-gray-200 px-3 pt-2">
|
||||
@@ -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);
|
||||
}}
|
||||
>
|
||||
|
160
src/components/Global/CodeBlock.tsx
Normal file
160
src/components/Global/CodeBlock.tsx
Normal file
@@ -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<HTMLDivElement>;
|
||||
|
||||
const CodeBlockFallback = ({ children, ...props }: CodeBlockFallbackProps) => (
|
||||
<div {...props}>
|
||||
<pre className="w-full bg-white">
|
||||
<code>
|
||||
{children
|
||||
?.toString()
|
||||
.split('\n')
|
||||
.map((line, i) => (
|
||||
<span className="line" key={i}>
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CodeBlockItemProps = HTMLAttributes<HTMLDivElement> & {
|
||||
value: string;
|
||||
lineNumbers?: boolean;
|
||||
};
|
||||
|
||||
export const CodeBlockItem = ({
|
||||
children,
|
||||
lineNumbers = true,
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: CodeBlockItemProps) => {
|
||||
return (
|
||||
<div className={cn(codeBlockClassName, className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type CodeBlockContentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
language?: BundledLanguage;
|
||||
syntaxHighlighting?: boolean;
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const CodeBlockContent = ({
|
||||
children,
|
||||
language,
|
||||
syntaxHighlighting = true,
|
||||
...props
|
||||
}: CodeBlockContentProps) => {
|
||||
const [html, setHtml] = useState<string | null>(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 <CodeBlockFallback>{children}</CodeBlockFallback>;
|
||||
}
|
||||
|
||||
return <div dangerouslySetInnerHTML={{ __html: html }} {...props} />;
|
||||
};
|
||||
|
||||
type CodeBlockHeaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
language: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
export function CodeBlockHeader(props: CodeBlockHeaderProps) {
|
||||
const { language, code, className, ...rest } = props;
|
||||
|
||||
const { copyText, isCopied } = useCopyText();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-2 border-b border-gray-200 bg-gray-50 px-3 py-2',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<span className="text-sm text-gray-600">{language}</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => 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 ? (
|
||||
<CheckIcon className="size-3.5" />
|
||||
) : (
|
||||
<CopyIcon className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
99
src/components/Global/Markdown.tsx
Normal file
99
src/components/Global/Markdown.tsx
Normal file
@@ -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 <pre>{children}</pre>;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="not-prose my-6 max-w-full overflow-hidden rounded-lg border border-gray-200">
|
||||
<CodeBlockHeader language={language} code={code} />
|
||||
|
||||
<CodeBlockItem key={language} value={language} lineNumbers={false}>
|
||||
<CodeBlockContent language={language as BundledLanguage}>
|
||||
{code}
|
||||
</CodeBlockContent>
|
||||
</CodeBlockItem>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
type MarkdownProps = {
|
||||
children: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function _Markdown(props: MarkdownProps) {
|
||||
const { children, className } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown
|
||||
components={components}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Markdown = memo(_Markdown, (prevProps, nextProps) => {
|
||||
return prevProps.children === nextProps.children;
|
||||
});
|
@@ -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<RoadmapContentDocument['links']>([]);
|
||||
const [activeTab, setActiveTab] =
|
||||
useState<AllowedTopicDetailsTabs>(defaultActiveTab);
|
||||
const [aiChatHistory, setAiChatHistory] =
|
||||
useState<AIChatHistoryType[]>(defaultChatHistory);
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||
const [isCustomResource, setIsCustomResource] = useState(false);
|
||||
|
||||
@@ -156,14 +154,20 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
const [resourceType, setResourceType] = useState<ResourceType>('roadmap');
|
||||
const [paidResources, setPaidResources] = useState<PaidResourceType[]>([]);
|
||||
|
||||
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={() => {
|
||||
|
@@ -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<UIMessage>['sendMessage'];
|
||||
setMessages: UseChatHelpers<UIMessage>['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<HTMLTextAreaElement>(null);
|
||||
const scrollareaRef = useRef<HTMLDivElement>(null);
|
||||
const formRef = useRef<HTMLFormElement>(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 (
|
||||
<div className="relative mt-4 flex grow flex-col overflow-hidden rounded-lg border border-gray-200">
|
||||
<div className="ai-chat relative mt-4 flex grow flex-col overflow-hidden rounded-lg border border-gray-200">
|
||||
{isDataLoading && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center gap-2 bg-white text-black">
|
||||
<Loader2Icon className="size-8 animate-spin stroke-3 text-gray-500" />
|
||||
@@ -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}
|
||||
</a>
|
||||
@@ -349,7 +268,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
|
||||
<button
|
||||
className="rounded-md bg-white px-2 py-2 text-xs font-medium text-black hover:bg-gray-200"
|
||||
onClick={() => {
|
||||
setAiChatHistory(defaultChatHistory);
|
||||
setMessages([]);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
@@ -416,39 +335,9 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
|
||||
|
||||
<div
|
||||
className="scrollbar-thumb-gray-300 scrollbar-track-transparent scrollbar-thin relative grow overflow-y-auto"
|
||||
ref={scrollareaRef}
|
||||
ref={scrollableContainerRef}
|
||||
>
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<div className="relative flex grow flex-col justify-end">
|
||||
<div className="flex flex-col justify-end gap-2 px-3 py-2">
|
||||
{aiChatHistory.map((chat, index) => {
|
||||
let content = chat.content;
|
||||
|
||||
if (chat.role === 'user' && promptLabelMapping[chat.content]) {
|
||||
content = promptLabelMapping[chat.content];
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={`chat-${index}`}>
|
||||
<AIChatCard
|
||||
role={chat.role}
|
||||
content={content}
|
||||
html={chat.html}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{isStreamingMessage && !streamedMessage && (
|
||||
<AIChatCard role="assistant" content="Thinking..." />
|
||||
)}
|
||||
|
||||
{streamedMessage && (
|
||||
<AIChatCard role="assistant" content={streamedMessage} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TopicChatMessages messages={messages} status={status} />
|
||||
</div>
|
||||
|
||||
<form
|
||||
@@ -517,7 +406,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isStreamingMessage || isLimitExceeded}
|
||||
disabled={status !== 'ready' || isLimitExceeded}
|
||||
className="flex aspect-square size-[41px] items-center justify-center text-zinc-500 hover:text-black disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<SendIcon className="size-4 stroke-[2.5]" />
|
||||
|
85
src/hooks/use-ai-chat-scroll.tsx
Normal file
85
src/hooks/use-ai-chat-scroll.tsx
Normal file
@@ -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<HTMLDivElement>(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,
|
||||
};
|
||||
}
|
119
src/hooks/use-completion.ts
Normal file
119
src/hooks/use-completion.ts
Normal file
@@ -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<string, string>;
|
||||
body?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type CompletionStatus = 'idle' | 'loading' | 'streaming' | 'success' | 'error';
|
||||
|
||||
export type CompletionContext = {
|
||||
content: string;
|
||||
};
|
||||
|
||||
type CompletionParams<D extends Record<string, unknown>> = {
|
||||
endpoint: string;
|
||||
onStart?: (options?: CompleteOptions) => Promise<void> | void;
|
||||
onData?: (
|
||||
part: CompletionPart<D>,
|
||||
context: CompletionContext
|
||||
) => Promise<void> | void;
|
||||
onFinish?: (
|
||||
result: string,
|
||||
context: CompletionContext
|
||||
) => Promise<void> | void;
|
||||
onError?: (error: Error) => void;
|
||||
};
|
||||
|
||||
export function useCompletion<
|
||||
D extends Record<string, unknown> = Record<string, unknown>,
|
||||
>(params: CompletionParams<D>) {
|
||||
const { endpoint, onData, onFinish, onError, onStart } = params;
|
||||
|
||||
const [status, setStatus] = useState<CompletionStatus>('idle');
|
||||
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [completion, setCompletion] = useState<string>('');
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(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<D>(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 };
|
||||
}
|
23
src/hooks/use-is-thinking.ts
Normal file
23
src/hooks/use-is-thinking.ts
Normal file
@@ -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]);
|
||||
}
|
@@ -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<AbortController | null>(null);
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
133
src/lib/message-part.ts
Normal file
133
src/lib/message-part.ts
Normal file
@@ -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<string, MessagePartRenderer>,
|
||||
) {
|
||||
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<tag> -> <tag>
|
||||
// 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: </tag>\n``` -> </tag>
|
||||
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;
|
||||
}
|
100
src/lib/stream.ts
Normal file
100
src/lib/stream.ts
Normal file
@@ -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<D extends Record<string, unknown>> = {
|
||||
type: 'details';
|
||||
data: D;
|
||||
};
|
||||
|
||||
export type CompletionPart<
|
||||
D extends Record<string, unknown> = Record<string, unknown>,
|
||||
> = CompletionTextPart | CompletionDetailsPart<D>;
|
||||
|
||||
export async function readDataStream<D extends Record<string, unknown>>(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
{
|
||||
onData,
|
||||
onFinish,
|
||||
}: {
|
||||
onData?: (part: CompletionPart<D>) => Promise<void> | void;
|
||||
onFinish?: () => Promise<void> | 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<D>[] = 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();
|
||||
}
|
@@ -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<string, MessagePartRenderer>,
|
||||
) {
|
||||
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,
|
||||
});
|
||||
|
Reference in New Issue
Block a user