diff --git a/.vscode/settings.json b/.vscode/settings.json index d3b317b51..6827afd40 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,13 @@ "prettier.documentSelectors": ["**/*.astro"], "[astro]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - } + }, + "tailwindCSS.experimental.classRegex": [ + ["\\b\\w+[cC]lassName\\s*=\\s*[\"']([^\"']*)[\"']"], + ["\\b\\w+[cC]lassName\\s*=\\s*`([^`]*)`"], + ["[\\w]+[cC]lassName[\"']?\\s*:\\s*[\"']([^\"']*)[\"']"], + ["[\\w]+[cC]lassName[\"']?\\s*:\\s*`([^`]*)`"], + ["cva\\(((?:[^()]|\\([^()]*\\))*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], + ["cx\\(((?:[^()]|\\([^()]*\\))*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] + ] } diff --git a/package.json b/package.json index 23b0a7828..bd177097e 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@nanostores/react": "^1.0.0", "@napi-rs/image": "^1.9.2", "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-popover": "^1.1.14", "@resvg/resvg-js": "^2.6.2", "@roadmapsh/editor": "workspace:*", "@tailwindcss/vite": "^4.1.7", @@ -119,6 +120,7 @@ "prettier": "^3.5.3", "prettier-plugin-astro": "^0.14.1", "prettier-plugin-tailwindcss": "^0.6.11", + "tailwind-scrollbar": "^4.0.2", "tsx": "^4.19.4" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cb7b0aa6..b80184638 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@radix-ui/react-dropdown-menu': specifier: ^2.1.15 version: 2.1.15(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-popover': + specifier: ^1.1.14 + version: 1.1.14(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@resvg/resvg-js': specifier: ^2.6.2 version: 2.6.2 @@ -267,6 +270,9 @@ importers: prettier-plugin-tailwindcss: specifier: ^0.6.11 version: 0.6.11(prettier-plugin-astro@0.14.1)(prettier@3.5.3) + tailwind-scrollbar: + specifier: ^4.0.2 + version: 4.0.2(react@19.1.0)(tailwindcss@4.1.7) tsx: specifier: ^4.19.4 version: 4.19.4 @@ -1151,6 +1157,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popover@1.1.14': + resolution: {integrity: sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popper@1.2.7': resolution: {integrity: sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==} peerDependencies: @@ -3496,6 +3515,11 @@ packages: engines: {node: '>=14'} hasBin: true + prism-react-renderer@2.4.1: + resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==} + peerDependencies: + react: '>=16.0.0' + prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} @@ -3919,6 +3943,12 @@ packages: tailwind-merge@3.3.0: resolution: {integrity: sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==} + tailwind-scrollbar@4.0.2: + resolution: {integrity: sha512-wAQiIxAPqk0MNTPptVe/xoyWi27y+NRGnTwvn4PQnbvB9kp8QUBiGl/wsfoVBHnQxTmhXJSNt9NHTmcz9EivFA==} + engines: {node: '>=12.13.0'} + peerDependencies: + tailwindcss: 4.x + tailwindcss@4.1.5: resolution: {integrity: sha512-nYtSPfWGDiWgCkwQG/m+aX83XCwf62sBgg3bIlNiiOcggnS1x3uVRDAuyelBFL+vJdOPPCGElxv9DjHJjRHiVA==} @@ -5164,6 +5194,29 @@ snapshots: '@types/react': 19.1.4 '@types/react-dom': 19.1.5(@types/react@19.1.4) + '@radix-ui/react-popover@1.1.14(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.4)(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.4)(react@19.1.0) + '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.4)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.0) + aria-hidden: 1.2.6 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-remove-scroll: 2.7.1(@types/react@19.1.4)(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + '@radix-ui/react-popper@1.2.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@floating-ui/react-dom': 2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -7548,6 +7601,12 @@ snapshots: prettier@3.5.3: {} + prism-react-renderer@2.4.1(react@19.1.0): + dependencies: + '@types/prismjs': 1.26.5 + clsx: 2.1.1 + react: 19.1.0 + prismjs@1.30.0: {} prompts@2.4.2: @@ -8144,6 +8203,13 @@ snapshots: tailwind-merge@3.3.0: {} + tailwind-scrollbar@4.0.2(react@19.1.0)(tailwindcss@4.1.7): + dependencies: + prism-react-renderer: 2.4.1(react@19.1.0) + tailwindcss: 4.1.7 + transitivePeerDependencies: + - react + tailwindcss@4.1.5: {} tailwindcss@4.1.7: {} diff --git a/src/components/AIChat/AIChat.tsx b/src/components/AIChat/AIChat.tsx index 56d39965f..783398e31 100644 --- a/src/components/AIChat/AIChat.tsx +++ b/src/components/AIChat/AIChat.tsx @@ -7,14 +7,7 @@ import { SendIcon, TrashIcon, } from 'lucide-react'; -import { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; import AutogrowTextarea from 'react-textarea-autosize'; import { QuickHelpPrompts } from './QuickHelpPrompts'; @@ -25,7 +18,6 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { queryClient } from '../../stores/query-client'; import { billingDetailsOptions } from '../../queries/billing'; import { useToast } from '../../hooks/use-toast'; -import { readStream } from '../../lib/ai'; import { markdownToHtml } from '../../lib/markdown'; import { ChatHistory } from './ChatHistory'; import { PersonalizedResponseForm } from './PersonalizedResponseForm'; @@ -38,31 +30,47 @@ import { type MessagePartRenderer, } from '../../lib/render-chat-message'; import { RoadmapRecommendations } from '../RoadmapAIChat/RoadmapRecommendations'; -import type { RoadmapAIChatHistoryType } from '../RoadmapAIChat/RoadmapAIChat'; import { AIChatCourse } from './AIChatCouse'; -import { getTailwindScreenDimension } from '../../lib/is-mobile'; -import type { TailwindScreenDimensions } from '../../lib/is-mobile'; import { showLoginPopup } from '../../lib/popup'; -import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; +import { readChatStream } from '../../lib/chat'; +import { chatHistoryOptions } from '../../queries/chat-history'; +import { cn } from '../../lib/classname'; +import type { RoadmapAIChatHistoryType } from '../../hooks/use-roadmap-ai-chat'; + +export const aiChatRenderer: Record = { + 'roadmap-recommendations': (options) => { + return ; + }, + 'generate-course': (options) => { + return ; + }, +}; + +type AIChatProps = { + messages?: RoadmapAIChatHistoryType[]; + chatHistoryId?: string; + setChatHistoryId?: (chatHistoryId: string) => void; + onUpgrade?: () => void; +}; + +export function AIChat(props: AIChatProps) { + const { + messages: defaultMessages, + chatHistoryId: defaultChatHistoryId, + setChatHistoryId: setDefaultChatHistoryId, + onUpgrade, + } = props; -export function AIChat() { const toast = useToast(); - const [deviceType, setDeviceType] = useState(); - - useLayoutEffect(() => { - setDeviceType(getTailwindScreenDimension()); - }, []); - const [message, setMessage] = useState(''); const [isStreamingMessage, setIsStreamingMessage] = useState(false); const [streamedMessage, setStreamedMessage] = useState(null); const [aiChatHistory, setAiChatHistory] = useState< RoadmapAIChatHistoryType[] - >([]); + >(defaultMessages ?? []); - const [showUpgradeModal, setShowUpgradeModal] = useState(false); const [isPersonalizedResponseFormOpen, setIsPersonalizedResponseFormOpen] = useState(false); const [isUploadResumeModalOpen, setIsUploadResumeModalOpen] = useState(false); @@ -89,6 +97,34 @@ export function AIChat() { userResumeOptions(), queryClient, ); + const { mutate: deleteChatMessage, isPending: isDeletingChatMessage } = + useMutation( + { + mutationFn: (messages: RoadmapAIChatHistoryType[]) => { + if (!defaultChatHistoryId) { + return Promise.resolve({ + status: 200, + message: 'Chat history not found', + }); + } + + return httpPost(`/v1-delete-chat-message/${defaultChatHistoryId}`, { + messages, + }); + }, + onSuccess: () => { + textareaMessageRef.current?.focus(); + + queryClient.invalidateQueries( + chatHistoryOptions(defaultChatHistoryId), + ); + }, + onError: (error) => { + toast.error(error?.message || 'Failed to delete message'); + }, + }, + queryClient, + ); const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0); const isPaidUser = userBillingDetails?.status === 'active'; @@ -101,7 +137,7 @@ export function AIChat() { if (isLimitExceeded) { if (!isPaidUser) { - setShowUpgradeModal(true); + onUpgrade?.(); } toast.error('Limit reached for today. Please wait until tomorrow.'); @@ -136,29 +172,39 @@ export function AIChat() { completeAIChat(newMessages); }; - const scrollToBottom = useCallback(() => { + const canScrollToBottom = useCallback(() => { const scrollableContainer = scrollableContainerRef?.current; if (!scrollableContainer) { - return; + return false; } - scrollableContainer.scrollTo({ - top: scrollableContainer.scrollHeight, - behavior: 'smooth', - }); - }, [scrollableContainerRef]); + const paddingBottom = parseInt( + getComputedStyle(scrollableContainer).paddingBottom, + ); - const renderer: Record = useMemo(() => { - return { - 'roadmap-recommendations': (options) => { - return ; - }, - 'generate-course': (options) => { - return ; - }, - }; + const distanceFromBottom = + scrollableContainer.scrollHeight - + (scrollableContainer.scrollTop + scrollableContainer.clientHeight) - + paddingBottom; + + return distanceFromBottom > -(paddingBottom - 80); }, []); + 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], + ); + const completeAIChat = async ( messages: RoadmapAIChatHistoryType[], force: boolean = false, @@ -172,6 +218,7 @@ export function AIChat() { }, credentials: 'include', body: JSON.stringify({ + chatHistoryId: defaultChatHistoryId, messages: messages.slice(-10), force, }), @@ -190,28 +237,26 @@ export function AIChat() { } } - const reader = response.body?.getReader(); - - if (!reader) { + const stream = response.body; + if (!stream) { setIsStreamingMessage(false); toast.error('Something went wrong'); return; } - await readStream(reader, { - onStream: async (content) => { - const jsx = await renderMessage(content, renderer, { + await readChatStream(stream, { + onMessage: async (content) => { + const jsx = await renderMessage(content, aiChatRenderer, { isLoading: true, }); flushSync(() => { setStreamedMessage(jsx); }); - - scrollToBottom(); + setShowScrollToBottomButton(canScrollToBottom()); }, - onStreamEnd: async (content) => { - const jsx = await renderMessage(content, renderer, { + onMessageEnd: async (content) => { + const jsx = await renderMessage(content, aiChatRenderer, { isLoading: false, }); @@ -231,7 +276,20 @@ export function AIChat() { }); queryClient.invalidateQueries(getAiCourseLimitOptions()); - scrollToBottom(); + queryClient.invalidateQueries({ + predicate: (query) => { + return query.queryKey[0] === 'list-chat-history'; + }, + }); + }, + onDetails: (details) => { + const detailsJson = JSON.parse(details); + const chatHistoryId = detailsJson?.chatHistoryId; + if (!chatHistoryId) { + return; + } + + setDefaultChatHistoryId?.(chatHistoryId); }, }); @@ -272,17 +330,7 @@ export function AIChat() { } timeoutId = setTimeout(() => { - const paddingBottom = parseInt( - getComputedStyle(scrollableContainer).paddingBottom, - ); - - const distanceFromBottom = - scrollableContainer.scrollHeight - - // scroll from the top + the container height - (scrollableContainer.scrollTop + scrollableContainer.clientHeight) - - paddingBottom; - - setShowScrollToBottomButton(distanceFromBottom > -(paddingBottom - 80)); + setShowScrollToBottomButton(canScrollToBottom()); }, 100); }; @@ -303,7 +351,7 @@ export function AIChat() { (index: number) => { if (isLimitExceeded) { if (!isPaidUser) { - setShowUpgradeModal(true); + onUpgrade?.(); } toast.error('Limit reached for today. Please wait until tomorrow.'); @@ -325,6 +373,7 @@ export function AIChat() { (index: number) => { const filteredChatHistory = aiChatHistory.filter((_, i) => i !== index); setAiChatHistory(filteredChatHistory); + deleteChatMessage(filteredChatHistory); }, [aiChatHistory], ); @@ -337,29 +386,40 @@ export function AIChat() { isUserPersonaLoading || isUserResumeLoading; + useEffect(() => { + scrollToBottom('instant'); + }, []); + + const shouldShowUpgradeBanner = !isPaidUser && aiChatHistory.length > 0; + return ( -
-
- {shouldShowQuickHelpPrompts && ( - { - textareaMessageRef.current?.focus(); - setMessage(question); - }} - /> - )} - {!shouldShowQuickHelpPrompts && ( - +
+
+
+ {shouldShowQuickHelpPrompts && ( + { + textareaMessageRef.current?.focus(); + setMessage(question); + }} + /> + )} + {!shouldShowQuickHelpPrompts && ( + + )} +
{isPersonalizedResponseFormOpen && ( @@ -378,12 +438,8 @@ export function AIChat() { /> )} - {showUpgradeModal && ( - setShowUpgradeModal(false)} /> - )} -
@@ -392,6 +448,11 @@ export function AIChat() { icon={PersonStandingIcon} label="Personalize" onClick={() => { + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + setIsPersonalizedResponseFormOpen(true); }} /> @@ -399,6 +460,11 @@ export function AIChat() { icon={FileUpIcon} label={isUploading ? 'Processing...' : 'Upload Resume'} onClick={() => { + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + setIsUploadResumeModalOpen(true); }} isLoading={isUploading} @@ -413,12 +479,13 @@ export function AIChat() { onClick={scrollToBottom} /> )} - {aiChatHistory.length > 0 && ( + {aiChatHistory.length > 0 && !isPaidUser && ( { setAiChatHistory([]); + deleteChatMessage([]); }} /> )} @@ -470,7 +537,7 @@ export function AIChat() { + +
+
+ + )} + + + ); +} diff --git a/src/components/AIChatHistory/ChatHistoryError.tsx b/src/components/AIChatHistory/ChatHistoryError.tsx new file mode 100644 index 000000000..c2ea104f5 --- /dev/null +++ b/src/components/AIChatHistory/ChatHistoryError.tsx @@ -0,0 +1,28 @@ +import { AlertCircleIcon } from 'lucide-react'; +import { cn } from '../../lib/classname'; + +type ChatHistoryErrorProps = { + error: Error | null; + className?: string; +}; + +export function ChatHistoryError(props: ChatHistoryErrorProps) { + const { error, className } = props; + + return ( +
+ +

+ Something went wrong +

+

+ {error?.message} +

+
+ ); +} diff --git a/src/components/AIChatHistory/ChatHistoryGroup.tsx b/src/components/AIChatHistory/ChatHistoryGroup.tsx new file mode 100644 index 000000000..e7faeab00 --- /dev/null +++ b/src/components/AIChatHistory/ChatHistoryGroup.tsx @@ -0,0 +1,42 @@ +import type { ChatHistoryWithoutMessages } from '../../queries/chat-history'; +import { ChatHistoryItem } from './ChatHistoryItem'; + +type ChatHistoryGroupProps = { + title: string; + histories: ChatHistoryWithoutMessages[]; + activeChatHistoryId?: string; + onChatHistoryClick: (id: string) => void; + onDelete: (id: string) => void; +}; + +export function ChatHistoryGroup(props: ChatHistoryGroupProps) { + const { + title, + histories, + activeChatHistoryId, + onChatHistoryClick, + onDelete, + } = props; + + return ( +
+

{title}

+ +
    + {histories.map((chatHistory) => { + return ( + { + onDelete?.(chatHistory._id); + }} + /> + ); + })} +
+
+ ); +} diff --git a/src/components/AIChatHistory/ChatHistoryItem.tsx b/src/components/AIChatHistory/ChatHistoryItem.tsx new file mode 100644 index 000000000..42be7400e --- /dev/null +++ b/src/components/AIChatHistory/ChatHistoryItem.tsx @@ -0,0 +1,33 @@ +import { cn } from '../../lib/classname'; +import type { ChatHistoryDocument } from '../../queries/chat-history'; +import { ChatHistoryAction } from './ChatHistoryAction'; + +type ChatHistoryItemProps = { + chatHistory: Omit; + isActive: boolean; + onChatHistoryClick: (chatHistoryId: string) => void; + onDelete?: () => void; +}; + +export function ChatHistoryItem(props: ChatHistoryItemProps) { + const { chatHistory, isActive, onChatHistoryClick, onDelete } = props; + + return ( +
  • + + +
    + +
    +
  • + ); +} diff --git a/src/components/AIChatHistory/ListChatHistory.tsx b/src/components/AIChatHistory/ListChatHistory.tsx new file mode 100644 index 000000000..e036b2897 --- /dev/null +++ b/src/components/AIChatHistory/ListChatHistory.tsx @@ -0,0 +1,292 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { listChatHistoryOptions } from '../../queries/chat-history'; +import { queryClient } from '../../stores/query-client'; +import { + Loader2Icon, + LockIcon, + PanelLeftCloseIcon, + PanelLeftIcon, + PlusIcon, +} from 'lucide-react'; +import { useEffect, useLayoutEffect, useMemo, useState } from 'react'; +import { ListChatHistorySkeleton } from './ListChatHistorySkeleton'; +import { ChatHistoryError } from './ChatHistoryError'; +import { cn } from '../../lib/classname'; +import { getTailwindScreenDimension } from '../../lib/is-mobile'; +import { groupChatHistory } from '../../helper/grouping'; +import { SearchAIChatHistory } from './SearchAIChatHistory'; +import { ChatHistoryGroup } from './ChatHistoryGroup'; +import { isLoggedIn } from '../../lib/jwt'; +import { CheckIcon } from '../ReactIcons/CheckIcon'; + +type ListChatHistoryProps = { + activeChatHistoryId?: string; + onChatHistoryClick: (chatHistoryId: string | null) => void; + onDelete?: (chatHistoryId: string) => void; + isPaidUser?: boolean; + onUpgrade?: () => void; +}; + +export function ListChatHistory(props: ListChatHistoryProps) { + const { + activeChatHistoryId, + onChatHistoryClick, + onDelete, + isPaidUser, + onUpgrade, + } = props; + + const [isOpen, setIsOpen] = useState(true); + const [isLoading, setIsLoading] = useState(true); + const [isMobile, setIsMobile] = useState(false); + + useLayoutEffect(() => { + const deviceType = getTailwindScreenDimension(); + const isMediumSize = ['sm', 'md'].includes(deviceType); + + // Only set initial state from localStorage if not on mobile + if (!isMediumSize) { + const storedState = localStorage.getItem('chat-history-sidebar-open'); + setIsOpen(storedState === null ? true : storedState === 'true'); + } else { + setIsOpen(!isMediumSize); + } + + setIsMobile(isMediumSize); + }, []); + + // Save state to localStorage when it changes, but only if not on mobile + useEffect(() => { + if (!isMobile) { + localStorage.setItem('chat-history-sidebar-open', isOpen.toString()); + } + }, [isOpen, isMobile]); + + const [query, setQuery] = useState(''); + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isError, + error, + isLoading: isLoadingInfiniteQuery, + } = useInfiniteQuery(listChatHistoryOptions({ query }), queryClient); + + useEffect(() => { + if (!data) { + return; + } + + setIsLoading(false); + }, [data?.pages]); + + const groupedChatHistory = useMemo(() => { + const allHistories = data?.pages?.flatMap((page) => page.data); + return groupChatHistory(allHistories ?? []); + }, [data?.pages]); + + if (!isLoggedIn()) { + return null; + } + + if (!isOpen) { + return ( +
    + + +
    + ); + } + + const isEmptyHistory = Object.values(groupedChatHistory ?? {}).every( + (group) => group.histories.length === 0, + ); + + const classNames = cn( + 'flex w-[255px] shrink-0 flex-col justify-start border-r border-gray-200 bg-white p-2', + 'max-md:absolute max-md:inset-0 max-md:z-20 max-md:w-full', + !isOpen && 'hidden', + ); + + const closeButton = ( + + ); + + if (!isPaidUser) { + return ( + + ); + } + + return ( +
    + {isLoading && } + {!isLoading && isError && } + + {!isLoading && !isError && ( + <> +
    +
    +

    Chat History

    + {closeButton} +
    + + + + +
    + +
    + {isEmptyHistory && !isLoadingInfiniteQuery && ( +
    +

    No chat history

    +
    + )} + + {Object.entries(groupedChatHistory ?? {}).map(([key, value]) => { + if (value.histories.length === 0) { + return null; + } + + return ( + { + if (isMobile) { + setIsOpen(false); + } + + onChatHistoryClick(id); + }} + onDelete={(id) => { + onDelete?.(id); + }} + /> + ); + })} + + {hasNextPage && ( +
    + +
    + )} +
    + + )} +
    + ); +} + +type UpgradeToProMessageProps = { + className?: string; + onUpgrade?: () => void; + closeButton?: React.ReactNode; +}; + +export function UpgradeToProMessage(props: UpgradeToProMessageProps) { + const { className, onUpgrade, closeButton } = props; + + return ( +
    +
    + {closeButton} +
    + +
    +
    +
    + +
    +

    + Unlock History +

    +

    + Save conversations and pick up right where you left off. +

    +
    + +
    +
    + + Unlimited history +
    +
    + + Search old chats +
    +
    + + +
    +
    + ); +} diff --git a/src/components/AIChatHistory/ListChatHistorySkeleton.tsx b/src/components/AIChatHistory/ListChatHistorySkeleton.tsx new file mode 100644 index 000000000..015745fab --- /dev/null +++ b/src/components/AIChatHistory/ListChatHistorySkeleton.tsx @@ -0,0 +1,35 @@ +export function ListChatHistorySkeleton() { + return ( + <> +
    +
    +
    + +
    +
    + +
    + +
    +
    +
    +
    + +
    + {['Today', 'Last 7 Days', 'Older'].map((group) => ( +
    +
    +
      + {[1, 2, 3].map((i) => ( +
    • + ))} +
    +
    + ))} +
    + + ); +} diff --git a/src/components/AIChatHistory/SearchAIChatHistory.tsx b/src/components/AIChatHistory/SearchAIChatHistory.tsx new file mode 100644 index 000000000..84dee4d9c --- /dev/null +++ b/src/components/AIChatHistory/SearchAIChatHistory.tsx @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react'; +import { useDebounceValue } from '../../hooks/use-debounce'; +import { Loader2Icon, XIcon, SearchIcon } from 'lucide-react'; +import { cn } from '../../lib/classname'; + +type SearchAIChatHistoryProps = { + onSearch: (search: string) => void; + isLoading?: boolean; + className?: string; + inputClassName?: string; +}; + +export function SearchAIChatHistory(props: SearchAIChatHistoryProps) { + const { onSearch, isLoading, className, inputClassName } = props; + + const [search, setSearch] = useState(''); + const debouncedSearch = useDebounceValue(search, 300); + + useEffect(() => { + onSearch(debouncedSearch); + }, [debouncedSearch, onSearch]); + + return ( +
    { + e.preventDefault(); + onSearch(search); + }} + > + setSearch(e.target.value)} + /> + +
    + {isLoading ? ( + + ) : ( + + )} +
    + {search && ( +
    + +
    + )} +
    + ); +} diff --git a/src/components/AITutor/AITutorLayout.tsx b/src/components/AITutor/AITutorLayout.tsx index cb72434fd..f7c797458 100644 --- a/src/components/AITutor/AITutorLayout.tsx +++ b/src/components/AITutor/AITutorLayout.tsx @@ -35,11 +35,6 @@ export function AITutorLayout(props: AITutorLayoutProps) { 'flex flex-grow flex-row lg:h-screen', containerClassName, )} - style={ - { - '--ai-sidebar-width': '255px', - } as React.CSSProperties - } > setIsSidebarFloating(false)} diff --git a/src/components/AITutor/AITutorSidebar.tsx b/src/components/AITutor/AITutorSidebar.tsx index 1d9215896..abbad81e7 100644 --- a/src/components/AITutor/AITutorSidebar.tsx +++ b/src/components/AITutor/AITutorSidebar.tsx @@ -94,7 +94,7 @@ export function AITutorSidebar(props: AITutorSidebarProps) {
    - {hasMessages && ( + {hasMessages && !isPaidUser && ( { setInputValue(''); @@ -550,7 +631,7 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) { {!isOpen && ( +
    + )} +
    - {isLoading && ( -
    -
    - - Loading Roadmap -
    -
    + {isLoading && } + {isChatHistoryLoading && ( + )} - {shouldShowChatPersona && !isLoading && ( + {shouldShowChatPersona && !isLoading && !isChatHistoryLoading && ( )} - {!isLoading && !shouldShowChatPersona && ( -
    -
    -
    - - } - isIntro - /> - - {aiChatHistory.map( - (chat: RoadmapAIChatHistoryType, index: number) => { - return ( - - - - ); - }, - )} - - {isStreamingMessage && !streamedMessage && ( + {!isLoading && + !isChatHistoryLoading && + !shouldShowChatPersona && ( +
    +
    +
    + } + isIntro /> - )} - {streamedMessage && ( - - )} + {aiChatHistory.map( + (chat: RoadmapAIChatHistoryType, index: number) => { + return ( + + + + ); + }, + )} + + {isStreamingMessage && !streamedMessage && ( + + )} + + {streamedMessage && ( + + )} +
    -
    - )} + )}
    - {!isLoading && !shouldShowChatPersona && ( + {!isLoading && !isChatHistoryLoading && !shouldShowChatPersona && (
    {!isLimitExceeded && ( )} @@ -508,3 +595,20 @@ function isEmptyContent(content: JSONContent) { (!firstContent?.content || firstContent?.content?.length === 0) ); } + +type LoaderProps = { + message?: string; +}; + +function Loader(props: LoaderProps) { + const { message } = props; + + return ( +
    +
    + + {message ?? 'Loading Roadmap'} +
    +
    + ); +} diff --git a/src/components/RoadmapAIChat/RoadmapAIChatHeader.tsx b/src/components/RoadmapAIChat/RoadmapAIChatHeader.tsx index e9bcc65c3..2ee9fcbfd 100644 --- a/src/components/RoadmapAIChat/RoadmapAIChatHeader.tsx +++ b/src/components/RoadmapAIChat/RoadmapAIChatHeader.tsx @@ -3,27 +3,14 @@ import { getAiCourseLimitOptions } from '../../queries/ai-course'; import { queryClient } from '../../stores/query-client'; import { billingDetailsOptions } from '../../queries/billing'; import { isLoggedIn } from '../../lib/jwt'; -import { BookIcon, BotIcon, GiftIcon, XIcon } from 'lucide-react'; +import { BookIcon, BotIcon, GiftIcon, PlusIcon, XIcon } from 'lucide-react'; import type { RoadmapAIChatTab } from './RoadmapAIChat'; import { useState } from 'react'; import { getPercentage } from '../../lib/number'; import { AILimitsPopup } from '../GenerateCourse/AILimitsPopup'; import { cn } from '../../lib/classname'; import { useKeydown } from '../../hooks/use-keydown'; - -type RoadmapAIChatHeaderProps = { - isLoading: boolean; - - onLogin: () => void; - onUpgrade: () => void; - - onCloseChat: () => void; - - activeTab: RoadmapAIChatTab; - onTabChange: (tab: RoadmapAIChatTab) => void; - onCloseTopic: () => void; - selectedTopicId: string | null; -}; +import { RoadmapAIChatHistory } from '../RoadmapAIChatHistory/RoadmapAIChatHistory'; type TabButtonProps = { icon: React.ReactNode; @@ -65,6 +52,26 @@ function TabButton(props: TabButtonProps) { ); } +type RoadmapAIChatHeaderProps = { + isLoading: boolean; + + onLogin: () => void; + onUpgrade: () => void; + + onCloseChat: () => void; + + activeTab: RoadmapAIChatTab; + onTabChange: (tab: RoadmapAIChatTab) => void; + onCloseTopic: () => void; + selectedTopicId: string | null; + + roadmapId: string; + activeChatHistoryId?: string; + onChatHistoryClick: (chatHistoryId: string) => void; + onNewChat: () => void; + onDeleteChatHistory: (chatHistoryId: string) => void; +}; + export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) { const { onLogin, @@ -76,6 +83,11 @@ export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) { onTabChange, onCloseTopic, selectedTopicId, + roadmapId, + activeChatHistoryId, + onChatHistoryClick, + onNewChat, + onDeleteChatHistory, } = props; const [showAILimitsPopup, setShowAILimitsPopup] = useState(false); @@ -146,15 +158,18 @@ export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) { {!isDataLoading && isLoggedIn() && (
    + {isPaidUser && ( + + )} + {!isPaidUser && ( <> - - )} + +
    )}
    diff --git a/src/components/RoadmapAIChatHistory/RoadmapAIChatHistory.tsx b/src/components/RoadmapAIChatHistory/RoadmapAIChatHistory.tsx new file mode 100644 index 000000000..aec8c9932 --- /dev/null +++ b/src/components/RoadmapAIChatHistory/RoadmapAIChatHistory.tsx @@ -0,0 +1,183 @@ +import { HistoryIcon, Loader2Icon } from 'lucide-react'; +import { Popover, PopoverContent, PopoverTrigger } from '../Popover'; +import { useEffect, useMemo, useState } from 'react'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { listChatHistoryOptions } from '../../queries/chat-history'; +import { isLoggedIn } from '../../lib/jwt'; +import { groupChatHistory } from '../../helper/grouping'; +import { ChatHistoryGroup } from '../AIChatHistory/ChatHistoryGroup'; +import { queryClient } from '../../stores/query-client'; +import { SearchAIChatHistory } from '../AIChatHistory/SearchAIChatHistory'; +import { billingDetailsOptions } from '../../queries/billing'; +import { UpgradeToProMessage } from '../AIChatHistory/ListChatHistory'; +import { showLoginPopup } from '../../lib/popup'; + +type RoadmapAIChatHistoryProps = { + roadmapId: string; + activeChatHistoryId?: string; + activeChatHistoryTitle?: string; + onChatHistoryClick: (id: string) => void; + onDelete?: (id: string) => void; + onUpgrade?: () => void; +}; + +export function RoadmapAIChatHistory(props: RoadmapAIChatHistoryProps) { + const { + roadmapId, + activeChatHistoryId, + activeChatHistoryTitle, + onChatHistoryClick, + onDelete, + onUpgrade, + } = props; + + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [query, setQuery] = useState(''); + + const { data: userBillingDetails, isLoading: isBillingDetailsLoading } = + useQuery(billingDetailsOptions(), queryClient); + const isPaidUser = userBillingDetails?.status === 'active'; + const { + data: chatHistory, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + isLoading: isLoadingInfiniteQuery, + } = useInfiniteQuery( + { + ...listChatHistoryOptions({ + roadmapId, + query, + }), + enabled: !!roadmapId && isLoggedIn() && isOpen && isPaidUser, + }, + queryClient, + ); + + // no initial spinner if not paid user + // because we won't fetch the data + useEffect(() => { + if (!isPaidUser) { + setIsLoading(false); + } + }, [isPaidUser]); + + useEffect(() => { + if (!chatHistory || isBillingDetailsLoading) { + return; + } + + setIsLoading(false); + }, [chatHistory, isBillingDetailsLoading]); + + const groupedChatHistory = useMemo(() => { + const allHistories = chatHistory?.pages?.flatMap((page) => page.data); + return groupChatHistory(allHistories ?? []); + }, [chatHistory?.pages]); + const isEmptyHistory = Object.values(groupedChatHistory ?? {}).every( + (group) => group.histories.length === 0, + ); + + return ( + { + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + + setIsOpen(open); + }} + > + + + {activeChatHistoryTitle || 'Chat History'} + + + {isLoading && ( +
    + +
    + )} + + {!isLoading && !isPaidUser && ( + { + setIsOpen(false); + onUpgrade?.(); + }} + /> + )} + + {!isLoading && isPaidUser && ( + <> + + +
    + {isEmptyHistory && ( +
    +

    No chat history

    +
    + )} + + {Object.entries(groupedChatHistory ?? {}).map(([key, value]) => { + if (value.histories.length === 0) { + return null; + } + + return ( + { + setIsOpen(false); + onChatHistoryClick(id); + }} + onDelete={(id) => { + setIsOpen(false); + onDelete?.(id); + }} + /> + ); + })} + + {hasNextPage && ( +
    + +
    + )} +
    + + )} +
    +
    + ); +} diff --git a/src/helper/grouping.ts b/src/helper/grouping.ts new file mode 100644 index 000000000..2bdab5b8a --- /dev/null +++ b/src/helper/grouping.ts @@ -0,0 +1,42 @@ +import { DateTime } from 'luxon'; +import type { ChatHistoryWithoutMessages } from '../queries/chat-history'; + +export function groupChatHistory(chatHistories: ChatHistoryWithoutMessages[]) { + const today = DateTime.now().startOf('day'); + + return chatHistories?.reduce( + (acc, chatHistory) => { + const updatedAt = DateTime.fromJSDate( + new Date(chatHistory.updatedAt), + ).startOf('day'); + const diffInDays = Math.abs(updatedAt.diff(today, 'days').days); + + if (diffInDays === 0) { + acc.today.histories.push(chatHistory); + } else if (diffInDays <= 7) { + acc.last7Days.histories.push(chatHistory); + } else { + acc.older.histories.push(chatHistory); + } + + return acc; + }, + { + today: { + title: 'Today', + histories: [], + }, + last7Days: { + title: 'Last 7 Days', + histories: [], + }, + older: { + title: 'Older', + histories: [], + }, + } as Record< + string, + { title: string; histories: ChatHistoryWithoutMessages[] } + >, + ); +} diff --git a/src/hooks/use-roadmap-ai-chat.tsx b/src/hooks/use-roadmap-ai-chat.tsx index 55ba716a5..6f6abbcde 100644 --- a/src/hooks/use-roadmap-ai-chat.tsx +++ b/src/hooks/use-roadmap-ai-chat.tsx @@ -16,6 +16,47 @@ import { RoadmapTopicList } from '../components/RoadmapAIChat/RoadmapTopicList'; import { ShareResourceLink } from '../components/RoadmapAIChat/ShareResourceLink'; import { RoadmapRecommendations } from '../components/RoadmapAIChat/RoadmapRecommendations'; import type { AllowedAIChatRole } from '../components/GenerateCourse/AICourseLessonChat'; +import { readChatStream } from '../lib/chat'; + +type RoadmapAIChatRendererOptions = { + totalTopicCount: number; + roadmapId: string; + onSelectTopic: (topicId: string, topicTitle: string) => void; +}; + +export function roadmapAIChatRenderer( + options: RoadmapAIChatRendererOptions, +): Record { + const { totalTopicCount, roadmapId, onSelectTopic } = options; + + return { + 'user-progress': () => ( + + ), + 'update-progress': (opts) => ( + + ), + 'roadmap-topics': (opts) => ( + { + const title = text.split(' > ').pop(); + if (!title) { + return; + } + + onSelectTopic(topicId, title); + }} + {...opts} + /> + ), + 'resource-progress-link': () => , + 'roadmap-recommendations': (opts) => , + }; +} export type RoadmapAIChatHistoryType = { role: AllowedAIChatRole; @@ -27,14 +68,23 @@ export type RoadmapAIChatHistoryType = { }; type Options = { + activeChatHistoryId?: string; roadmapId: string; totalTopicCount: number; scrollareaRef: React.RefObject; onSelectTopic: (topicId: string, topicTitle: string) => void; + onChatHistoryIdChange?: (chatHistoryId: string) => void; }; export function useRoadmapAIChat(options: Options) { - const { roadmapId, totalTopicCount, scrollareaRef, onSelectTopic } = options; + const { + activeChatHistoryId, + roadmapId, + totalTopicCount, + scrollareaRef, + onSelectTopic, + onChatHistoryIdChange, + } = options; const toast = useToast(); const [aiChatHistory, setAiChatHistory] = useState< @@ -99,33 +149,7 @@ export function useRoadmapAIChat(options: Options) { }, [isStreamingMessage, streamedMessage, scrollToBottom]); const renderer: Record = useMemo( - () => ({ - 'user-progress': () => ( - - ), - 'update-progress': (opts) => ( - - ), - 'roadmap-topics': (opts) => ( - { - const title = text.split(' > ').pop(); - if (title) { - onSelectTopic(topicId, title); - } - }} - {...opts} - /> - ), - 'resource-progress-link': () => ( - - ), - 'roadmap-recommendations': (opts) => , - }), + () => roadmapAIChatRenderer({ roadmapId, totalTopicCount, onSelectTopic }), [roadmapId, onSelectTopic, totalTopicCount], ); @@ -141,7 +165,13 @@ export function useRoadmapAIChat(options: Options) { headers: { 'Content-Type': 'application/json' }, credentials: 'include', signal: abortController?.signal, - body: JSON.stringify({ roadmapId, messages: messages.slice(-10) }), + body: JSON.stringify({ + roadmapId, + messages, + ...(activeChatHistoryId + ? { chatHistoryId: activeChatHistoryId } + : {}), + }), }, ); @@ -158,23 +188,31 @@ export function useRoadmapAIChat(options: Options) { return; } - const reader = response.body?.getReader(); - if (!reader) { + const stream = response.body; + if (!stream) { setIsStreamingMessage(false); toast.error('Something went wrong'); return; } - await readStream(reader, { - onStream: async (content) => { - if (abortController?.signal.aborted) return; + await readChatStream(stream, { + onMessage: async (content) => { + if (abortController?.signal.aborted) { + return; + } + const jsx = await renderMessage(content, renderer, { isLoading: true, }); - flushSync(() => setStreamedMessage(jsx)); + flushSync(() => { + setStreamedMessage(jsx); + }); }, - onStreamEnd: async (content) => { - if (abortController?.signal.aborted) return; + onMessageEnd: async (content) => { + if (abortController?.signal.aborted) { + return; + } + const jsx = await renderMessage(content, renderer, { isLoading: false, }); @@ -188,6 +226,24 @@ export function useRoadmapAIChat(options: Options) { setAiChatHistory(newMessages); }); queryClient.invalidateQueries(getAiCourseLimitOptions()); + queryClient.invalidateQueries({ + predicate: (query) => { + return ( + query.queryKey[0] === 'list-chat-history' && + (query.queryKey[1] as { roadmapId: string })?.roadmapId === + roadmapId + ); + }, + }); + }, + onDetails: (details) => { + const detailsJson = JSON.parse(details); + const chatHistoryId = detailsJson?.chatHistoryId; + if (!chatHistoryId) { + return; + } + + onChatHistoryIdChange?.(chatHistoryId); }, }); @@ -256,10 +312,11 @@ export function useRoadmapAIChat(options: Options) { handleAbort, clearChat, scrollToBottom, + setAiChatHistory, }; } -function htmlFromTiptapJSON(json: JSONContent): string { +export function htmlFromTiptapJSON(json: JSONContent): string { const content = json.content; let text = ''; for (const child of content || []) { diff --git a/src/lib/chat.ts b/src/lib/chat.ts new file mode 100644 index 000000000..b1004b865 --- /dev/null +++ b/src/lib/chat.ts @@ -0,0 +1,94 @@ +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; +} + +export async function readChatStream( + stream: ReadableStream, + { + onMessage, + onMessageEnd, + onDetails, + }: { + onMessage?: (message: string) => Promise; + onMessageEnd?: (message: string) => Promise; + onDetails?: (details: string) => Promise | void; + }, +) { + const reader = stream.getReader(); + const decoder = new TextDecoder('utf-8'); + const chunks: Uint8Array[] = []; + + let totalLength = 0; + let result = ''; + + 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 = 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: 'message', content: JSON.parse(content) }; + case CHAT_RESPONSE_PREFIX.details: + return { type: 'details', content }; + default: + throw new Error('Invalid prefix: ' + prefix); + } + }); + + for (const part of streamParts) { + if (part.type === 'message') { + result += part.content; + await onMessage?.(result); + } else if (part.type === 'details') { + await onDetails?.(part.content); + } + } + } + + await onMessageEnd?.(result); + reader.releaseLock(); +} diff --git a/src/pages/[roadmapId]/ai.astro b/src/pages/[roadmapId]/ai.astro index d52b8be6d..75e8c2215 100644 --- a/src/pages/[roadmapId]/ai.astro +++ b/src/pages/[roadmapId]/ai.astro @@ -1,7 +1,15 @@ --- -import { type RoadmapFrontmatter, getRoadmapIds } from '../../lib/roadmap'; +import { CheckSubscriptionVerification } from '../../components/Billing/CheckSubscriptionVerification'; +import { RoadmapAIChat } from '../../components/RoadmapAIChat/RoadmapAIChat'; +import SkeletonLayout from '../../layouts/SkeletonLayout.astro'; +import { AITutorLayout } from '../../components/AITutor/AITutorLayout'; +import { getRoadmapById, getRoadmapIds } from '../../lib/roadmap'; -export const prerender = true; +type Props = { + roadmapId: string; +}; + +export const prerender = false; export async function getStaticPaths() { const roadmapIds = await getRoadmapIds(); @@ -11,19 +19,25 @@ export async function getStaticPaths() { })); } -interface Params extends Record { - roadmapId: string; -} +const { roadmapId } = Astro.params as Props; -const { roadmapId } = Astro.params as Params; -const roadmapFile = await import( - `../../data/roadmaps/${roadmapId}/${roadmapId}.md` -); +const roadmapDetail = await getRoadmapById(roadmapId); -const roadmapData = roadmapFile.frontmatter as RoadmapFrontmatter; -if (roadmapData.renderer !== 'editor') { - return Astro.rewrite(`/404`); -} - -return Astro.rewrite(`/ai/chat/${roadmapId}`); +const canonicalUrl = `https://roadmap.sh/${roadmapId}/ai`; +const roadmapBriefTitle = roadmapDetail.frontmatter.briefTitle; --- + + + + + + + diff --git a/src/pages/ai/chat/[chatId].astro b/src/pages/ai/chat/[chatId].astro new file mode 100644 index 000000000..e592afc8c --- /dev/null +++ b/src/pages/ai/chat/[chatId].astro @@ -0,0 +1,18 @@ +--- +import SkeletonLayout from '../../../layouts/SkeletonLayout.astro'; +import { AIChatHistory } from '../../../components/AIChatHistory/AIChatHistory'; + +type Props = { + chatId: string; +}; + +const { chatId } = Astro.params as Props; +--- + + + + diff --git a/src/pages/ai/chat/[roadmapId].astro b/src/pages/ai/chat/[roadmapId].astro deleted file mode 100644 index 7e774ab33..000000000 --- a/src/pages/ai/chat/[roadmapId].astro +++ /dev/null @@ -1,33 +0,0 @@ ---- -import { CheckSubscriptionVerification } from '../../../components/Billing/CheckSubscriptionVerification'; -import { RoadmapAIChat } from '../../../components/RoadmapAIChat/RoadmapAIChat'; -import SkeletonLayout from '../../../layouts/SkeletonLayout.astro'; -import { AITutorLayout } from '../../../components/AITutor/AITutorLayout'; -import { getRoadmapById } from '../../../lib/roadmap'; - -type Props = { - roadmapId: string; -}; - -const { roadmapId } = Astro.params as Props; - -const roadmapDetail = await getRoadmapById(roadmapId); - -const canonicalUrl = `https://roadmap.sh/${roadmapId}/ai`; -const roadmapBriefTitle = roadmapDetail.frontmatter.briefTitle; ---- - - - - - - - diff --git a/src/pages/ai/chat/index.astro b/src/pages/ai/chat/index.astro index b73417029..c1d571ab7 100644 --- a/src/pages/ai/chat/index.astro +++ b/src/pages/ai/chat/index.astro @@ -1,8 +1,7 @@ --- import SkeletonLayout from '../../../layouts/SkeletonLayout.astro'; -import { AIChat } from '../../../components/AIChat/AIChat'; -import { AITutorLayout } from '../../../components/AITutor/AITutorLayout'; -import { CheckSubscriptionVerification } from '../../../components/Billing/CheckSubscriptionVerification'; +import { AIChatLayout } from '../../../components/AIChatHistory/AIChatLayout'; +import { AIChatHistory } from '../../../components/AIChatHistory/AIChatHistory'; --- - - - - + diff --git a/src/queries/chat-history.ts b/src/queries/chat-history.ts new file mode 100644 index 000000000..927f44ce4 --- /dev/null +++ b/src/queries/chat-history.ts @@ -0,0 +1,121 @@ +import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query'; +import { httpGet } from '../lib/query-http'; +import { isLoggedIn } from '../lib/jwt'; +import { markdownToHtml } from '../lib/markdown'; +import { aiChatRenderer } from '../components/AIChat/AIChat'; +import { + type MessagePartRenderer, + renderMessage, +} from '../lib/render-chat-message'; +import { + htmlFromTiptapJSON, + type RoadmapAIChatHistoryType, +} from '../hooks/use-roadmap-ai-chat'; +import type { JSONContent } from '@tiptap/core'; + +export type ChatHistoryMessage = { + _id: string; + role: 'user' | 'assistant'; + content: string; + json?: JSONContent; +}; + +export interface ChatHistoryDocument { + _id: string; + + userId: string; + roadmapId?: string; + title: string; + messages: ChatHistoryMessage[]; + + createdAt: Date; + updatedAt: Date; +} + +export function chatHistoryOptions( + chatHistoryId?: string, + renderer?: Record, +) { + return queryOptions({ + queryKey: ['chat-history-details', chatHistoryId], + queryFn: async () => { + const data = await httpGet( + `/v1-chat-history/${chatHistoryId}`, + ); + + if (data.title) { + 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, + }; + }, + enabled: !!isLoggedIn() && !!chatHistoryId, + }); +} + +type ListChatHistoryQuery = { + perPage?: string; + currPage?: string; + query?: string; + roadmapId?: string; +}; + +export type ChatHistoryWithoutMessages = Omit; + +type ListChatHistoryResponse = { + data: ChatHistoryWithoutMessages[]; + totalCount: number; + totalPages: number; + currPage: number; + perPage: number; +}; + +export function listChatHistoryOptions( + query: ListChatHistoryQuery = { + query: '', + roadmapId: '', + }, +) { + return infiniteQueryOptions({ + queryKey: ['list-chat-history', query], + queryFn: ({ pageParam }) => { + return httpGet('/v1-list-chat-history', { + ...(query?.query ? { query: query.query } : {}), + ...(query?.roadmapId ? { roadmapId: query.roadmapId } : {}), + ...(pageParam ? { currPage: pageParam } : {}), + perPage: '21', + }); + }, + enabled: !!isLoggedIn(), + getNextPageParam: (lastPage, pages) => { + return lastPage.currPage < lastPage.totalPages + ? lastPage.currPage + 1 + : undefined; + }, + initialPageParam: 1, + }); +} diff --git a/src/styles/global.css b/src/styles/global.css index 14fee8605..50b13447c 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -2,6 +2,7 @@ @import '@roadmapsh/editor/style.css'; @config '../../tailwind.config.cjs'; +@plugin 'tailwind-scrollbar'; @font-face { font-family: 'Balsamiq Sans';