From d5fdc62343f78f5b40fc26445d6847ba14f816d9 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed Date: Mon, 4 Mar 2024 17:56:15 +0000 Subject: [PATCH] Adds AI roadmap generator (#5289) * feat: implement roadmap generator * feat: add roadmap stream * Update UI * fix: add fingerprint visitor id * feat: implement edit generated roadmap * feat: implement ai roadmap download * feat: add limit count * fix: add limit check * fix: download image button * feat: implement roadmap generator * feat: add roadmap stream * Update UI * fix: add fingerprint visitor id * feat: implement edit generated roadmap * feat: implement ai roadmap download * feat: add limit count * fix: add limit check * fix: download image button * UI Updates * Update UI for roadmap search * Update UI for roadmap limit * Update UI for roadmap * UI responsiveness on AI roadmap generator --------- Co-authored-by: Arik Chakma --- package.json | 4 + pnpm-lock.yaml | 90 +++-- public/images/icons8-wand.gif | Bin 0 -> 31555 bytes .../GenerateRoadmap/GenerateRoadmap.css | 58 +++ .../GenerateRoadmap/GenerateRoadmap.tsx | 361 ++++++++++++++++++ .../GenerateRoadmap/RoadmapSearch.tsx | 121 ++++++ src/helper/download-image.ts | 32 ++ src/helper/read-stream.ts | 43 +++ src/layouts/BaseLayout.astro | 2 +- src/pages/ai/index.astro | 10 + tsconfig.json | 5 +- 11 files changed, 688 insertions(+), 38 deletions(-) create mode 100644 public/images/icons8-wand.gif create mode 100644 src/components/GenerateRoadmap/GenerateRoadmap.css create mode 100644 src/components/GenerateRoadmap/GenerateRoadmap.tsx create mode 100644 src/components/GenerateRoadmap/RoadmapSearch.tsx create mode 100644 src/helper/read-stream.ts create mode 100644 src/pages/ai/index.astro diff --git a/package.json b/package.json index e2bc8eb11..d4e87dab9 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "astro": "^4.4.0", "astro-compress": "^2.2.10", "clsx": "^2.1.0", + "dom-to-image": "^2.6.0", "dracula-prism": "^2.1.16", "jose": "^5.2.2", "js-cookie": "^3.0.5", @@ -46,15 +47,18 @@ "react-dom": "^18.2.0", "reactflow": "^11.10.4", "rehype-external-links": "^3.0.0", + "remark-parse": "^11.0.0", "roadmap-renderer": "^1.0.6", "slugify": "^1.6.6", "tailwind-merge": "^2.2.1", "tailwindcss": "^3.4.1", + "unified": "^11.0.4", "zustand": "^4.5.1" }, "devDependencies": { "@playwright/test": "^1.41.2", "@tailwindcss/typography": "^0.5.10", + "@types/dom-to-image": "^2.6.7", "@types/js-cookie": "^3.0.6", "@types/prismjs": "^1.26.3", "csv-parser": "^3.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7831ce0e..40919589e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,7 +7,7 @@ settings: dependencies: '@astrojs/react': specifier: ^3.0.10 - version: 3.0.10(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)(vite@5.1.3) + version: 3.0.10(@types/react-dom@18.2.19)(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)(vite@5.1.3) '@astrojs/sitemap': specifier: ^3.0.5 version: 3.0.5 @@ -22,7 +22,7 @@ dependencies: version: 0.7.1(nanostores@0.9.5)(react@18.2.0) '@types/react': specifier: ^18.2.56 - version: 18.2.58 + version: 18.2.59 '@types/react-dom': specifier: ^18.2.19 version: 18.2.19 @@ -35,6 +35,9 @@ dependencies: clsx: specifier: ^2.1.0 version: 2.1.0 + dom-to-image: + specifier: ^2.6.0 + version: 2.6.0 dracula-prism: specifier: ^2.1.16 version: 2.1.16 @@ -73,10 +76,13 @@ dependencies: version: 18.2.0(react@18.2.0) reactflow: specifier: ^11.10.4 - version: 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) + version: 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0) rehype-external-links: specifier: ^3.0.0 version: 3.0.0 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 roadmap-renderer: specifier: ^1.0.6 version: 1.0.6 @@ -89,9 +95,12 @@ dependencies: tailwindcss: specifier: ^3.4.1 version: 3.4.1 + unified: + specifier: ^11.0.4 + version: 11.0.4 zustand: specifier: ^4.5.1 - version: 4.5.1(@types/react@18.2.58)(react@18.2.0) + version: 4.5.1(@types/react@18.2.59)(react@18.2.0) devDependencies: '@playwright/test': @@ -100,6 +109,9 @@ devDependencies: '@tailwindcss/typography': specifier: ^0.5.10 version: 0.5.10(tailwindcss@3.4.1) + '@types/dom-to-image': + specifier: ^2.6.7 + version: 2.6.7 '@types/js-cookie': specifier: ^3.0.6 version: 3.0.6 @@ -185,7 +197,7 @@ packages: prismjs: 1.29.0 dev: false - /@astrojs/react@3.0.10(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)(vite@5.1.3): + /@astrojs/react@3.0.10(@types/react-dom@18.2.19)(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)(vite@5.1.3): resolution: {integrity: sha512-uGRIwKMAn7tva2vxXMyoVIGxWFr0rjZ8ZWIlkTG/vIpnAjD2nM8Cz6B8j7yzj176jvl6gZ6xTbTVPm09aeK0Yw==} engines: {node: '>=18.14.1'} peerDependencies: @@ -194,7 +206,7 @@ packages: react: ^17.0.2 || ^18.0.0 react-dom: ^17.0.2 || ^18.0.0 dependencies: - '@types/react': 18.2.58 + '@types/react': 18.2.59 '@types/react-dom': 18.2.19 '@vitejs/plugin-react': 4.2.1(vite@5.1.3) react: 18.2.0 @@ -1102,39 +1114,39 @@ packages: config-chain: 1.1.13 dev: false - /@reactflow/background@11.3.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0): + /@reactflow/background@11.3.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-byj/G9pEC8tN0wT/ptcl/LkEP/BBfa33/SvBkqE4XwyofckqF87lKp573qGlisfnsijwAbpDlf81PuFL41So4Q==} peerDependencies: react: '>=17' react-dom: '>=17' dependencies: - '@reactflow/core': 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/core': 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0) classcat: 5.0.4 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - zustand: 4.5.1(@types/react@18.2.58)(react@18.2.0) + zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0) transitivePeerDependencies: - '@types/react' - immer dev: false - /@reactflow/controls@11.2.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0): + /@reactflow/controls@11.2.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-e8nWplbYfOn83KN1BrxTXS17+enLyFnjZPbyDgHSRLtI5ZGPKF/8iRXV+VXb2LFVzlu4Wh3la/pkxtfP/0aguA==} peerDependencies: react: '>=17' react-dom: '>=17' dependencies: - '@reactflow/core': 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/core': 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0) classcat: 5.0.4 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - zustand: 4.5.1(@types/react@18.2.58)(react@18.2.0) + zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0) transitivePeerDependencies: - '@types/react' - immer dev: false - /@reactflow/core@11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0): + /@reactflow/core@11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-j3i9b2fsTX/sBbOm+RmNzYEFWbNx4jGWGuGooh2r1jQaE2eV+TLJgiG/VNOp0q5mBl9f6g1IXs3Gm86S9JfcGw==} peerDependencies: react: '>=17' @@ -1150,19 +1162,19 @@ packages: d3-zoom: 3.0.0 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - zustand: 4.5.1(@types/react@18.2.58)(react@18.2.0) + zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0) transitivePeerDependencies: - '@types/react' - immer dev: false - /@reactflow/minimap@11.7.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0): + /@reactflow/minimap@11.7.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-le95jyTtt3TEtJ1qa7tZ5hyM4S7gaEQkW43cixcMOZLu33VAdc2aCpJg/fXcRrrf7moN2Mbl9WIMNXUKsp5ILA==} peerDependencies: react: '>=17' react-dom: '>=17' dependencies: - '@reactflow/core': 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/core': 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0) '@types/d3-selection': 3.0.10 '@types/d3-zoom': 3.0.8 classcat: 5.0.4 @@ -1170,41 +1182,41 @@ packages: d3-zoom: 3.0.0 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - zustand: 4.5.1(@types/react@18.2.58)(react@18.2.0) + zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0) transitivePeerDependencies: - '@types/react' - immer dev: false - /@reactflow/node-resizer@2.2.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0): + /@reactflow/node-resizer@2.2.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-HfickMm0hPDIHt9qH997nLdgLt0kayQyslKE0RS/GZvZ4UMQJlx/NRRyj5y47Qyg0NnC66KYOQWDM9LLzRTnUg==} peerDependencies: react: '>=17' react-dom: '>=17' dependencies: - '@reactflow/core': 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/core': 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0) classcat: 5.0.4 d3-drag: 3.0.0 d3-selection: 3.0.0 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - zustand: 4.5.1(@types/react@18.2.58)(react@18.2.0) + zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0) transitivePeerDependencies: - '@types/react' - immer dev: false - /@reactflow/node-toolbar@1.3.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0): + /@reactflow/node-toolbar@1.3.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-VmgxKmToax4sX1biZ9LXA7cj/TBJ+E5cklLGwquCCVVxh+lxpZGTBF3a5FJGVHiUNBBtFsC8ldcSZIK4cAlQww==} peerDependencies: react: '>=17' react-dom: '>=17' dependencies: - '@reactflow/core': 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/core': 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0) classcat: 5.0.4 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - zustand: 4.5.1(@types/react@18.2.58)(react@18.2.0) + zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0) transitivePeerDependencies: - '@types/react' - immer @@ -1618,6 +1630,10 @@ packages: '@types/ms': 0.7.34 dev: false + /@types/dom-to-image@2.6.7: + resolution: {integrity: sha512-me5VbCv+fcXozblWwG13krNBvuEOm6kA5xoa4RrjDJCNFOZSWR3/QLtOXimBHk1Fisq69Gx3JtOoXtg1N1tijg==} + dev: true + /@types/estree@1.0.5: resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} dev: false @@ -1700,11 +1716,11 @@ packages: /@types/react-dom@18.2.19: resolution: {integrity: sha512-aZvQL6uUbIJpjZk4U8JZGbau9KDeAwMfmhyWorxgBkqDIEf6ROjRozcmPIicqsUwPUjbkDfHKgGee1Lq65APcA==} dependencies: - '@types/react': 18.2.58 + '@types/react': 18.2.59 dev: false - /@types/react@18.2.58: - resolution: {integrity: sha512-TaGvMNhxvG2Q0K0aYxiKfNDS5m5ZsoIBBbtfUorxdH4NGSXIlYvZxLJI+9Dd3KjeB3780bciLyAb7ylO8pLhPw==} + /@types/react@18.2.59: + resolution: {integrity: sha512-DE+F6BYEC8VtajY85Qr7mmhTd/79rJKIHCg99MU9SWPB4xvLb6D1za2vYflgZfmPqQVEr6UqJTnLXEwzpVPuOg==} dependencies: '@types/prop-types': 15.7.11 '@types/scheduler': 0.16.8 @@ -2697,6 +2713,10 @@ packages: entities: 4.5.0 dev: false + /dom-to-image@2.6.0: + resolution: {integrity: sha512-Dt0QdaHmLpjURjU7Tnu3AgYSF2LuOmksSGsUcE6ItvJoCWTBEmiMXcqBdNSAm9+QbbwD7JMoVsuuKX6ZVQv1qA==} + dev: false + /domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} dev: false @@ -5543,18 +5563,18 @@ packages: loose-envify: 1.4.0 dev: false - /reactflow@11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0): + /reactflow@11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-0CApYhtYicXEDg/x2kvUHiUk26Qur8lAtTtiSlptNKuyEuGti6P1y5cS32YGaUoDMoCqkm/m+jcKkfMOvSCVRA==} peerDependencies: react: '>=17' react-dom: '>=17' dependencies: - '@reactflow/background': 11.3.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) - '@reactflow/controls': 11.2.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) - '@reactflow/core': 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) - '@reactflow/minimap': 11.7.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) - '@reactflow/node-resizer': 2.2.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) - '@reactflow/node-toolbar': 1.3.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/background': 11.3.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/controls': 11.2.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/core': 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/minimap': 11.7.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/node-resizer': 2.2.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/node-toolbar': 1.3.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) transitivePeerDependencies: @@ -6929,7 +6949,7 @@ packages: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} dev: false - /zustand@4.5.1(@types/react@18.2.58)(react@18.2.0): + /zustand@4.5.1(@types/react@18.2.59)(react@18.2.0): resolution: {integrity: sha512-XlauQmH64xXSC1qGYNv00ODaQ3B+tNPoy22jv2diYiP4eoDKr9LA+Bh5Bc3gplTrFdb6JVI+N4kc1DZ/tbtfPg==} engines: {node: '>=12.7.0'} peerDependencies: @@ -6944,7 +6964,7 @@ packages: react: optional: true dependencies: - '@types/react': 18.2.58 + '@types/react': 18.2.59 react: 18.2.0 use-sync-external-store: 1.2.0(react@18.2.0) dev: false diff --git a/public/images/icons8-wand.gif b/public/images/icons8-wand.gif new file mode 100644 index 0000000000000000000000000000000000000000..621b405e3de6677c099a960d744884bb8625ad71 GIT binary patch literal 31555 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 g[data-type='topic'], +svg > g[data-type='subtopic'], +svg > g > g[data-type='link-item'], +svg > g[data-type='button'] { + cursor: pointer; +} + +svg > g[data-type='topic']:hover > rect { + fill: #d6d700; +} + +svg > g[data-type='subtopic']:hover > rect { + fill: #f3c950; +} +svg > g[data-type='button']:hover { + opacity: 0.8; +} + +svg .done rect { + fill: #cbcbcb !important; +} + +svg .done text, +svg .skipped text { + text-decoration: line-through; +} + +svg > g[data-type='topic'].learning > rect + text, +svg > g[data-type='topic'].done > rect + text { + fill: black; +} + +svg > g[data-type='subtipic'].done > rect + text, +svg > g[data-type='subtipic'].learning > rect + text { + fill: #cbcbcb; +} + +svg .learning rect { + fill: #dad1fd !important; +} +svg .learning text { + text-decoration: underline; +} + +svg .skipped rect { + fill: #496b69 !important; +} diff --git a/src/components/GenerateRoadmap/GenerateRoadmap.tsx b/src/components/GenerateRoadmap/GenerateRoadmap.tsx new file mode 100644 index 000000000..7b1269fc9 --- /dev/null +++ b/src/components/GenerateRoadmap/GenerateRoadmap.tsx @@ -0,0 +1,361 @@ +import { type FormEvent, useEffect, useRef, useState } from 'react'; +import './GenerateRoadmap.css'; +import { useToast } from '../../hooks/use-toast'; +import { generateAIRoadmapFromText } from '../../../editor/utils/roadmap-generator'; +import { renderFlowJSON } from '../../../editor/renderer/renderer'; +import { replaceChildren } from '../../lib/dom'; +import { readAIRoadmapStream } from '../../helper/read-stream'; +import { isLoggedIn, removeAuthToken } from '../../lib/jwt'; +import { RoadmapSearch } from './RoadmapSearch.tsx'; +import { Spinner } from '../ReactIcons/Spinner.tsx'; +import { Ban, Download, PenSquare, Wand } from 'lucide-react'; +import { ShareRoadmapButton } from '../ShareRoadmapButton.tsx'; +import { httpGet, httpPost } from '../../lib/http.ts'; +import { pageProgressMessage } from '../../stores/page.ts'; +import { + deleteUrlParam, + getUrlParams, + setUrlParams, +} from '../../lib/browser.ts'; +import { downloadGeneratedRoadmapImage } from '../../helper/download-image.ts'; +import { showLoginPopup } from '../../lib/popup.ts'; +import { cn } from '../../lib/classname.ts'; + +const ROADMAP_ID_REGEX = new RegExp('@ROADMAPID:(\\w+)@'); + +export function GenerateRoadmap() { + const roadmapContainerRef = useRef(null); + + const { id: roadmapId } = getUrlParams() as { id: string }; + const toast = useToast(); + + const [hasSubmitted, setHasSubmitted] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [roadmapTopic, setRoadmapTopic] = useState(''); + const [generatedRoadmap, setGeneratedRoadmap] = useState(''); + + const [roadmapLimit, setRoadmapLimit] = useState(0); + const [roadmapLimitUsed, setRoadmapLimitUsed] = useState(0); + + const renderRoadmap = async (roadmap: string) => { + const { nodes, edges } = generateAIRoadmapFromText(roadmap); + const svg = await renderFlowJSON({ nodes, edges }); + if (roadmapContainerRef?.current) { + replaceChildren(roadmapContainerRef?.current, svg); + } + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (!roadmapTopic) { + return; + } + + setIsLoading(true); + setHasSubmitted(true); + + if (roadmapLimitUsed >= roadmapLimit) { + toast.error('You have reached your limit of generating roadmaps'); + setIsLoading(false); + return; + } + + deleteUrlParam('id'); + + const response = await fetch( + `${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-roadmap`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ topic: roadmapTopic }), + }, + ); + + if (!response.ok) { + const data = await response.json(); + + toast.error(data?.message || 'Something went wrong'); + setIsLoading(false); + + // Logout user if token is invalid + if (data.status === 401) { + removeAuthToken(); + window.location.reload(); + } + } + + const reader = response.body?.getReader(); + + if (!reader) { + setIsLoading(false); + toast.error('Something went wrong'); + return; + } + + await readAIRoadmapStream(reader, { + onStream: async (result) => { + if (result.includes('@ROADMAPID')) { + // @ROADMAPID: is a special token that we use to identify the roadmap + // @ROADMAPID:1234@ is the format, we will remove the token and the id + // and replace it with a empty string + const roadmapId = result.match(ROADMAP_ID_REGEX)?.[1] || ''; + setUrlParams({ id: roadmapId }); + result = result.replace(ROADMAP_ID_REGEX, ''); + } + + await renderRoadmap(result); + }, + onStreamEnd: async (result) => { + result = result.replace(ROADMAP_ID_REGEX, ''); + setGeneratedRoadmap(result); + loadAIRoadmapLimit().finally(() => {}); + }, + }); + + setIsLoading(false); + }; + + const editGeneratedRoadmap = async () => { + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + + pageProgressMessage.set('Redirecting to Editor'); + + const { nodes, edges } = generateAIRoadmapFromText(generatedRoadmap); + + const { response, error } = await httpPost<{ + roadmapId: string; + }>(`${import.meta.env.PUBLIC_API_URL}/v1-edit-ai-generated-roadmap`, { + title: roadmapTopic, + nodes: nodes.map((node) => ({ + ...node, + + // To reset the width and height of the node + // so that it can be calculated based on the content in the editor + width: undefined, + height: undefined, + style: { + ...node.style, + width: undefined, + height: undefined, + }, + })), + edges, + }); + + if (error || !response) { + toast.error(error?.message || 'Something went wrong'); + setIsLoading(false); + return; + } + + window.location.href = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${response.roadmapId}`; + }; + + const downloadGeneratedRoadmap = async () => { + pageProgressMessage.set('Downloading Roadmap'); + + const node = document.getElementById('roadmap-container'); + if (!node) { + toast.error('Something went wrong'); + return; + } + + try { + await downloadGeneratedRoadmapImage(roadmapTopic, node); + pageProgressMessage.set(''); + } catch (error) { + console.error(error); + toast.error('Something went wrong'); + } + }; + + const loadAIRoadmapLimit = async () => { + const { response, error } = await httpGet<{ + limit: number; + used: number; + }>(`${import.meta.env.PUBLIC_API_URL}/v1-get-ai-roadmap-limit`); + + if (error || !response) { + toast.error(error?.message || 'Something went wrong'); + return; + } + + const { limit, used } = response; + setRoadmapLimit(limit); + setRoadmapLimitUsed(used); + }; + + const loadAIRoadmap = async (roadmapId: string) => { + pageProgressMessage.set('Loading Roadmap'); + + const { response, error } = await httpGet<{ + topic: string; + data: string; + }>(`${import.meta.env.PUBLIC_API_URL}/v1-get-ai-roadmap/${roadmapId}`); + + if (error || !response) { + toast.error(error?.message || 'Something went wrong'); + setIsLoading(false); + return; + } + + const { topic, data } = response; + await renderRoadmap(data); + + setRoadmapTopic(topic); + setGeneratedRoadmap(data); + }; + + useEffect(() => { + loadAIRoadmapLimit().finally(() => {}); + }, []); + + useEffect(() => { + if (!roadmapId) { + return; + } + + setHasSubmitted(true); + loadAIRoadmap(roadmapId).finally(() => { + pageProgressMessage.set(''); + }); + }, [roadmapId]); + + if (!hasSubmitted) { + return ( + + ); + } + + const pageUrl = `https://roadmap.sh/ai?id=${roadmapId}`; + const canGenerateMore = roadmapLimitUsed < roadmapLimit; + + return ( +
+
+ {isLoading && ( + + + Generating roadmap .. + + )} + {!isLoading && ( +
+
+ + + {roadmapLimitUsed} of {roadmapLimit} + {' '} + roadmaps generated + {!isLoggedIn() && ( + <> + {' '} + + + )} + +
+
+ + setRoadmapTopic((e.target as HTMLInputElement).value) + } + /> + +
+
+
+ + {roadmapId && ( + + )} +
+ +
+
+ )} +
+
+
+ ); +} diff --git a/src/components/GenerateRoadmap/RoadmapSearch.tsx b/src/components/GenerateRoadmap/RoadmapSearch.tsx new file mode 100644 index 000000000..0b2ec2bac --- /dev/null +++ b/src/components/GenerateRoadmap/RoadmapSearch.tsx @@ -0,0 +1,121 @@ +import { Ban, Wand } from 'lucide-react'; +import type { FormEvent } from 'react'; +import { isLoggedIn } from '../../lib/jwt'; +import { showLoginPopup } from '../../lib/popup'; +import { cn } from '../../lib/classname.ts'; + +type RoadmapSearchProps = { + roadmapTopic: string; + setRoadmapTopic: (topic: string) => void; + handleSubmit: (e: FormEvent) => void; + limit: number; + limitUsed: number; +}; + +export function RoadmapSearch(props: RoadmapSearchProps) { + const { + roadmapTopic, + setRoadmapTopic, + handleSubmit, + limit = 0, + limitUsed = 0, + } = props; + + const canGenerateMore = limitUsed < limit; + + return ( +
+
+

+ Generate roadmaps with AI + AI Roadmap Generator +

+

+ + Enter a topic and let the AI generate a roadmap for you + + + Enter a topic to generate a roadmap + +

+
+
{ + if (limit > 0 && canGenerateMore) { + handleSubmit(e); + } else { + e.preventDefault(); + } + }} + className="my-3 flex w-full max-w-[600px] flex-col gap-2 sm:my-5 sm:flex-row" + > + setRoadmapTopic((e.target as HTMLInputElement).value)} + /> + +
+
+

