mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-26 18:44:57 +02:00
feat: histories in global chat and roadmap chat (#8775)
* feat: ai chat history * wip * wip * wip * wip * wip * wip * wip * wip * wip: skeleton loading * wip * wip * wip * wip * wip * wip * wip * wip * fix: chat history * wip * wip * fix: responsiveness * wip * wip * Chat history UI * Update chat history * wip * Update chat history * Update chat history * Fix ai chat not working * Update * wip * feat: show chat history always * feat: upgrade to pro * wip * Update history design * UI design improvement for empty sidebar * feat: chat history title * Fix, delete chat throwing error * Plus icon when chat is closed * fix: action z-index * Improve skeleton and logged out user workflow * Chat history improvements * Add plus for chat icons --------- Co-authored-by: Arik Chakma <arikchangma@gmail.com>
This commit is contained in:
10
.vscode/settings.json
vendored
10
.vscode/settings.json
vendored
@@ -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\\(((?:[^()]|\\([^()]*\\))*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||
]
|
||||
}
|
||||
|
@@ -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"
|
||||
}
|
||||
}
|
||||
|
66
pnpm-lock.yaml
generated
66
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
@@ -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<string, MessagePartRenderer> = {
|
||||
'roadmap-recommendations': (options) => {
|
||||
return <RoadmapRecommendations {...options} />;
|
||||
},
|
||||
'generate-course': (options) => {
|
||||
return <AIChatCourse {...options} />;
|
||||
},
|
||||
};
|
||||
|
||||
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<TailwindScreenDimensions>();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setDeviceType(getTailwindScreenDimension());
|
||||
}, []);
|
||||
|
||||
const [message, setMessage] = useState('');
|
||||
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
|
||||
const [streamedMessage, setStreamedMessage] =
|
||||
useState<React.ReactNode | null>(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<string, MessagePartRenderer> = useMemo(() => {
|
||||
return {
|
||||
'roadmap-recommendations': (options) => {
|
||||
return <RoadmapRecommendations {...options} />;
|
||||
},
|
||||
'generate-course': (options) => {
|
||||
return <AIChatCourse {...options} />;
|
||||
},
|
||||
};
|
||||
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 (
|
||||
<div
|
||||
className="ai-chat relative flex min-h-screen w-full flex-col gap-2 overflow-y-auto bg-gray-100 pb-55"
|
||||
ref={scrollableContainerRef}
|
||||
>
|
||||
<div className="relative mx-auto w-full max-w-3xl grow px-4">
|
||||
{shouldShowQuickHelpPrompts && (
|
||||
<QuickHelpPrompts
|
||||
onQuestionClick={(question) => {
|
||||
textareaMessageRef.current?.focus();
|
||||
setMessage(question);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!shouldShowQuickHelpPrompts && (
|
||||
<ChatHistory
|
||||
chatHistory={aiChatHistory}
|
||||
isStreamingMessage={isStreamingMessage}
|
||||
streamedMessage={streamedMessage}
|
||||
onDelete={handleDelete}
|
||||
onRegenerate={handleRegenerate}
|
||||
/>
|
||||
<div className="ai-chat relative flex grow flex-col gap-2 bg-gray-100">
|
||||
<div
|
||||
className={cn(
|
||||
'scrollbar-none absolute inset-0 overflow-y-auto pb-55',
|
||||
shouldShowUpgradeBanner ? 'pb-60' : 'pb-55',
|
||||
)}
|
||||
ref={scrollableContainerRef}
|
||||
>
|
||||
<div className="relative mx-auto w-full max-w-3xl grow px-4">
|
||||
{shouldShowQuickHelpPrompts && (
|
||||
<QuickHelpPrompts
|
||||
onQuestionClick={(question) => {
|
||||
textareaMessageRef.current?.focus();
|
||||
setMessage(question);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!shouldShowQuickHelpPrompts && (
|
||||
<ChatHistory
|
||||
chatHistory={aiChatHistory}
|
||||
isStreamingMessage={isStreamingMessage}
|
||||
streamedMessage={streamedMessage}
|
||||
onDelete={handleDelete}
|
||||
onRegenerate={handleRegenerate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPersonalizedResponseFormOpen && (
|
||||
@@ -378,12 +438,8 @@ export function AIChat() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showUpgradeModal && (
|
||||
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
|
||||
)}
|
||||
|
||||
<div
|
||||
className="pointer-events-none fixed right-0 bottom-0 left-0 mx-auto w-full max-w-3xl px-4 lg:left-[var(--ai-sidebar-width)]"
|
||||
className="pointer-events-none absolute right-0 bottom-0 left-0 mx-auto w-full max-w-3xl px-4"
|
||||
ref={chatContainerRef}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
@@ -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 && (
|
||||
<QuickActionButton
|
||||
icon={TrashIcon}
|
||||
label="Clear Chat"
|
||||
onClick={() => {
|
||||
setAiChatHistory([]);
|
||||
deleteChatMessage([]);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -470,7 +537,7 @@ export function AIChat() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowUpgradeModal(true);
|
||||
onUpgrade?.();
|
||||
}}
|
||||
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
|
||||
>
|
||||
|
@@ -8,8 +8,8 @@ import {
|
||||
RotateCwIcon,
|
||||
} from 'lucide-react';
|
||||
import { useCopyText } from '../../hooks/use-copy-text';
|
||||
import type { RoadmapAIChatHistoryType } from '../RoadmapAIChat/RoadmapAIChat';
|
||||
import { Tooltip } from '../Tooltip';
|
||||
import type { RoadmapAIChatHistoryType } from '../../hooks/use-roadmap-ai-chat';
|
||||
|
||||
type ChatHistoryProps = {
|
||||
chatHistory: RoadmapAIChatHistoryType[];
|
||||
|
@@ -57,6 +57,7 @@ export function QuickHelpPrompts(props: QuickHelpPromptsProps) {
|
||||
<div className="mt-6 flex flex-wrap items-center gap-2">
|
||||
{quickActions.map((action, index) => (
|
||||
<button
|
||||
key={action.label}
|
||||
className={cn(
|
||||
'pointer-events-auto flex shrink-0 cursor-pointer items-center gap-2 rounded-lg border bg-white px-2 py-1.5 text-sm hover:bg-gray-100 hover:text-black',
|
||||
selectedActionIndex === index
|
||||
@@ -73,6 +74,7 @@ export function QuickHelpPrompts(props: QuickHelpPromptsProps) {
|
||||
<div className="mt-6 divide-y divide-gray-200">
|
||||
{selectedAction.questions.map((question) => (
|
||||
<button
|
||||
type="button"
|
||||
key={question}
|
||||
className="block w-full cursor-pointer p-2 text-left text-sm text-gray-500 hover:bg-gray-100 hover:text-black"
|
||||
onClick={() => onQuestionClick(question)}
|
||||
|
171
src/components/AIChatHistory/AIChatHistory.tsx
Normal file
171
src/components/AIChatHistory/AIChatHistory.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { chatHistoryOptions } from '../../queries/chat-history';
|
||||
import { AIChat, aiChatRenderer } from '../AIChat/AIChat';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { AIChatLayout } from './AIChatLayout';
|
||||
import { ListChatHistory } from './ListChatHistory';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { ChatHistoryError } from './ChatHistoryError';
|
||||
import { useClientMount } from '../../hooks/use-client-mount';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
|
||||
type AIChatHistoryProps = {
|
||||
chatHistoryId?: string;
|
||||
};
|
||||
|
||||
export function AIChatHistory(props: AIChatHistoryProps) {
|
||||
const { chatHistoryId: defaultChatHistoryId } = props;
|
||||
|
||||
const isClientMounted = useClientMount();
|
||||
const [keyTrigger, setKeyTrigger] = useState(0);
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||
const [isChatHistoryLoading, setIsChatHistoryLoading] = useState(true);
|
||||
const [chatHistoryId, setChatHistoryId] = useState<string | undefined>(
|
||||
defaultChatHistoryId || undefined,
|
||||
);
|
||||
|
||||
const { data, error: chatHistoryError } = useQuery(
|
||||
chatHistoryOptions(chatHistoryId, aiChatRenderer),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const {
|
||||
data: userBillingDetails,
|
||||
isLoading: isBillingDetailsLoading,
|
||||
error: billingDetailsError,
|
||||
} = useQuery(billingDetailsOptions(), queryClient);
|
||||
|
||||
const handleChatHistoryClick = useCallback(
|
||||
(nextChatHistoryId: string | null) => {
|
||||
setKeyTrigger((key) => key + 1);
|
||||
|
||||
if (nextChatHistoryId === null) {
|
||||
setChatHistoryId(undefined);
|
||||
window.history.replaceState(null, '', '/ai/chat');
|
||||
return;
|
||||
}
|
||||
|
||||
// show loader only if the chat history hasn't been fetched before (avoids UI flash)
|
||||
const hasAlreadyFetched = queryClient.getQueryData(
|
||||
chatHistoryOptions(nextChatHistoryId).queryKey,
|
||||
);
|
||||
|
||||
if (!hasAlreadyFetched) {
|
||||
setIsChatHistoryLoading(true);
|
||||
}
|
||||
|
||||
setChatHistoryId(nextChatHistoryId);
|
||||
window.history.replaceState(null, '', `/ai/chat/${nextChatHistoryId}`);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(deletedChatHistoryId: string) => {
|
||||
if (deletedChatHistoryId !== chatHistoryId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setChatHistoryId(undefined);
|
||||
window.history.replaceState(null, '', '/ai/chat');
|
||||
setKeyTrigger((key) => key + 1);
|
||||
},
|
||||
[chatHistoryId],
|
||||
);
|
||||
|
||||
const isPaidUser = userBillingDetails?.status === 'active';
|
||||
|
||||
const hasError = chatHistoryError || billingDetailsError;
|
||||
|
||||
const showLoader = isChatHistoryLoading && !hasError;
|
||||
const showError = !isChatHistoryLoading && Boolean(hasError);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatHistoryId) {
|
||||
setIsChatHistoryLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsChatHistoryLoading(false);
|
||||
}, [data, chatHistoryId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasError) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsChatHistoryLoading(false);
|
||||
}, [hasError]);
|
||||
|
||||
if (!isClientMounted || isBillingDetailsLoading) {
|
||||
return (
|
||||
<AIChatLayout>
|
||||
<div className="relative flex grow">
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center">
|
||||
<Loader2Icon className="h-8 w-8 animate-spin stroke-[2.5] text-gray-400/80" />
|
||||
</div>
|
||||
</div>
|
||||
</AIChatLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AIChatLayout>
|
||||
<div className="relative flex grow">
|
||||
<ListChatHistory
|
||||
activeChatHistoryId={chatHistoryId}
|
||||
onChatHistoryClick={handleChatHistoryClick}
|
||||
onDelete={handleDelete}
|
||||
isPaidUser={isPaidUser}
|
||||
onUpgrade={() => {
|
||||
setShowUpgradeModal(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative flex grow">
|
||||
{showLoader && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center">
|
||||
<Loader2Icon className="h-8 w-8 animate-spin stroke-[2.5] text-gray-400/80" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showError && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center">
|
||||
<ChatHistoryError error={hasError} className="mt-0" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showLoader && !showError && (
|
||||
<AIChat
|
||||
key={keyTrigger}
|
||||
messages={data?.messages}
|
||||
chatHistoryId={chatHistoryId}
|
||||
setChatHistoryId={(id) => {
|
||||
setChatHistoryId(id);
|
||||
window.history.replaceState(null, '', `/ai/chat/${id}`);
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
return query.queryKey[0] === 'list-chat-history';
|
||||
},
|
||||
});
|
||||
}}
|
||||
onUpgrade={() => {
|
||||
setShowUpgradeModal(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showUpgradeModal && (
|
||||
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
|
||||
)}
|
||||
</AIChatLayout>
|
||||
);
|
||||
}
|
22
src/components/AIChatHistory/AIChatLayout.tsx
Normal file
22
src/components/AIChatHistory/AIChatLayout.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { AITutorLayout } from '../AITutor/AITutorLayout';
|
||||
import { CheckSubscriptionVerification } from '../Billing/CheckSubscriptionVerification';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
|
||||
type AIChatLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function AIChatLayout(props: AIChatLayoutProps) {
|
||||
const { children } = props;
|
||||
|
||||
return (
|
||||
<AITutorLayout
|
||||
activeTab="chat"
|
||||
wrapperClassName="flex-row p-0 lg:p-0 overflow-hidden"
|
||||
containerClassName="h-[calc(100vh-49px)] overflow-hidden"
|
||||
>
|
||||
{children}
|
||||
<CheckSubscriptionVerification />
|
||||
</AITutorLayout>
|
||||
);
|
||||
}
|
116
src/components/AIChatHistory/ChatHistoryAction.tsx
Normal file
116
src/components/AIChatHistory/ChatHistoryAction.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { EllipsisVerticalIcon, Loader2Icon, Trash2Icon } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../DropdownMenu';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { httpDelete } from '../../lib/query-http';
|
||||
import { listChatHistoryOptions } from '../../queries/chat-history';
|
||||
import { useState } from 'react';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
type ChatHistoryActionProps = {
|
||||
chatHistoryId: string;
|
||||
onDelete?: () => void;
|
||||
};
|
||||
|
||||
export function ChatHistoryAction(props: ChatHistoryActionProps) {
|
||||
const { chatHistoryId, onDelete } = props;
|
||||
|
||||
const toast = useToast();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const { mutate: deleteChatHistory, isPending: isDeletingLoading } =
|
||||
useMutation(
|
||||
{
|
||||
mutationFn: (chatHistoryId: string) => {
|
||||
return httpDelete(`/v1-delete-chat/${chatHistoryId}`);
|
||||
},
|
||||
onSettled: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
return query.queryKey[0] === 'list-chat-history';
|
||||
},
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Chat history deleted');
|
||||
setIsOpen(false);
|
||||
onDelete?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error?.message || 'Failed to delete chat history');
|
||||
},
|
||||
},
|
||||
queryClient,
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger className="rounded-lg p-2 opacity-0 group-hover/item:opacity-100 hover:bg-gray-100 focus:outline-none data-[state=open]:bg-gray-100 data-[state=open]:opacity-100">
|
||||
<EllipsisVerticalIcon className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="z-[9999]">
|
||||
{!isDeleting && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer text-red-500 focus:bg-red-50 focus:text-red-500"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDeleting(true);
|
||||
}}
|
||||
disabled={isDeletingLoading}
|
||||
>
|
||||
{isDeletingLoading ? (
|
||||
<>
|
||||
<Loader2Icon className="h-4 w-4 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
Delete
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isDeleting && (
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
className="focus:bg-transparent"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
disabled={isDeletingLoading}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-1.5">
|
||||
Are you sure?
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteChatHistory(chatHistoryId);
|
||||
setIsDeleting(false);
|
||||
}}
|
||||
className="cursor-pointer text-red-500 underline hover:text-red-800"
|
||||
disabled={isDeletingLoading}
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsDeleting(false)}
|
||||
className="cursor-pointer text-red-500 underline hover:text-red-800"
|
||||
disabled={isDeletingLoading}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
28
src/components/AIChatHistory/ChatHistoryError.tsx
Normal file
28
src/components/AIChatHistory/ChatHistoryError.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-10 flex max-w-md flex-col items-center justify-center text-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<AlertCircleIcon className="h-8 w-8 text-red-500" />
|
||||
<h3 className="mt-4 text-sm font-medium text-gray-900">
|
||||
Something went wrong
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-balance text-gray-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
42
src/components/AIChatHistory/ChatHistoryGroup.tsx
Normal file
42
src/components/AIChatHistory/ChatHistoryGroup.tsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<h2 className="ml-2 text-xs text-gray-500">{title}</h2>
|
||||
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{histories.map((chatHistory) => {
|
||||
return (
|
||||
<ChatHistoryItem
|
||||
key={chatHistory._id}
|
||||
chatHistory={chatHistory}
|
||||
isActive={activeChatHistoryId === chatHistory._id}
|
||||
onChatHistoryClick={onChatHistoryClick}
|
||||
onDelete={() => {
|
||||
onDelete?.(chatHistory._id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
33
src/components/AIChatHistory/ChatHistoryItem.tsx
Normal file
33
src/components/AIChatHistory/ChatHistoryItem.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { cn } from '../../lib/classname';
|
||||
import type { ChatHistoryDocument } from '../../queries/chat-history';
|
||||
import { ChatHistoryAction } from './ChatHistoryAction';
|
||||
|
||||
type ChatHistoryItemProps = {
|
||||
chatHistory: Omit<ChatHistoryDocument, 'messages'>;
|
||||
isActive: boolean;
|
||||
onChatHistoryClick: (chatHistoryId: string) => void;
|
||||
onDelete?: () => void;
|
||||
};
|
||||
|
||||
export function ChatHistoryItem(props: ChatHistoryItemProps) {
|
||||
const { chatHistory, isActive, onChatHistoryClick, onDelete } = props;
|
||||
|
||||
return (
|
||||
<li key={chatHistory._id} className="group/item relative text-sm">
|
||||
<button
|
||||
className="block w-full truncate rounded-lg p-2 pr-10 text-left hover:bg-gray-100 data-[active=true]:bg-gray-100"
|
||||
data-active={isActive}
|
||||
onClick={() => onChatHistoryClick(chatHistory._id)}
|
||||
>
|
||||
{chatHistory.title}
|
||||
</button>
|
||||
|
||||
<div className="absolute inset-y-0 right-2 flex items-center">
|
||||
<ChatHistoryAction
|
||||
chatHistoryId={chatHistory._id}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
292
src/components/AIChatHistory/ListChatHistory.tsx
Normal file
292
src/components/AIChatHistory/ListChatHistory.tsx
Normal file
@@ -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 (
|
||||
<div className="absolute top-2 left-2 z-20 flex items-center gap-1">
|
||||
<button
|
||||
className="flex size-8 items-center justify-center rounded-lg p-1 hover:bg-gray-200"
|
||||
onClick={() => {
|
||||
setIsOpen(true);
|
||||
}}
|
||||
>
|
||||
<PanelLeftIcon className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
<button
|
||||
className="flex size-8 items-center justify-center rounded-lg p-1 hover:bg-gray-200"
|
||||
onClick={() => {
|
||||
if (isMobile) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
onChatHistoryClick(null);
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<button
|
||||
className="flex size-8 items-center justify-center rounded-lg p-1 text-gray-500 hover:bg-gray-100 hover:text-black"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<PanelLeftCloseIcon className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
);
|
||||
|
||||
if (!isPaidUser) {
|
||||
return (
|
||||
<UpgradeToProMessage
|
||||
className={classNames}
|
||||
closeButton={closeButton}
|
||||
onUpgrade={onUpgrade}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames}>
|
||||
{isLoading && <ListChatHistorySkeleton />}
|
||||
{!isLoading && isError && <ChatHistoryError error={error} />}
|
||||
|
||||
{!isLoading && !isError && (
|
||||
<>
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="font-medium text-gray-900">Chat History</h1>
|
||||
{closeButton}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-black p-2 text-sm text-white hover:opacity-80"
|
||||
onClick={() => {
|
||||
if (isMobile) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
onChatHistoryClick(null);
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
<span className="text-sm">New Chat</span>
|
||||
</button>
|
||||
|
||||
<SearchAIChatHistory
|
||||
onSearch={setQuery}
|
||||
isLoading={isLoadingInfiniteQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="scrollbar-track-transparent scrollbar-thin scrollbar-thumb-gray-300 -mx-2 mt-6 grow space-y-4 overflow-y-scroll px-2">
|
||||
{isEmptyHistory && !isLoadingInfiniteQuery && (
|
||||
<div className="flex items-center justify-center">
|
||||
<p className="text-sm text-gray-500">No chat history</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.entries(groupedChatHistory ?? {}).map(([key, value]) => {
|
||||
if (value.histories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatHistoryGroup
|
||||
key={key}
|
||||
title={value.title}
|
||||
histories={value.histories}
|
||||
activeChatHistoryId={activeChatHistoryId}
|
||||
onChatHistoryClick={(id) => {
|
||||
if (isMobile) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
onChatHistoryClick(id);
|
||||
}}
|
||||
onDelete={(id) => {
|
||||
onDelete?.(id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{hasNextPage && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-2 text-sm text-gray-500 hover:text-black"
|
||||
onClick={() => {
|
||||
fetchNextPage();
|
||||
}}
|
||||
disabled={isFetchingNextPage}
|
||||
>
|
||||
{isFetchingNextPage && (
|
||||
<>
|
||||
<Loader2Icon className="h-4 w-4 animate-spin" />
|
||||
Loading more...
|
||||
</>
|
||||
)}
|
||||
{!isFetchingNextPage && 'Load More'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type UpgradeToProMessageProps = {
|
||||
className?: string;
|
||||
onUpgrade?: () => void;
|
||||
closeButton?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function UpgradeToProMessage(props: UpgradeToProMessageProps) {
|
||||
const { className, onUpgrade, closeButton } = props;
|
||||
|
||||
return (
|
||||
<div className={cn('relative flex flex-col', className)}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
{closeButton}
|
||||
</div>
|
||||
|
||||
<div className="flex grow flex-col items-center justify-center px-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="mb-3 rounded-full bg-yellow-100 p-3">
|
||||
<LockIcon className="size-6 text-yellow-600" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Unlock History
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-balance text-gray-600">
|
||||
Save conversations and pick up right where you left off.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="my-5 w-full space-y-2">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
|
||||
<CheckIcon additionalClasses="size-4 text-green-500" />
|
||||
<span className="text-sm text-gray-600">Unlimited history</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
|
||||
<CheckIcon additionalClasses="size-4 text-green-500" />
|
||||
<span className="text-sm text-gray-600">Search old chats</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full cursor-pointer rounded-lg bg-yellow-400 px-4 py-2 text-sm font-medium text-black hover:bg-yellow-500"
|
||||
onClick={() => {
|
||||
onUpgrade?.();
|
||||
}}
|
||||
>
|
||||
Upgrade to Pro
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
35
src/components/AIChatHistory/ListChatHistorySkeleton.tsx
Normal file
35
src/components/AIChatHistory/ListChatHistorySkeleton.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
export function ListChatHistorySkeleton() {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between gap-2">
|
||||
<div className="h-6 w-1/2 animate-pulse rounded bg-gray-200" />
|
||||
|
||||
<div className="size-8 animate-pulse rounded-md bg-gray-200" />
|
||||
</div>
|
||||
|
||||
<div className="h-9 w-full animate-pulse rounded-lg bg-gray-200" />
|
||||
|
||||
<div className="relative mt-2">
|
||||
<div className="h-9 w-full animate-pulse rounded-lg bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="scrollbar-track-transparent scrollbar-thin scrollbar-thumb-gray-300 -mx-2 mt-6 grow space-y-4 overflow-y-scroll px-2">
|
||||
{['Today', 'Last 7 Days', 'Older'].map((group) => (
|
||||
<div key={group}>
|
||||
<div className="h-4 w-16 animate-pulse rounded bg-gray-200" />
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="h-9 animate-pulse rounded-lg bg-gray-100"
|
||||
></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
66
src/components/AIChatHistory/SearchAIChatHistory.tsx
Normal file
66
src/components/AIChatHistory/SearchAIChatHistory.tsx
Normal file
@@ -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 (
|
||||
<form
|
||||
className={cn('relative mt-2 flex grow items-center', className)}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSearch(search);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search folder by name"
|
||||
className={cn(
|
||||
'block h-9 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 pr-7 pl-8 text-sm outline-none placeholder:text-zinc-500 focus:border-zinc-500',
|
||||
inputClassName,
|
||||
)}
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={255}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="absolute top-1/2 left-2.5 -translate-y-1/2">
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="size-4 animate-spin text-gray-500" />
|
||||
) : (
|
||||
<SearchIcon className="size-4 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
{search && (
|
||||
<div className="absolute inset-y-0 right-1 flex items-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
}}
|
||||
className="rounded-lg p-1 hover:bg-gray-100"
|
||||
>
|
||||
<XIcon className="size-4 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
@@ -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
|
||||
}
|
||||
>
|
||||
<AITutorSidebar
|
||||
onClose={() => setIsSidebarFloating(false)}
|
||||
|
@@ -94,7 +94,7 @@ export function AITutorSidebar(props: AITutorSidebarProps) {
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
'flex w-[var(--ai-sidebar-width)] shrink-0 flex-col border-r border-slate-200',
|
||||
'flex w-[255px] shrink-0 flex-col border-r border-slate-200',
|
||||
isFloating
|
||||
? 'fixed top-0 bottom-0 left-0 z-50 flex border-r-0 bg-white shadow-xl'
|
||||
: 'hidden lg:flex',
|
||||
|
@@ -3,9 +3,11 @@ import type { JSONContent } from '@tiptap/core';
|
||||
import {
|
||||
BookOpen,
|
||||
ChevronDown,
|
||||
Loader2Icon,
|
||||
MessageCirclePlus,
|
||||
PauseCircleIcon,
|
||||
PersonStanding,
|
||||
Plus,
|
||||
SendIcon,
|
||||
SquareArrowOutUpRight,
|
||||
Trash2,
|
||||
@@ -16,23 +18,25 @@ import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import {
|
||||
roadmapAIChatRenderer,
|
||||
useRoadmapAIChat,
|
||||
type RoadmapAIChatHistoryType,
|
||||
} from '../../hooks/use-roadmap-ai-chat';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { lockBodyScroll } from '../../lib/dom';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { slugify } from '../../lib/slugger';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { chatHistoryOptions } from '../../queries/chat-history';
|
||||
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 { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
|
||||
type ChatHeaderButtonProps = {
|
||||
onClick?: () => void;
|
||||
@@ -47,7 +51,7 @@ function ChatHeaderButton(props: ChatHeaderButtonProps) {
|
||||
const { onClick, href, icon, children, className, target } = props;
|
||||
|
||||
const classNames = cn(
|
||||
'flex items-center gap-1.5 text-xs text-gray-600 transition-colors hover:text-gray-900',
|
||||
'flex shrink-0 items-center gap-1.5 text-xs text-gray-600 transition-colors hover:text-gray-900 min-w-8',
|
||||
className,
|
||||
);
|
||||
|
||||
@@ -227,6 +231,22 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const [isChatHistoryLoading, setIsChatHistoryLoading] = useState(true);
|
||||
const [activeChatHistoryId, setActiveChatHistoryId] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
const { data: chatHistory } = useQuery(
|
||||
chatHistoryOptions(
|
||||
activeChatHistoryId,
|
||||
roadmapAIChatRenderer({
|
||||
roadmapId,
|
||||
totalTopicCount,
|
||||
onSelectTopic,
|
||||
}),
|
||||
),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const {
|
||||
aiChatHistory,
|
||||
isStreamingMessage,
|
||||
@@ -237,13 +257,39 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
|
||||
handleAbort,
|
||||
scrollToBottom,
|
||||
clearChat,
|
||||
setAiChatHistory,
|
||||
} = useRoadmapAIChat({
|
||||
activeChatHistoryId,
|
||||
roadmapId,
|
||||
totalTopicCount,
|
||||
scrollareaRef,
|
||||
onSelectTopic,
|
||||
onChatHistoryIdChange: (chatHistoryId) => {
|
||||
setActiveChatHistoryId(chatHistoryId);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatHistory) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAiChatHistory(chatHistory?.messages ?? []);
|
||||
setIsChatHistoryLoading(false);
|
||||
setTimeout(() => {
|
||||
scrollToBottom('instant');
|
||||
}, 0);
|
||||
}, [chatHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeChatHistoryId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAiChatHistory([]);
|
||||
setIsChatHistoryLoading(false);
|
||||
}, [activeChatHistoryId, setAiChatHistory, setIsChatHistoryLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
lockBodyScroll(isOpen);
|
||||
}, [isOpen]);
|
||||
@@ -293,6 +339,7 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
|
||||
};
|
||||
|
||||
const hasMessages = aiChatHistory.length > 0;
|
||||
const newTabUrl = `/${roadmapId}/ai${activeChatHistoryId ? `?chatId=${activeChatHistoryId}` : ''}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -330,32 +377,69 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
|
||||
>
|
||||
{isOpen && (
|
||||
<>
|
||||
<div className="flex h-full w-full flex-col overflow-hidden rounded-lg bg-white shadow-lg">
|
||||
{/* Messages area */}
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden rounded-lg bg-white shadow-lg">
|
||||
{isChatHistoryLoading && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white">
|
||||
<div className="flex items-center rounded-md border border-gray-200 py-2 pr-3 pl-2">
|
||||
<Loader2Icon className="size-4 animate-spin stroke-[2.5] text-gray-400" />
|
||||
<span className="ml-2 text-sm text-gray-500">
|
||||
Loading history..
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<div className="flex">
|
||||
<ChatHeaderButton
|
||||
icon={<BookOpen className="h-3.5 w-3.5" />}
|
||||
className="text-sm"
|
||||
className="pointer-events-none text-sm"
|
||||
>
|
||||
AI Tutor
|
||||
{chatHistory?.title || 'AI Tutor'}
|
||||
</ChatHeaderButton>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5">
|
||||
{isPaidUser && activeChatHistoryId && (
|
||||
<ChatHeaderButton
|
||||
onClick={() => {
|
||||
setActiveChatHistoryId(undefined);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
icon={<Plus className="h-3.5 w-3.5" />}
|
||||
className="justify-center rounded-md bg-gray-200 px-2 py-1 text-xs text-black hover:bg-gray-300"
|
||||
/>
|
||||
)}
|
||||
|
||||
<RoadmapAIChatHistory
|
||||
roadmapId={roadmapId}
|
||||
activeChatHistoryId={activeChatHistoryId}
|
||||
onChatHistoryClick={(chatHistoryId) => {
|
||||
setIsChatHistoryLoading(true);
|
||||
setActiveChatHistoryId(chatHistoryId);
|
||||
setShowScrollToBottom(false);
|
||||
}}
|
||||
onDelete={(chatHistoryId) => {
|
||||
if (activeChatHistoryId === chatHistoryId) {
|
||||
setActiveChatHistoryId(undefined);
|
||||
}
|
||||
}}
|
||||
onUpgrade={() => {
|
||||
setShowUpgradeModal(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChatHeaderButton
|
||||
href={`/${roadmapId}/ai`}
|
||||
href={newTabUrl}
|
||||
target="_blank"
|
||||
icon={<SquareArrowOutUpRight className="h-3.5 w-3.5" />}
|
||||
className="hidden rounded-md py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300 sm:flex"
|
||||
>
|
||||
Open in new tab
|
||||
</ChatHeaderButton>
|
||||
className="hidden justify-center rounded-md bg-gray-200 px-1 py-1 text-gray-500 hover:bg-gray-300 sm:flex"
|
||||
/>
|
||||
|
||||
<ChatHeaderButton
|
||||
onClick={() => setIsOpen(false)}
|
||||
icon={<X className="h-3.5 w-3.5" />}
|
||||
className="rounded-md bg-red-100 px-1 py-1 text-red-500 hover:bg-red-200"
|
||||
className="flex items-center justify-center rounded-md bg-red-100 px-1 py-1 text-red-500 hover:bg-red-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -412,13 +496,11 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{aiChatHistory.map(
|
||||
(chat: RoadmapAIChatHistoryType, index: number) => (
|
||||
<Fragment key={`chat-${index}`}>
|
||||
<RoadmapAIChatCard {...chat} />
|
||||
</Fragment>
|
||||
),
|
||||
)}
|
||||
{aiChatHistory.map((chat, index) => (
|
||||
<Fragment key={`chat-${index}`}>
|
||||
<RoadmapAIChatCard {...chat} />
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{isStreamingMessage && !streamedMessage && (
|
||||
<RoadmapAIChatCard role="assistant" html="Thinking..." />
|
||||
@@ -444,7 +526,6 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
{isLimitExceeded && (
|
||||
<UpgradeMessage
|
||||
onUpgradeClick={() => {
|
||||
@@ -482,7 +563,7 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{hasMessages && (
|
||||
{hasMessages && !isPaidUser && (
|
||||
<ChatHeaderButton
|
||||
onClick={() => {
|
||||
setInputValue('');
|
||||
@@ -550,7 +631,7 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
|
||||
{!isOpen && (
|
||||
<button
|
||||
className={cn(
|
||||
'relative mx-auto flex flex-shrink-0 cursor-pointer items-center justify-center gap-2 rounded-full bg-stone-900 py-2.5 pr-8 pl-6 text-center text-white shadow-2xl transition-all duration-300 hover:scale-101 hover:bg-stone-800 w-max',
|
||||
'relative mx-auto flex w-max flex-shrink-0 cursor-pointer items-center justify-center gap-2 rounded-full bg-stone-900 py-2.5 pr-8 pl-6 text-center text-white shadow-2xl transition-all duration-300 hover:scale-101 hover:bg-stone-800',
|
||||
)}
|
||||
onClick={() => {
|
||||
setIsOpen(true);
|
||||
@@ -566,10 +647,10 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
|
||||
<span className="mr-1 text-sm font-semibold text-yellow-400">
|
||||
AI Tutor
|
||||
</span>
|
||||
<span className={'text-white hidden sm:block'}>
|
||||
<span className={'hidden text-white sm:block'}>
|
||||
Have a question? Type here
|
||||
</span>
|
||||
<span className={'text-white block sm:hidden'}>
|
||||
<span className={'block text-white sm:hidden'}>
|
||||
Ask anything
|
||||
</span>
|
||||
</>
|
||||
|
28
src/components/Popover.tsx
Normal file
28
src/components/Popover.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
import * as React from 'react';
|
||||
import { cn } from '../lib/classname';
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-lg border border-gray-200 bg-white p-2 text-black shadow-sm outline-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverContent, PopoverTrigger };
|
@@ -24,10 +24,12 @@ type AIChatActionButtonsProps = {
|
||||
onTellUsAboutYourSelf: () => void;
|
||||
onClearChat: () => void;
|
||||
messageCount: number;
|
||||
showClearChat: boolean;
|
||||
};
|
||||
|
||||
export function AIChatActionButtons(props: AIChatActionButtonsProps) {
|
||||
const { onTellUsAboutYourSelf, onClearChat, messageCount } = props;
|
||||
const { onTellUsAboutYourSelf, onClearChat, messageCount, showClearChat } =
|
||||
props;
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 px-4 pt-2">
|
||||
@@ -36,7 +38,7 @@ export function AIChatActionButtons(props: AIChatActionButtonsProps) {
|
||||
label="Tell us about your self"
|
||||
onClick={onTellUsAboutYourSelf}
|
||||
/>
|
||||
{messageCount > 0 && (
|
||||
{showClearChat && messageCount > 0 && (
|
||||
<AIChatActionButton
|
||||
icon={Trash2}
|
||||
label="Clear chat"
|
||||
|
@@ -1,8 +1,17 @@
|
||||
import './RoadmapAIChat.css';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { roadmapJSONOptions } from '../../queries/roadmap';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import type { Editor, JSONContent } from '@tiptap/core';
|
||||
import {
|
||||
Bot,
|
||||
Frown,
|
||||
HistoryIcon,
|
||||
Loader2Icon,
|
||||
LockIcon,
|
||||
PauseCircleIcon,
|
||||
SendIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
@@ -12,45 +21,41 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
Bot,
|
||||
Frown,
|
||||
Loader2Icon,
|
||||
LockIcon,
|
||||
PauseCircleIcon,
|
||||
SendIcon,
|
||||
} from 'lucide-react';
|
||||
import { ChatEditor } from '../ChatEditor/ChatEditor';
|
||||
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import type { JSONContent, Editor } from '@tiptap/core';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import {
|
||||
roadmapAIChatRenderer,
|
||||
useRoadmapAIChat,
|
||||
type RoadmapAIChatHistoryType,
|
||||
} from '../../hooks/use-roadmap-ai-chat';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { userResourceProgressOptions } from '../../queries/resource-progress';
|
||||
import { ChatRoadmapRenderer } from './ChatRoadmapRenderer';
|
||||
import { RoadmapAIChatCard } from './RoadmapAIChatCard';
|
||||
import { RoadmapAIChatHeader } from './RoadmapAIChatHeader';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { TopicDetail } from '../TopicDetail/TopicDetail';
|
||||
import { slugify } from '../../lib/slugger';
|
||||
import { AIChatActionButtons } from './AIChatActionButtons';
|
||||
import { deleteUrlParam, getUrlParams } from '../../lib/browser';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { lockBodyScroll } from '../../lib/dom';
|
||||
import {
|
||||
getTailwindScreenDimension,
|
||||
type TailwindScreenDimensions,
|
||||
} from '../../lib/is-mobile';
|
||||
import { ChatPersona } from '../UserPersona/ChatPersona';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { slugify } from '../../lib/slugger';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { chatHistoryOptions } from '../../queries/chat-history';
|
||||
import { userResourceProgressOptions } from '../../queries/resource-progress';
|
||||
import { roadmapJSONOptions } from '../../queries/roadmap';
|
||||
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
|
||||
import { userRoadmapPersonaOptions } from '../../queries/user-persona';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { ChatEditor } from '../ChatEditor/ChatEditor';
|
||||
import { TopicDetail } from '../TopicDetail/TopicDetail';
|
||||
import { ChatPersona } from '../UserPersona/ChatPersona';
|
||||
import { UpdatePersonaModal } from '../UserPersona/UpdatePersonaModal';
|
||||
import { lockBodyScroll } from '../../lib/dom';
|
||||
import { AIChatActionButtons } from './AIChatActionButtons';
|
||||
import { ChatRoadmapRenderer } from './ChatRoadmapRenderer';
|
||||
import { RoadmapAIChatCard } from './RoadmapAIChatCard';
|
||||
import { RoadmapAIChatHeader } from './RoadmapAIChatHeader';
|
||||
import { TutorIntroMessage } from './TutorIntroMessage';
|
||||
import {
|
||||
useRoadmapAIChat,
|
||||
type RoadmapAIChatHistoryType,
|
||||
} from '../../hooks/use-roadmap-ai-chat';
|
||||
|
||||
export type RoadmapAIChatTab = 'chat' | 'topic';
|
||||
|
||||
@@ -79,6 +84,9 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
null,
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<RoadmapAIChatTab>('chat');
|
||||
const [activeChatHistoryId, setActiveChatHistoryId] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
|
||||
const [showUpdatePersonaModal, setShowUpdatePersonaModal] = useState(false);
|
||||
|
||||
@@ -136,10 +144,19 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
}, [roadmapDetail]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = getUrlParams();
|
||||
const queryChatId = params.chatId;
|
||||
|
||||
if (!roadmapTreeData || !roadmapDetail || isUserPersonaLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (queryChatId) {
|
||||
setIsChatHistoryLoading(true);
|
||||
setActiveChatHistoryId(queryChatId);
|
||||
deleteUrlParam('chatId');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, [roadmapTreeData, roadmapDetail, isUserPersonaLoading]);
|
||||
|
||||
@@ -170,6 +187,19 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
[roadmapId, deviceType],
|
||||
);
|
||||
|
||||
const [isChatHistoryLoading, setIsChatHistoryLoading] = useState(true);
|
||||
const { data: chatHistory } = useQuery(
|
||||
chatHistoryOptions(
|
||||
activeChatHistoryId,
|
||||
roadmapAIChatRenderer({
|
||||
roadmapId,
|
||||
totalTopicCount,
|
||||
onSelectTopic,
|
||||
}),
|
||||
),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const {
|
||||
aiChatHistory,
|
||||
isStreamingMessage,
|
||||
@@ -179,13 +209,39 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
handleAbort,
|
||||
clearChat,
|
||||
scrollToBottom,
|
||||
setAiChatHistory,
|
||||
} = useRoadmapAIChat({
|
||||
activeChatHistoryId,
|
||||
roadmapId,
|
||||
totalTopicCount,
|
||||
scrollareaRef,
|
||||
onSelectTopic,
|
||||
onChatHistoryIdChange: (chatHistoryId) => {
|
||||
setActiveChatHistoryId(chatHistoryId);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatHistory) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAiChatHistory(chatHistory?.messages ?? []);
|
||||
setIsChatHistoryLoading(false);
|
||||
setTimeout(() => {
|
||||
scrollToBottom('instant');
|
||||
}, 0);
|
||||
}, [chatHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeChatHistoryId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAiChatHistory([]);
|
||||
setIsChatHistoryLoading(false);
|
||||
}, [activeChatHistoryId, setAiChatHistory, setIsChatHistoryLoading]);
|
||||
|
||||
if (roadmapDetailError) {
|
||||
return (
|
||||
<div className="flex flex-grow flex-col items-center justify-center">
|
||||
@@ -307,6 +363,21 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
setActiveTab('chat');
|
||||
}}
|
||||
selectedTopicId={selectedTopicId}
|
||||
roadmapId={roadmapId}
|
||||
activeChatHistoryId={activeChatHistoryId}
|
||||
onChatHistoryClick={(chatHistoryId) => {
|
||||
setIsChatHistoryLoading(true);
|
||||
setActiveChatHistoryId(chatHistoryId);
|
||||
}}
|
||||
onNewChat={() => {
|
||||
document.title = 'Roadmap AI Chat';
|
||||
setActiveChatHistoryId(undefined);
|
||||
}}
|
||||
onDeleteChatHistory={(chatHistoryId) => {
|
||||
if (activeChatHistoryId === chatHistoryId) {
|
||||
setActiveChatHistoryId(undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{activeTab === 'topic' && selectedTopicId && (
|
||||
@@ -333,62 +404,77 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
|
||||
{activeTab === 'chat' && (
|
||||
<>
|
||||
{!!chatHistory && isPaidUser && !isChatHistoryLoading && (
|
||||
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-gray-100 px-3 py-2 text-sm text-gray-500">
|
||||
<h3 className="flex min-w-0 items-center gap-2">
|
||||
<HistoryIcon className="size-4 shrink-0" />
|
||||
<span className="truncate">{chatHistory.title}</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveChatHistoryId(undefined);
|
||||
}}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative grow overflow-y-auto" ref={scrollareaRef}>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white p-1.5 px-3 text-sm text-gray-500">
|
||||
<Loader2Icon className="size-4 animate-spin stroke-[2.5]" />
|
||||
<span>Loading Roadmap</span>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading && <Loader />}
|
||||
{isChatHistoryLoading && (
|
||||
<Loader message="Loading chat history" />
|
||||
)}
|
||||
|
||||
{shouldShowChatPersona && !isLoading && (
|
||||
{shouldShowChatPersona && !isLoading && !isChatHistoryLoading && (
|
||||
<ChatPersona roadmapId={roadmapId} />
|
||||
)}
|
||||
|
||||
{!isLoading && !shouldShowChatPersona && (
|
||||
<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">
|
||||
<RoadmapAIChatCard
|
||||
role="assistant"
|
||||
jsx={
|
||||
<TutorIntroMessage roadmap={roadmapDetail?.json!} />
|
||||
}
|
||||
isIntro
|
||||
/>
|
||||
|
||||
{aiChatHistory.map(
|
||||
(chat: RoadmapAIChatHistoryType, index: number) => {
|
||||
return (
|
||||
<Fragment key={`chat-${index}`}>
|
||||
<RoadmapAIChatCard {...chat} />
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
)}
|
||||
|
||||
{isStreamingMessage && !streamedMessage && (
|
||||
{!isLoading &&
|
||||
!isChatHistoryLoading &&
|
||||
!shouldShowChatPersona && (
|
||||
<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">
|
||||
<RoadmapAIChatCard
|
||||
role="assistant"
|
||||
html="Thinking..."
|
||||
jsx={
|
||||
<TutorIntroMessage roadmap={roadmapDetail?.json!} />
|
||||
}
|
||||
isIntro
|
||||
/>
|
||||
)}
|
||||
|
||||
{streamedMessage && (
|
||||
<RoadmapAIChatCard
|
||||
role="assistant"
|
||||
jsx={streamedMessage}
|
||||
/>
|
||||
)}
|
||||
{aiChatHistory.map(
|
||||
(chat: RoadmapAIChatHistoryType, index: number) => {
|
||||
return (
|
||||
<Fragment key={`chat-${index}`}>
|
||||
<RoadmapAIChatCard {...chat} />
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
)}
|
||||
|
||||
{isStreamingMessage && !streamedMessage && (
|
||||
<RoadmapAIChatCard
|
||||
role="assistant"
|
||||
html="Thinking..."
|
||||
/>
|
||||
)}
|
||||
|
||||
{streamedMessage && (
|
||||
<RoadmapAIChatCard
|
||||
role="assistant"
|
||||
jsx={streamedMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isLoading && !shouldShowChatPersona && (
|
||||
{!isLoading && !isChatHistoryLoading && !shouldShowChatPersona && (
|
||||
<div className="flex flex-col border-t border-gray-200">
|
||||
{!isLimitExceeded && (
|
||||
<AIChatActionButtons
|
||||
@@ -396,6 +482,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
setShowUpdatePersonaModal(true);
|
||||
}}
|
||||
messageCount={aiChatHistory.length}
|
||||
showClearChat={!isPaidUser}
|
||||
onClearChat={clearChat}
|
||||
/>
|
||||
)}
|
||||
@@ -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 (
|
||||
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white p-1.5 px-3 text-sm text-gray-500">
|
||||
<Loader2Icon className="size-4 animate-spin stroke-[2.5]" />
|
||||
<span>{message ?? 'Loading Roadmap'}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -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() && (
|
||||
<div className="flex gap-1.5 pr-4">
|
||||
{isPaidUser && (
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-md bg-gray-200 px-2 py-1 text-xs text-black hover:bg-gray-300"
|
||||
onClick={onNewChat}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
New Chat
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isPaidUser && (
|
||||
<>
|
||||
<button
|
||||
className="hidden rounded-md bg-gray-200 px-2 py-1 text-sm hover:bg-gray-300 2xl:block"
|
||||
onClick={handleCreditsClick}
|
||||
>
|
||||
<span className="font-medium">{usagePercentage}%</span> limit
|
||||
used
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-md bg-yellow-400 px-2 py-1 text-sm text-black hover:bg-yellow-500"
|
||||
onClick={handleUpgradeClick}
|
||||
@@ -162,14 +177,21 @@ export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) {
|
||||
<GiftIcon className="size-4" />
|
||||
Upgrade
|
||||
</button>
|
||||
<button
|
||||
className="hidden items-center gap-1 rounded-md bg-gray-200 px-2 py-1 text-sm text-black hover:bg-gray-300 max-xl:flex"
|
||||
onClick={onCloseChat}
|
||||
>
|
||||
<XIcon className="size-3.5" strokeWidth={2.5} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<RoadmapAIChatHistory
|
||||
roadmapId={roadmapId}
|
||||
onChatHistoryClick={onChatHistoryClick}
|
||||
activeChatHistoryId={activeChatHistoryId}
|
||||
onDelete={onDeleteChatHistory}
|
||||
onUpgrade={onUpgrade}
|
||||
/>
|
||||
<button
|
||||
className="hidden items-center gap-1 rounded-md bg-gray-200 px-2 py-1 text-sm text-black hover:bg-gray-300 max-xl:flex"
|
||||
onClick={onCloseChat}
|
||||
>
|
||||
<XIcon className="size-3.5" strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
183
src/components/RoadmapAIChatHistory/RoadmapAIChatHistory.tsx
Normal file
183
src/components/RoadmapAIChatHistory/RoadmapAIChatHistory.tsx
Normal file
@@ -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 (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOpen(open);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger className="flex items-center justify-center gap-2 rounded-md bg-gray-200 px-3 py-1.5 text-xs text-gray-900 hover:bg-gray-300 hover:text-black">
|
||||
<HistoryIcon className="size-3.5" />
|
||||
{activeChatHistoryTitle || 'Chat History'}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="z-[999] flex max-h-[400px] w-80 flex-col overflow-hidden p-0 shadow-lg"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2Icon className="size-6 animate-spin stroke-[2.5] text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isPaidUser && (
|
||||
<UpgradeToProMessage
|
||||
className="mt-0 px-10 py-10"
|
||||
onUpgrade={() => {
|
||||
setIsOpen(false);
|
||||
onUpgrade?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && isPaidUser && (
|
||||
<>
|
||||
<SearchAIChatHistory
|
||||
onSearch={setQuery}
|
||||
isLoading={isLoadingInfiniteQuery}
|
||||
className="mt-0"
|
||||
inputClassName="border-x-0 border-t-0 border-b border-b-gray-200 rounded-none focus:border-b-gray-200"
|
||||
/>
|
||||
|
||||
<div className="scrollbar-track-transparent scrollbar-thin scrollbar-thumb-gray-300 grow space-y-4 overflow-y-auto p-2 pt-4">
|
||||
{isEmptyHistory && (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<p className="text-sm text-gray-500">No chat history</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.entries(groupedChatHistory ?? {}).map(([key, value]) => {
|
||||
if (value.histories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatHistoryGroup
|
||||
key={key}
|
||||
title={value.title}
|
||||
histories={value.histories}
|
||||
activeChatHistoryId={activeChatHistoryId}
|
||||
onChatHistoryClick={(id) => {
|
||||
setIsOpen(false);
|
||||
onChatHistoryClick(id);
|
||||
}}
|
||||
onDelete={(id) => {
|
||||
setIsOpen(false);
|
||||
onDelete?.(id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{hasNextPage && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-2 text-sm text-gray-500 hover:text-black"
|
||||
onClick={() => {
|
||||
fetchNextPage();
|
||||
}}
|
||||
disabled={isFetchingNextPage}
|
||||
>
|
||||
{isFetchingNextPage && (
|
||||
<>
|
||||
<Loader2Icon className="h-4 w-4 animate-spin" />
|
||||
Loading more...
|
||||
</>
|
||||
)}
|
||||
{!isFetchingNextPage && 'Load More'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
42
src/helper/grouping.ts
Normal file
42
src/helper/grouping.ts
Normal file
@@ -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[] }
|
||||
>,
|
||||
);
|
||||
}
|
@@ -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<string, MessagePartRenderer> {
|
||||
const { totalTopicCount, roadmapId, onSelectTopic } = options;
|
||||
|
||||
return {
|
||||
'user-progress': () => (
|
||||
<UserProgressList
|
||||
totalTopicCount={totalTopicCount}
|
||||
roadmapId={roadmapId}
|
||||
/>
|
||||
),
|
||||
'update-progress': (opts) => (
|
||||
<UserProgressActionList roadmapId={roadmapId} {...opts} />
|
||||
),
|
||||
'roadmap-topics': (opts) => (
|
||||
<RoadmapTopicList
|
||||
roadmapId={roadmapId}
|
||||
onTopicClick={(topicId, text) => {
|
||||
const title = text.split(' > ').pop();
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectTopic(topicId, title);
|
||||
}}
|
||||
{...opts}
|
||||
/>
|
||||
),
|
||||
'resource-progress-link': () => <ShareResourceLink roadmapId={roadmapId} />,
|
||||
'roadmap-recommendations': (opts) => <RoadmapRecommendations {...opts} />,
|
||||
};
|
||||
}
|
||||
|
||||
export type RoadmapAIChatHistoryType = {
|
||||
role: AllowedAIChatRole;
|
||||
@@ -27,14 +68,23 @@ export type RoadmapAIChatHistoryType = {
|
||||
};
|
||||
|
||||
type Options = {
|
||||
activeChatHistoryId?: string;
|
||||
roadmapId: string;
|
||||
totalTopicCount: number;
|
||||
scrollareaRef: React.RefObject<HTMLDivElement | null>;
|
||||
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<string, MessagePartRenderer> = useMemo(
|
||||
() => ({
|
||||
'user-progress': () => (
|
||||
<UserProgressList
|
||||
totalTopicCount={totalTopicCount}
|
||||
roadmapId={roadmapId}
|
||||
/>
|
||||
),
|
||||
'update-progress': (opts) => (
|
||||
<UserProgressActionList roadmapId={roadmapId} {...opts} />
|
||||
),
|
||||
'roadmap-topics': (opts) => (
|
||||
<RoadmapTopicList
|
||||
roadmapId={roadmapId}
|
||||
onTopicClick={(topicId, text) => {
|
||||
const title = text.split(' > ').pop();
|
||||
if (title) {
|
||||
onSelectTopic(topicId, title);
|
||||
}
|
||||
}}
|
||||
{...opts}
|
||||
/>
|
||||
),
|
||||
'resource-progress-link': () => (
|
||||
<ShareResourceLink roadmapId={roadmapId} />
|
||||
),
|
||||
'roadmap-recommendations': (opts) => <RoadmapRecommendations {...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 || []) {
|
||||
|
94
src/lib/chat.ts
Normal file
94
src/lib/chat.ts
Normal file
@@ -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<Uint8Array>,
|
||||
{
|
||||
onMessage,
|
||||
onMessageEnd,
|
||||
onDetails,
|
||||
}: {
|
||||
onMessage?: (message: string) => Promise<void>;
|
||||
onMessageEnd?: (message: string) => Promise<void>;
|
||||
onDetails?: (details: string) => Promise<void> | 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();
|
||||
}
|
@@ -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<string, string | undefined> {
|
||||
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;
|
||||
---
|
||||
|
||||
<SkeletonLayout
|
||||
title={`${roadmapBriefTitle} AI Mentor`}
|
||||
description=`Learn anything ${roadmapBriefTitle} with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.`
|
||||
canonicalUrl={canonicalUrl}
|
||||
>
|
||||
<AITutorLayout
|
||||
activeTab='chat'
|
||||
wrapperClassName='flex-row p-0 lg:p-0 overflow-hidden'
|
||||
client:load
|
||||
>
|
||||
<RoadmapAIChat roadmapId={roadmapId} client:load />
|
||||
<CheckSubscriptionVerification client:load />
|
||||
</AITutorLayout>
|
||||
</SkeletonLayout>
|
||||
|
18
src/pages/ai/chat/[chatId].astro
Normal file
18
src/pages/ai/chat/[chatId].astro
Normal file
@@ -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;
|
||||
---
|
||||
|
||||
<SkeletonLayout
|
||||
title='AI Chat'
|
||||
noIndex={true}
|
||||
description='Learn anything with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.'
|
||||
>
|
||||
<AIChatHistory client:load chatHistoryId={chatId} />
|
||||
</SkeletonLayout>
|
@@ -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;
|
||||
---
|
||||
|
||||
<SkeletonLayout
|
||||
title={`${roadmapBriefTitle} AI Mentor`}
|
||||
description=`Learn anything ${roadmapBriefTitle} with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.`
|
||||
canonicalUrl={canonicalUrl}
|
||||
>
|
||||
<AITutorLayout
|
||||
activeTab='chat'
|
||||
wrapperClassName='flex-row p-0 lg:p-0 overflow-hidden'
|
||||
client:load
|
||||
>
|
||||
<RoadmapAIChat roadmapId={roadmapId} client:load />
|
||||
<CheckSubscriptionVerification client:load />
|
||||
</AITutorLayout>
|
||||
</SkeletonLayout>
|
@@ -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';
|
||||
---
|
||||
|
||||
<SkeletonLayout
|
||||
@@ -10,13 +9,5 @@ import { CheckSubscriptionVerification } from '../../../components/Billing/Check
|
||||
noIndex={true}
|
||||
description='Learn anything with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.'
|
||||
>
|
||||
<AITutorLayout
|
||||
activeTab='chat'
|
||||
wrapperClassName='flex-row p-0 lg:p-0 overflow-hidden'
|
||||
client:load
|
||||
containerClassName='h-[calc(100vh-49px)] overflow-hidden'
|
||||
>
|
||||
<AIChat client:load />
|
||||
<CheckSubscriptionVerification client:load />
|
||||
</AITutorLayout>
|
||||
<AIChatHistory client:load />
|
||||
</SkeletonLayout>
|
||||
|
121
src/queries/chat-history.ts
Normal file
121
src/queries/chat-history.ts
Normal file
@@ -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<string, MessagePartRenderer>,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: ['chat-history-details', chatHistoryId],
|
||||
queryFn: async () => {
|
||||
const data = await httpGet<ChatHistoryDocument>(
|
||||
`/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<ChatHistoryDocument, 'messages'>;
|
||||
|
||||
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<ListChatHistoryResponse>('/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,
|
||||
});
|
||||
}
|
@@ -2,6 +2,7 @@
|
||||
@import '@roadmapsh/editor/style.css';
|
||||
|
||||
@config '../../tailwind.config.cjs';
|
||||
@plugin 'tailwind-scrollbar';
|
||||
|
||||
@font-face {
|
||||
font-family: 'Balsamiq Sans';
|
||||
|
Reference in New Issue
Block a user