+ Generated + You have generated + + {limitUsed} of {limit} + {' '} + roadmaps. + {!isLoggedIn && ( + <> + {' '} + + + )} +

+
+
+ ); +} diff --git a/src/helper/download-image.ts b/src/helper/download-image.ts index 193128cea..db598a49d 100644 --- a/src/helper/download-image.ts +++ b/src/helper/download-image.ts @@ -34,3 +34,35 @@ export async function downloadImage({ alert('Error downloading image'); } } + +export async function downloadGeneratedRoadmapImage( + name: string, + node: HTMLElement, +) { + // Append a watermark to the bottom right of the image + const watermark = document.createElement('div'); + watermark.className = 'flex justify-end absolute top-4 right-4 gap-2'; + watermark.innerHTML = ` + + roadmap.sh + + `; + node.insertAdjacentElement('afterbegin', watermark); + + const domtoimage = (await import('dom-to-image')).default; + if (!domtoimage) { + throw new Error('Unable to download image'); + } + + const dataUrl = await domtoimage.toJpeg(node, { + bgcolor: 'white', + quality: 1, + }); + node?.removeChild(watermark); + const link = document.createElement('a'); + link.download = `${name}-roadmap.jpg`; + link.href = dataUrl; + link.click(); +} diff --git a/src/helper/read-stream.ts b/src/helper/read-stream.ts new file mode 100644 index 000000000..422c89c85 --- /dev/null +++ b/src/helper/read-stream.ts @@ -0,0 +1,43 @@ +const NEW_LINE = '\n'.charCodeAt(0); + +export async function readAIRoadmapStream( + reader: ReadableStreamDefaultReader, + { + onStream, + onStreamEnd, + }: { + onStream?: (roadmap: string) => void; + onStreamEnd?: (roadmap: string) => void; + }, +) { + const decoder = new TextDecoder('utf-8'); + let result = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + + // We will call the renderRoadmap callback whenever we encounter + // a new line with the result until the new line + // otherwise, we will keep appending the result to the previous result + if (value) { + let start = 0; + for (let i = 0; i < value.length; i++) { + if (value[i] === NEW_LINE) { + result += decoder.decode(value.slice(start, i + 1)); + onStream?.(result); + start = i + 1; + } + } + if (start < value.length) { + result += decoder.decode(value.slice(start)); + } + } + } + + onStream?.(result); + onStreamEnd?.(result); + reader.releaseLock(); +} diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 5baa80f82..ae3adfc96 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -149,7 +149,7 @@ const gaPageIdentifier = Astro.url.pathname ) } - + diff --git a/src/pages/ai/index.astro b/src/pages/ai/index.astro new file mode 100644 index 000000000..149e2064a --- /dev/null +++ b/src/pages/ai/index.astro @@ -0,0 +1,10 @@ +--- +import LoginPopup from '../../components/AuthenticationFlow/LoginPopup.astro'; +import { GenerateRoadmap } from '../../components/GenerateRoadmap/GenerateRoadmap'; +import AccountLayout from '../../layouts/AccountLayout.astro'; +--- + + + + + diff --git a/tsconfig.json b/tsconfig.json index c164c57da..0fb7885fa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,5 +4,6 @@ "moduleResolution": "node", "jsx": "react-jsx", "jsxImportSource": "react" - } -} \ No newline at end of file + }, + "exclude": ["node_modules", "dist"] +}