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"],
|
"prettier.documentSelectors": ["**/*.astro"],
|
||||||
"[astro]": {
|
"[astro]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"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",
|
"@nanostores/react": "^1.0.0",
|
||||||
"@napi-rs/image": "^1.9.2",
|
"@napi-rs/image": "^1.9.2",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
|
"@radix-ui/react-popover": "^1.1.14",
|
||||||
"@resvg/resvg-js": "^2.6.2",
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"@roadmapsh/editor": "workspace:*",
|
"@roadmapsh/editor": "workspace:*",
|
||||||
"@tailwindcss/vite": "^4.1.7",
|
"@tailwindcss/vite": "^4.1.7",
|
||||||
@@ -119,6 +120,7 @@
|
|||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"prettier-plugin-astro": "^0.14.1",
|
"prettier-plugin-astro": "^0.14.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
|
"tailwind-scrollbar": "^4.0.2",
|
||||||
"tsx": "^4.19.4"
|
"tsx": "^4.19.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
66
pnpm-lock.yaml
generated
66
pnpm-lock.yaml
generated
@@ -32,6 +32,9 @@ importers:
|
|||||||
'@radix-ui/react-dropdown-menu':
|
'@radix-ui/react-dropdown-menu':
|
||||||
specifier: ^2.1.15
|
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)
|
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':
|
'@resvg/resvg-js':
|
||||||
specifier: ^2.6.2
|
specifier: ^2.6.2
|
||||||
version: 2.6.2
|
version: 2.6.2
|
||||||
@@ -267,6 +270,9 @@ importers:
|
|||||||
prettier-plugin-tailwindcss:
|
prettier-plugin-tailwindcss:
|
||||||
specifier: ^0.6.11
|
specifier: ^0.6.11
|
||||||
version: 0.6.11(prettier-plugin-astro@0.14.1)(prettier@3.5.3)
|
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:
|
tsx:
|
||||||
specifier: ^4.19.4
|
specifier: ^4.19.4
|
||||||
version: 4.19.4
|
version: 4.19.4
|
||||||
@@ -1151,6 +1157,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
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':
|
'@radix-ui/react-popper@1.2.7':
|
||||||
resolution: {integrity: sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==}
|
resolution: {integrity: sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3496,6 +3515,11 @@ packages:
|
|||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
hasBin: true
|
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:
|
prismjs@1.30.0:
|
||||||
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
|
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -3919,6 +3943,12 @@ packages:
|
|||||||
tailwind-merge@3.3.0:
|
tailwind-merge@3.3.0:
|
||||||
resolution: {integrity: sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==}
|
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:
|
tailwindcss@4.1.5:
|
||||||
resolution: {integrity: sha512-nYtSPfWGDiWgCkwQG/m+aX83XCwf62sBgg3bIlNiiOcggnS1x3uVRDAuyelBFL+vJdOPPCGElxv9DjHJjRHiVA==}
|
resolution: {integrity: sha512-nYtSPfWGDiWgCkwQG/m+aX83XCwf62sBgg3bIlNiiOcggnS1x3uVRDAuyelBFL+vJdOPPCGElxv9DjHJjRHiVA==}
|
||||||
|
|
||||||
@@ -5164,6 +5194,29 @@ snapshots:
|
|||||||
'@types/react': 19.1.4
|
'@types/react': 19.1.4
|
||||||
'@types/react-dom': 19.1.5(@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)':
|
'@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:
|
dependencies:
|
||||||
'@floating-ui/react-dom': 2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
'@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: {}
|
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: {}
|
prismjs@1.30.0: {}
|
||||||
|
|
||||||
prompts@2.4.2:
|
prompts@2.4.2:
|
||||||
@@ -8144,6 +8203,13 @@ snapshots:
|
|||||||
|
|
||||||
tailwind-merge@3.3.0: {}
|
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.5: {}
|
||||||
|
|
||||||
tailwindcss@4.1.7: {}
|
tailwindcss@4.1.7: {}
|
||||||
|
@@ -7,14 +7,7 @@ import {
|
|||||||
SendIcon,
|
SendIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
import AutogrowTextarea from 'react-textarea-autosize';
|
import AutogrowTextarea from 'react-textarea-autosize';
|
||||||
import { QuickHelpPrompts } from './QuickHelpPrompts';
|
import { QuickHelpPrompts } from './QuickHelpPrompts';
|
||||||
@@ -25,7 +18,6 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
|||||||
import { queryClient } from '../../stores/query-client';
|
import { queryClient } from '../../stores/query-client';
|
||||||
import { billingDetailsOptions } from '../../queries/billing';
|
import { billingDetailsOptions } from '../../queries/billing';
|
||||||
import { useToast } from '../../hooks/use-toast';
|
import { useToast } from '../../hooks/use-toast';
|
||||||
import { readStream } from '../../lib/ai';
|
|
||||||
import { markdownToHtml } from '../../lib/markdown';
|
import { markdownToHtml } from '../../lib/markdown';
|
||||||
import { ChatHistory } from './ChatHistory';
|
import { ChatHistory } from './ChatHistory';
|
||||||
import { PersonalizedResponseForm } from './PersonalizedResponseForm';
|
import { PersonalizedResponseForm } from './PersonalizedResponseForm';
|
||||||
@@ -38,31 +30,47 @@ import {
|
|||||||
type MessagePartRenderer,
|
type MessagePartRenderer,
|
||||||
} from '../../lib/render-chat-message';
|
} from '../../lib/render-chat-message';
|
||||||
import { RoadmapRecommendations } from '../RoadmapAIChat/RoadmapRecommendations';
|
import { RoadmapRecommendations } from '../RoadmapAIChat/RoadmapRecommendations';
|
||||||
import type { RoadmapAIChatHistoryType } from '../RoadmapAIChat/RoadmapAIChat';
|
|
||||||
import { AIChatCourse } from './AIChatCouse';
|
import { AIChatCourse } from './AIChatCouse';
|
||||||
import { getTailwindScreenDimension } from '../../lib/is-mobile';
|
|
||||||
import type { TailwindScreenDimensions } from '../../lib/is-mobile';
|
|
||||||
import { showLoginPopup } from '../../lib/popup';
|
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 toast = useToast();
|
||||||
|
|
||||||
const [deviceType, setDeviceType] = useState<TailwindScreenDimensions>();
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
setDeviceType(getTailwindScreenDimension());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
|
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
|
||||||
const [streamedMessage, setStreamedMessage] =
|
const [streamedMessage, setStreamedMessage] =
|
||||||
useState<React.ReactNode | null>(null);
|
useState<React.ReactNode | null>(null);
|
||||||
const [aiChatHistory, setAiChatHistory] = useState<
|
const [aiChatHistory, setAiChatHistory] = useState<
|
||||||
RoadmapAIChatHistoryType[]
|
RoadmapAIChatHistoryType[]
|
||||||
>([]);
|
>(defaultMessages ?? []);
|
||||||
|
|
||||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
|
||||||
const [isPersonalizedResponseFormOpen, setIsPersonalizedResponseFormOpen] =
|
const [isPersonalizedResponseFormOpen, setIsPersonalizedResponseFormOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [isUploadResumeModalOpen, setIsUploadResumeModalOpen] = useState(false);
|
const [isUploadResumeModalOpen, setIsUploadResumeModalOpen] = useState(false);
|
||||||
@@ -89,6 +97,34 @@ export function AIChat() {
|
|||||||
userResumeOptions(),
|
userResumeOptions(),
|
||||||
queryClient,
|
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 isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
|
||||||
const isPaidUser = userBillingDetails?.status === 'active';
|
const isPaidUser = userBillingDetails?.status === 'active';
|
||||||
@@ -101,7 +137,7 @@ export function AIChat() {
|
|||||||
|
|
||||||
if (isLimitExceeded) {
|
if (isLimitExceeded) {
|
||||||
if (!isPaidUser) {
|
if (!isPaidUser) {
|
||||||
setShowUpgradeModal(true);
|
onUpgrade?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error('Limit reached for today. Please wait until tomorrow.');
|
toast.error('Limit reached for today. Please wait until tomorrow.');
|
||||||
@@ -136,29 +172,39 @@ export function AIChat() {
|
|||||||
completeAIChat(newMessages);
|
completeAIChat(newMessages);
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
const canScrollToBottom = useCallback(() => {
|
||||||
const scrollableContainer = scrollableContainerRef?.current;
|
const scrollableContainer = scrollableContainerRef?.current;
|
||||||
if (!scrollableContainer) {
|
if (!scrollableContainer) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollableContainer.scrollTo({
|
const paddingBottom = parseInt(
|
||||||
top: scrollableContainer.scrollHeight,
|
getComputedStyle(scrollableContainer).paddingBottom,
|
||||||
behavior: 'smooth',
|
);
|
||||||
});
|
|
||||||
}, [scrollableContainerRef]);
|
|
||||||
|
|
||||||
const renderer: Record<string, MessagePartRenderer> = useMemo(() => {
|
const distanceFromBottom =
|
||||||
return {
|
scrollableContainer.scrollHeight -
|
||||||
'roadmap-recommendations': (options) => {
|
(scrollableContainer.scrollTop + scrollableContainer.clientHeight) -
|
||||||
return <RoadmapRecommendations {...options} />;
|
paddingBottom;
|
||||||
},
|
|
||||||
'generate-course': (options) => {
|
return distanceFromBottom > -(paddingBottom - 80);
|
||||||
return <AIChatCourse {...options} />;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
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 (
|
const completeAIChat = async (
|
||||||
messages: RoadmapAIChatHistoryType[],
|
messages: RoadmapAIChatHistoryType[],
|
||||||
force: boolean = false,
|
force: boolean = false,
|
||||||
@@ -172,6 +218,7 @@ export function AIChat() {
|
|||||||
},
|
},
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
chatHistoryId: defaultChatHistoryId,
|
||||||
messages: messages.slice(-10),
|
messages: messages.slice(-10),
|
||||||
force,
|
force,
|
||||||
}),
|
}),
|
||||||
@@ -190,28 +237,26 @@ export function AIChat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = response.body?.getReader();
|
const stream = response.body;
|
||||||
|
if (!stream) {
|
||||||
if (!reader) {
|
|
||||||
setIsStreamingMessage(false);
|
setIsStreamingMessage(false);
|
||||||
toast.error('Something went wrong');
|
toast.error('Something went wrong');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await readStream(reader, {
|
await readChatStream(stream, {
|
||||||
onStream: async (content) => {
|
onMessage: async (content) => {
|
||||||
const jsx = await renderMessage(content, renderer, {
|
const jsx = await renderMessage(content, aiChatRenderer, {
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
setStreamedMessage(jsx);
|
setStreamedMessage(jsx);
|
||||||
});
|
});
|
||||||
|
setShowScrollToBottomButton(canScrollToBottom());
|
||||||
scrollToBottom();
|
|
||||||
},
|
},
|
||||||
onStreamEnd: async (content) => {
|
onMessageEnd: async (content) => {
|
||||||
const jsx = await renderMessage(content, renderer, {
|
const jsx = await renderMessage(content, aiChatRenderer, {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -231,7 +276,20 @@ export function AIChat() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
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(() => {
|
timeoutId = setTimeout(() => {
|
||||||
const paddingBottom = parseInt(
|
setShowScrollToBottomButton(canScrollToBottom());
|
||||||
getComputedStyle(scrollableContainer).paddingBottom,
|
|
||||||
);
|
|
||||||
|
|
||||||
const distanceFromBottom =
|
|
||||||
scrollableContainer.scrollHeight -
|
|
||||||
// scroll from the top + the container height
|
|
||||||
(scrollableContainer.scrollTop + scrollableContainer.clientHeight) -
|
|
||||||
paddingBottom;
|
|
||||||
|
|
||||||
setShowScrollToBottomButton(distanceFromBottom > -(paddingBottom - 80));
|
|
||||||
}, 100);
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -303,7 +351,7 @@ export function AIChat() {
|
|||||||
(index: number) => {
|
(index: number) => {
|
||||||
if (isLimitExceeded) {
|
if (isLimitExceeded) {
|
||||||
if (!isPaidUser) {
|
if (!isPaidUser) {
|
||||||
setShowUpgradeModal(true);
|
onUpgrade?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error('Limit reached for today. Please wait until tomorrow.');
|
toast.error('Limit reached for today. Please wait until tomorrow.');
|
||||||
@@ -325,6 +373,7 @@ export function AIChat() {
|
|||||||
(index: number) => {
|
(index: number) => {
|
||||||
const filteredChatHistory = aiChatHistory.filter((_, i) => i !== index);
|
const filteredChatHistory = aiChatHistory.filter((_, i) => i !== index);
|
||||||
setAiChatHistory(filteredChatHistory);
|
setAiChatHistory(filteredChatHistory);
|
||||||
|
deleteChatMessage(filteredChatHistory);
|
||||||
},
|
},
|
||||||
[aiChatHistory],
|
[aiChatHistory],
|
||||||
);
|
);
|
||||||
@@ -337,29 +386,40 @@ export function AIChat() {
|
|||||||
isUserPersonaLoading ||
|
isUserPersonaLoading ||
|
||||||
isUserResumeLoading;
|
isUserResumeLoading;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom('instant');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const shouldShowUpgradeBanner = !isPaidUser && aiChatHistory.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="ai-chat relative flex grow flex-col gap-2 bg-gray-100">
|
||||||
className="ai-chat relative flex min-h-screen w-full flex-col gap-2 overflow-y-auto bg-gray-100 pb-55"
|
<div
|
||||||
ref={scrollableContainerRef}
|
className={cn(
|
||||||
>
|
'scrollbar-none absolute inset-0 overflow-y-auto pb-55',
|
||||||
<div className="relative mx-auto w-full max-w-3xl grow px-4">
|
shouldShowUpgradeBanner ? 'pb-60' : 'pb-55',
|
||||||
{shouldShowQuickHelpPrompts && (
|
|
||||||
<QuickHelpPrompts
|
|
||||||
onQuestionClick={(question) => {
|
|
||||||
textareaMessageRef.current?.focus();
|
|
||||||
setMessage(question);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!shouldShowQuickHelpPrompts && (
|
|
||||||
<ChatHistory
|
|
||||||
chatHistory={aiChatHistory}
|
|
||||||
isStreamingMessage={isStreamingMessage}
|
|
||||||
streamedMessage={streamedMessage}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
onRegenerate={handleRegenerate}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
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>
|
</div>
|
||||||
|
|
||||||
{isPersonalizedResponseFormOpen && (
|
{isPersonalizedResponseFormOpen && (
|
||||||
@@ -378,12 +438,8 @@ export function AIChat() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showUpgradeModal && (
|
|
||||||
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
<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}
|
ref={chatContainerRef}
|
||||||
>
|
>
|
||||||
<div className="mb-2 flex items-center justify-between gap-2">
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
@@ -392,6 +448,11 @@ export function AIChat() {
|
|||||||
icon={PersonStandingIcon}
|
icon={PersonStandingIcon}
|
||||||
label="Personalize"
|
label="Personalize"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
showLoginPopup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsPersonalizedResponseFormOpen(true);
|
setIsPersonalizedResponseFormOpen(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -399,6 +460,11 @@ export function AIChat() {
|
|||||||
icon={FileUpIcon}
|
icon={FileUpIcon}
|
||||||
label={isUploading ? 'Processing...' : 'Upload Resume'}
|
label={isUploading ? 'Processing...' : 'Upload Resume'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
showLoginPopup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsUploadResumeModalOpen(true);
|
setIsUploadResumeModalOpen(true);
|
||||||
}}
|
}}
|
||||||
isLoading={isUploading}
|
isLoading={isUploading}
|
||||||
@@ -413,12 +479,13 @@ export function AIChat() {
|
|||||||
onClick={scrollToBottom}
|
onClick={scrollToBottom}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{aiChatHistory.length > 0 && (
|
{aiChatHistory.length > 0 && !isPaidUser && (
|
||||||
<QuickActionButton
|
<QuickActionButton
|
||||||
icon={TrashIcon}
|
icon={TrashIcon}
|
||||||
label="Clear Chat"
|
label="Clear Chat"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAiChatHistory([]);
|
setAiChatHistory([]);
|
||||||
|
deleteChatMessage([]);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -470,7 +537,7 @@ export function AIChat() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowUpgradeModal(true);
|
onUpgrade?.();
|
||||||
}}
|
}}
|
||||||
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
|
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,
|
RotateCwIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useCopyText } from '../../hooks/use-copy-text';
|
import { useCopyText } from '../../hooks/use-copy-text';
|
||||||
import type { RoadmapAIChatHistoryType } from '../RoadmapAIChat/RoadmapAIChat';
|
|
||||||
import { Tooltip } from '../Tooltip';
|
import { Tooltip } from '../Tooltip';
|
||||||
|
import type { RoadmapAIChatHistoryType } from '../../hooks/use-roadmap-ai-chat';
|
||||||
|
|
||||||
type ChatHistoryProps = {
|
type ChatHistoryProps = {
|
||||||
chatHistory: RoadmapAIChatHistoryType[];
|
chatHistory: RoadmapAIChatHistoryType[];
|
||||||
|
@@ -57,6 +57,7 @@ export function QuickHelpPrompts(props: QuickHelpPromptsProps) {
|
|||||||
<div className="mt-6 flex flex-wrap items-center gap-2">
|
<div className="mt-6 flex flex-wrap items-center gap-2">
|
||||||
{quickActions.map((action, index) => (
|
{quickActions.map((action, index) => (
|
||||||
<button
|
<button
|
||||||
|
key={action.label}
|
||||||
className={cn(
|
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',
|
'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
|
selectedActionIndex === index
|
||||||
@@ -73,6 +74,7 @@ export function QuickHelpPrompts(props: QuickHelpPromptsProps) {
|
|||||||
<div className="mt-6 divide-y divide-gray-200">
|
<div className="mt-6 divide-y divide-gray-200">
|
||||||
{selectedAction.questions.map((question) => (
|
{selectedAction.questions.map((question) => (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
key={question}
|
key={question}
|
||||||
className="block w-full cursor-pointer p-2 text-left text-sm text-gray-500 hover:bg-gray-100 hover:text-black"
|
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)}
|
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',
|
'flex flex-grow flex-row lg:h-screen',
|
||||||
containerClassName,
|
containerClassName,
|
||||||
)}
|
)}
|
||||||
style={
|
|
||||||
{
|
|
||||||
'--ai-sidebar-width': '255px',
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<AITutorSidebar
|
<AITutorSidebar
|
||||||
onClose={() => setIsSidebarFloating(false)}
|
onClose={() => setIsSidebarFloating(false)}
|
||||||
|
@@ -94,7 +94,7 @@ export function AITutorSidebar(props: AITutorSidebarProps) {
|
|||||||
|
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
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
|
isFloating
|
||||||
? 'fixed top-0 bottom-0 left-0 z-50 flex border-r-0 bg-white shadow-xl'
|
? 'fixed top-0 bottom-0 left-0 z-50 flex border-r-0 bg-white shadow-xl'
|
||||||
: 'hidden lg:flex',
|
: 'hidden lg:flex',
|
||||||
|
@@ -3,9 +3,11 @@ import type { JSONContent } from '@tiptap/core';
|
|||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Loader2Icon,
|
||||||
MessageCirclePlus,
|
MessageCirclePlus,
|
||||||
PauseCircleIcon,
|
PauseCircleIcon,
|
||||||
PersonStanding,
|
PersonStanding,
|
||||||
|
Plus,
|
||||||
SendIcon,
|
SendIcon,
|
||||||
SquareArrowOutUpRight,
|
SquareArrowOutUpRight,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -16,23 +18,25 @@ import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
import { useKeydown } from '../../hooks/use-keydown';
|
import { useKeydown } from '../../hooks/use-keydown';
|
||||||
import {
|
import {
|
||||||
|
roadmapAIChatRenderer,
|
||||||
useRoadmapAIChat,
|
useRoadmapAIChat,
|
||||||
type RoadmapAIChatHistoryType,
|
|
||||||
} from '../../hooks/use-roadmap-ai-chat';
|
} from '../../hooks/use-roadmap-ai-chat';
|
||||||
import { cn } from '../../lib/classname';
|
import { cn } from '../../lib/classname';
|
||||||
import { lockBodyScroll } from '../../lib/dom';
|
import { lockBodyScroll } from '../../lib/dom';
|
||||||
|
import { isLoggedIn } from '../../lib/jwt';
|
||||||
|
import { showLoginPopup } from '../../lib/popup';
|
||||||
import { slugify } from '../../lib/slugger';
|
import { slugify } from '../../lib/slugger';
|
||||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||||
import { billingDetailsOptions } from '../../queries/billing';
|
import { billingDetailsOptions } from '../../queries/billing';
|
||||||
|
import { chatHistoryOptions } from '../../queries/chat-history';
|
||||||
import { roadmapJSONOptions } from '../../queries/roadmap';
|
import { roadmapJSONOptions } from '../../queries/roadmap';
|
||||||
import { roadmapQuestionsOptions } from '../../queries/roadmap-questions';
|
import { roadmapQuestionsOptions } from '../../queries/roadmap-questions';
|
||||||
import { queryClient } from '../../stores/query-client';
|
import { queryClient } from '../../stores/query-client';
|
||||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||||
import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard';
|
import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard';
|
||||||
|
import { RoadmapAIChatHistory } from '../RoadmapAIChatHistory/RoadmapAIChatHistory';
|
||||||
import { CLOSE_TOPIC_DETAIL_EVENT } from '../TopicDetail/TopicDetail';
|
import { CLOSE_TOPIC_DETAIL_EVENT } from '../TopicDetail/TopicDetail';
|
||||||
import { UpdatePersonaModal } from '../UserPersona/UpdatePersonaModal';
|
import { UpdatePersonaModal } from '../UserPersona/UpdatePersonaModal';
|
||||||
import { isLoggedIn } from '../../lib/jwt';
|
|
||||||
import { showLoginPopup } from '../../lib/popup';
|
|
||||||
|
|
||||||
type ChatHeaderButtonProps = {
|
type ChatHeaderButtonProps = {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
@@ -47,7 +51,7 @@ function ChatHeaderButton(props: ChatHeaderButtonProps) {
|
|||||||
const { onClick, href, icon, children, className, target } = props;
|
const { onClick, href, icon, children, className, target } = props;
|
||||||
|
|
||||||
const classNames = cn(
|
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,
|
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 {
|
const {
|
||||||
aiChatHistory,
|
aiChatHistory,
|
||||||
isStreamingMessage,
|
isStreamingMessage,
|
||||||
@@ -237,13 +257,39 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
|
|||||||
handleAbort,
|
handleAbort,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
clearChat,
|
clearChat,
|
||||||
|
setAiChatHistory,
|
||||||
} = useRoadmapAIChat({
|
} = useRoadmapAIChat({
|
||||||
|
activeChatHistoryId,
|
||||||
roadmapId,
|
roadmapId,
|
||||||
totalTopicCount,
|
totalTopicCount,
|
||||||
scrollareaRef,
|
scrollareaRef,
|
||||||
onSelectTopic,
|
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(() => {
|
useEffect(() => {
|
||||||
lockBodyScroll(isOpen);
|
lockBodyScroll(isOpen);
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
@@ -293,6 +339,7 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hasMessages = aiChatHistory.length > 0;
|
const hasMessages = aiChatHistory.length > 0;
|
||||||
|
const newTabUrl = `/${roadmapId}/ai${activeChatHistoryId ? `?chatId=${activeChatHistoryId}` : ''}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -330,32 +377,69 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
|
|||||||
>
|
>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<>
|
<>
|
||||||
<div className="flex h-full w-full flex-col overflow-hidden rounded-lg bg-white shadow-lg">
|
<div className="relative flex h-full w-full flex-col overflow-hidden rounded-lg bg-white shadow-lg">
|
||||||
{/* Messages area */}
|
{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 items-center justify-between px-3 py-2">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<ChatHeaderButton
|
<ChatHeaderButton
|
||||||
icon={<BookOpen className="h-3.5 w-3.5" />}
|
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>
|
</ChatHeaderButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1.5">
|
<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
|
<ChatHeaderButton
|
||||||
href={`/${roadmapId}/ai`}
|
href={newTabUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
icon={<SquareArrowOutUpRight className="h-3.5 w-3.5" />}
|
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"
|
className="hidden justify-center rounded-md bg-gray-200 px-1 py-1 text-gray-500 hover:bg-gray-300 sm:flex"
|
||||||
>
|
/>
|
||||||
Open in new tab
|
|
||||||
</ChatHeaderButton>
|
|
||||||
|
|
||||||
<ChatHeaderButton
|
<ChatHeaderButton
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
icon={<X className="h-3.5 w-3.5" />}
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -412,13 +496,11 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{aiChatHistory.map(
|
{aiChatHistory.map((chat, index) => (
|
||||||
(chat: RoadmapAIChatHistoryType, index: number) => (
|
<Fragment key={`chat-${index}`}>
|
||||||
<Fragment key={`chat-${index}`}>
|
<RoadmapAIChatCard {...chat} />
|
||||||
<RoadmapAIChatCard {...chat} />
|
</Fragment>
|
||||||
</Fragment>
|
))}
|
||||||
),
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isStreamingMessage && !streamedMessage && (
|
{isStreamingMessage && !streamedMessage && (
|
||||||
<RoadmapAIChatCard role="assistant" html="Thinking..." />
|
<RoadmapAIChatCard role="assistant" html="Thinking..." />
|
||||||
@@ -444,7 +526,6 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input area */}
|
|
||||||
{isLimitExceeded && (
|
{isLimitExceeded && (
|
||||||
<UpgradeMessage
|
<UpgradeMessage
|
||||||
onUpgradeClick={() => {
|
onUpgradeClick={() => {
|
||||||
@@ -482,7 +563,7 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hasMessages && (
|
{hasMessages && !isPaidUser && (
|
||||||
<ChatHeaderButton
|
<ChatHeaderButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setInputValue('');
|
setInputValue('');
|
||||||
@@ -550,7 +631,7 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
|
|||||||
{!isOpen && (
|
{!isOpen && (
|
||||||
<button
|
<button
|
||||||
className={cn(
|
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={() => {
|
onClick={() => {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
@@ -566,10 +647,10 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
|
|||||||
<span className="mr-1 text-sm font-semibold text-yellow-400">
|
<span className="mr-1 text-sm font-semibold text-yellow-400">
|
||||||
AI Tutor
|
AI Tutor
|
||||||
</span>
|
</span>
|
||||||
<span className={'text-white hidden sm:block'}>
|
<span className={'hidden text-white sm:block'}>
|
||||||
Have a question? Type here
|
Have a question? Type here
|
||||||
</span>
|
</span>
|
||||||
<span className={'text-white block sm:hidden'}>
|
<span className={'block text-white sm:hidden'}>
|
||||||
Ask anything
|
Ask anything
|
||||||
</span>
|
</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;
|
onTellUsAboutYourSelf: () => void;
|
||||||
onClearChat: () => void;
|
onClearChat: () => void;
|
||||||
messageCount: number;
|
messageCount: number;
|
||||||
|
showClearChat: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AIChatActionButtons(props: AIChatActionButtonsProps) {
|
export function AIChatActionButtons(props: AIChatActionButtonsProps) {
|
||||||
const { onTellUsAboutYourSelf, onClearChat, messageCount } = props;
|
const { onTellUsAboutYourSelf, onClearChat, messageCount, showClearChat } =
|
||||||
|
props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2 px-4 pt-2">
|
<div className="flex gap-2 px-4 pt-2">
|
||||||
@@ -36,7 +38,7 @@ export function AIChatActionButtons(props: AIChatActionButtonsProps) {
|
|||||||
label="Tell us about your self"
|
label="Tell us about your self"
|
||||||
onClick={onTellUsAboutYourSelf}
|
onClick={onTellUsAboutYourSelf}
|
||||||
/>
|
/>
|
||||||
{messageCount > 0 && (
|
{showClearChat && messageCount > 0 && (
|
||||||
<AIChatActionButton
|
<AIChatActionButton
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
label="Clear chat"
|
label="Clear chat"
|
||||||
|
@@ -1,8 +1,17 @@
|
|||||||
import './RoadmapAIChat.css';
|
import './RoadmapAIChat.css';
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { roadmapJSONOptions } from '../../queries/roadmap';
|
import type { Editor, JSONContent } from '@tiptap/core';
|
||||||
import { queryClient } from '../../stores/query-client';
|
import {
|
||||||
|
Bot,
|
||||||
|
Frown,
|
||||||
|
HistoryIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
LockIcon,
|
||||||
|
PauseCircleIcon,
|
||||||
|
SendIcon,
|
||||||
|
XIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Fragment,
|
Fragment,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -12,45 +21,41 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} 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 { 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 { useToast } from '../../hooks/use-toast';
|
||||||
import { userResourceProgressOptions } from '../../queries/resource-progress';
|
import { deleteUrlParam, getUrlParams } from '../../lib/browser';
|
||||||
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 { cn } from '../../lib/classname';
|
import { cn } from '../../lib/classname';
|
||||||
|
import { lockBodyScroll } from '../../lib/dom';
|
||||||
import {
|
import {
|
||||||
getTailwindScreenDimension,
|
getTailwindScreenDimension,
|
||||||
type TailwindScreenDimensions,
|
type TailwindScreenDimensions,
|
||||||
} from '../../lib/is-mobile';
|
} 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 { 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 { 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 { TutorIntroMessage } from './TutorIntroMessage';
|
||||||
import {
|
|
||||||
useRoadmapAIChat,
|
|
||||||
type RoadmapAIChatHistoryType,
|
|
||||||
} from '../../hooks/use-roadmap-ai-chat';
|
|
||||||
|
|
||||||
export type RoadmapAIChatTab = 'chat' | 'topic';
|
export type RoadmapAIChatTab = 'chat' | 'topic';
|
||||||
|
|
||||||
@@ -79,6 +84,9 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [activeTab, setActiveTab] = useState<RoadmapAIChatTab>('chat');
|
const [activeTab, setActiveTab] = useState<RoadmapAIChatTab>('chat');
|
||||||
|
const [activeChatHistoryId, setActiveChatHistoryId] = useState<
|
||||||
|
string | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
const [showUpdatePersonaModal, setShowUpdatePersonaModal] = useState(false);
|
const [showUpdatePersonaModal, setShowUpdatePersonaModal] = useState(false);
|
||||||
|
|
||||||
@@ -136,10 +144,19 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
|||||||
}, [roadmapDetail]);
|
}, [roadmapDetail]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const params = getUrlParams();
|
||||||
|
const queryChatId = params.chatId;
|
||||||
|
|
||||||
if (!roadmapTreeData || !roadmapDetail || isUserPersonaLoading) {
|
if (!roadmapTreeData || !roadmapDetail || isUserPersonaLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (queryChatId) {
|
||||||
|
setIsChatHistoryLoading(true);
|
||||||
|
setActiveChatHistoryId(queryChatId);
|
||||||
|
deleteUrlParam('chatId');
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}, [roadmapTreeData, roadmapDetail, isUserPersonaLoading]);
|
}, [roadmapTreeData, roadmapDetail, isUserPersonaLoading]);
|
||||||
|
|
||||||
@@ -170,6 +187,19 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
|||||||
[roadmapId, deviceType],
|
[roadmapId, deviceType],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [isChatHistoryLoading, setIsChatHistoryLoading] = useState(true);
|
||||||
|
const { data: chatHistory } = useQuery(
|
||||||
|
chatHistoryOptions(
|
||||||
|
activeChatHistoryId,
|
||||||
|
roadmapAIChatRenderer({
|
||||||
|
roadmapId,
|
||||||
|
totalTopicCount,
|
||||||
|
onSelectTopic,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
queryClient,
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
aiChatHistory,
|
aiChatHistory,
|
||||||
isStreamingMessage,
|
isStreamingMessage,
|
||||||
@@ -179,13 +209,39 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
|||||||
handleAbort,
|
handleAbort,
|
||||||
clearChat,
|
clearChat,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
|
setAiChatHistory,
|
||||||
} = useRoadmapAIChat({
|
} = useRoadmapAIChat({
|
||||||
|
activeChatHistoryId,
|
||||||
roadmapId,
|
roadmapId,
|
||||||
totalTopicCount,
|
totalTopicCount,
|
||||||
scrollareaRef,
|
scrollareaRef,
|
||||||
onSelectTopic,
|
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) {
|
if (roadmapDetailError) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-grow flex-col items-center justify-center">
|
<div className="flex flex-grow flex-col items-center justify-center">
|
||||||
@@ -307,6 +363,21 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
|||||||
setActiveTab('chat');
|
setActiveTab('chat');
|
||||||
}}
|
}}
|
||||||
selectedTopicId={selectedTopicId}
|
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 && (
|
{activeTab === 'topic' && selectedTopicId && (
|
||||||
@@ -333,62 +404,77 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
|||||||
|
|
||||||
{activeTab === 'chat' && (
|
{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}>
|
<div className="relative grow overflow-y-auto" ref={scrollareaRef}>
|
||||||
{isLoading && (
|
{isLoading && <Loader />}
|
||||||
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
|
{isChatHistoryLoading && (
|
||||||
<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">
|
<Loader message="Loading chat history" />
|
||||||
<Loader2Icon className="size-4 animate-spin stroke-[2.5]" />
|
|
||||||
<span>Loading Roadmap</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{shouldShowChatPersona && !isLoading && (
|
{shouldShowChatPersona && !isLoading && !isChatHistoryLoading && (
|
||||||
<ChatPersona roadmapId={roadmapId} />
|
<ChatPersona roadmapId={roadmapId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && !shouldShowChatPersona && (
|
{!isLoading &&
|
||||||
<div className="absolute inset-0 flex flex-col">
|
!isChatHistoryLoading &&
|
||||||
<div className="relative flex grow flex-col justify-end">
|
!shouldShowChatPersona && (
|
||||||
<div className="flex flex-col justify-end gap-2 px-3 py-2">
|
<div className="absolute inset-0 flex flex-col">
|
||||||
<RoadmapAIChatCard
|
<div className="relative flex grow flex-col justify-end">
|
||||||
role="assistant"
|
<div className="flex flex-col justify-end gap-2 px-3 py-2">
|
||||||
jsx={
|
|
||||||
<TutorIntroMessage roadmap={roadmapDetail?.json!} />
|
|
||||||
}
|
|
||||||
isIntro
|
|
||||||
/>
|
|
||||||
|
|
||||||
{aiChatHistory.map(
|
|
||||||
(chat: RoadmapAIChatHistoryType, index: number) => {
|
|
||||||
return (
|
|
||||||
<Fragment key={`chat-${index}`}>
|
|
||||||
<RoadmapAIChatCard {...chat} />
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isStreamingMessage && !streamedMessage && (
|
|
||||||
<RoadmapAIChatCard
|
<RoadmapAIChatCard
|
||||||
role="assistant"
|
role="assistant"
|
||||||
html="Thinking..."
|
jsx={
|
||||||
|
<TutorIntroMessage roadmap={roadmapDetail?.json!} />
|
||||||
|
}
|
||||||
|
isIntro
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{streamedMessage && (
|
{aiChatHistory.map(
|
||||||
<RoadmapAIChatCard
|
(chat: RoadmapAIChatHistoryType, index: number) => {
|
||||||
role="assistant"
|
return (
|
||||||
jsx={streamedMessage}
|
<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>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isLoading && !shouldShowChatPersona && (
|
{!isLoading && !isChatHistoryLoading && !shouldShowChatPersona && (
|
||||||
<div className="flex flex-col border-t border-gray-200">
|
<div className="flex flex-col border-t border-gray-200">
|
||||||
{!isLimitExceeded && (
|
{!isLimitExceeded && (
|
||||||
<AIChatActionButtons
|
<AIChatActionButtons
|
||||||
@@ -396,6 +482,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
|||||||
setShowUpdatePersonaModal(true);
|
setShowUpdatePersonaModal(true);
|
||||||
}}
|
}}
|
||||||
messageCount={aiChatHistory.length}
|
messageCount={aiChatHistory.length}
|
||||||
|
showClearChat={!isPaidUser}
|
||||||
onClearChat={clearChat}
|
onClearChat={clearChat}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -508,3 +595,20 @@ function isEmptyContent(content: JSONContent) {
|
|||||||
(!firstContent?.content || firstContent?.content?.length === 0)
|
(!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 { queryClient } from '../../stores/query-client';
|
||||||
import { billingDetailsOptions } from '../../queries/billing';
|
import { billingDetailsOptions } from '../../queries/billing';
|
||||||
import { isLoggedIn } from '../../lib/jwt';
|
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 type { RoadmapAIChatTab } from './RoadmapAIChat';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { getPercentage } from '../../lib/number';
|
import { getPercentage } from '../../lib/number';
|
||||||
import { AILimitsPopup } from '../GenerateCourse/AILimitsPopup';
|
import { AILimitsPopup } from '../GenerateCourse/AILimitsPopup';
|
||||||
import { cn } from '../../lib/classname';
|
import { cn } from '../../lib/classname';
|
||||||
import { useKeydown } from '../../hooks/use-keydown';
|
import { useKeydown } from '../../hooks/use-keydown';
|
||||||
|
import { RoadmapAIChatHistory } from '../RoadmapAIChatHistory/RoadmapAIChatHistory';
|
||||||
type RoadmapAIChatHeaderProps = {
|
|
||||||
isLoading: boolean;
|
|
||||||
|
|
||||||
onLogin: () => void;
|
|
||||||
onUpgrade: () => void;
|
|
||||||
|
|
||||||
onCloseChat: () => void;
|
|
||||||
|
|
||||||
activeTab: RoadmapAIChatTab;
|
|
||||||
onTabChange: (tab: RoadmapAIChatTab) => void;
|
|
||||||
onCloseTopic: () => void;
|
|
||||||
selectedTopicId: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TabButtonProps = {
|
type TabButtonProps = {
|
||||||
icon: React.ReactNode;
|
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) {
|
export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) {
|
||||||
const {
|
const {
|
||||||
onLogin,
|
onLogin,
|
||||||
@@ -76,6 +83,11 @@ export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) {
|
|||||||
onTabChange,
|
onTabChange,
|
||||||
onCloseTopic,
|
onCloseTopic,
|
||||||
selectedTopicId,
|
selectedTopicId,
|
||||||
|
roadmapId,
|
||||||
|
activeChatHistoryId,
|
||||||
|
onChatHistoryClick,
|
||||||
|
onNewChat,
|
||||||
|
onDeleteChatHistory,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
|
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
|
||||||
@@ -146,15 +158,18 @@ export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) {
|
|||||||
|
|
||||||
{!isDataLoading && isLoggedIn() && (
|
{!isDataLoading && isLoggedIn() && (
|
||||||
<div className="flex gap-1.5 pr-4">
|
<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 && (
|
{!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
|
<button
|
||||||
className="flex items-center gap-1 rounded-md bg-yellow-400 px-2 py-1 text-sm text-black hover:bg-yellow-500"
|
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}
|
onClick={handleUpgradeClick}
|
||||||
@@ -162,14 +177,21 @@ export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) {
|
|||||||
<GiftIcon className="size-4" />
|
<GiftIcon className="size-4" />
|
||||||
Upgrade
|
Upgrade
|
||||||
</button>
|
</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>
|
||||||
)}
|
)}
|
||||||
</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 { ShareResourceLink } from '../components/RoadmapAIChat/ShareResourceLink';
|
||||||
import { RoadmapRecommendations } from '../components/RoadmapAIChat/RoadmapRecommendations';
|
import { RoadmapRecommendations } from '../components/RoadmapAIChat/RoadmapRecommendations';
|
||||||
import type { AllowedAIChatRole } from '../components/GenerateCourse/AICourseLessonChat';
|
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 = {
|
export type RoadmapAIChatHistoryType = {
|
||||||
role: AllowedAIChatRole;
|
role: AllowedAIChatRole;
|
||||||
@@ -27,14 +68,23 @@ export type RoadmapAIChatHistoryType = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
|
activeChatHistoryId?: string;
|
||||||
roadmapId: string;
|
roadmapId: string;
|
||||||
totalTopicCount: number;
|
totalTopicCount: number;
|
||||||
scrollareaRef: React.RefObject<HTMLDivElement | null>;
|
scrollareaRef: React.RefObject<HTMLDivElement | null>;
|
||||||
onSelectTopic: (topicId: string, topicTitle: string) => void;
|
onSelectTopic: (topicId: string, topicTitle: string) => void;
|
||||||
|
onChatHistoryIdChange?: (chatHistoryId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useRoadmapAIChat(options: Options) {
|
export function useRoadmapAIChat(options: Options) {
|
||||||
const { roadmapId, totalTopicCount, scrollareaRef, onSelectTopic } = options;
|
const {
|
||||||
|
activeChatHistoryId,
|
||||||
|
roadmapId,
|
||||||
|
totalTopicCount,
|
||||||
|
scrollareaRef,
|
||||||
|
onSelectTopic,
|
||||||
|
onChatHistoryIdChange,
|
||||||
|
} = options;
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const [aiChatHistory, setAiChatHistory] = useState<
|
const [aiChatHistory, setAiChatHistory] = useState<
|
||||||
@@ -99,33 +149,7 @@ export function useRoadmapAIChat(options: Options) {
|
|||||||
}, [isStreamingMessage, streamedMessage, scrollToBottom]);
|
}, [isStreamingMessage, streamedMessage, scrollToBottom]);
|
||||||
|
|
||||||
const renderer: Record<string, MessagePartRenderer> = useMemo(
|
const renderer: Record<string, MessagePartRenderer> = useMemo(
|
||||||
() => ({
|
() => roadmapAIChatRenderer({ roadmapId, totalTopicCount, onSelectTopic }),
|
||||||
'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} />,
|
|
||||||
}),
|
|
||||||
[roadmapId, onSelectTopic, totalTopicCount],
|
[roadmapId, onSelectTopic, totalTopicCount],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -141,7 +165,13 @@ export function useRoadmapAIChat(options: Options) {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
signal: abortController?.signal,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = response.body?.getReader();
|
const stream = response.body;
|
||||||
if (!reader) {
|
if (!stream) {
|
||||||
setIsStreamingMessage(false);
|
setIsStreamingMessage(false);
|
||||||
toast.error('Something went wrong');
|
toast.error('Something went wrong');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await readStream(reader, {
|
await readChatStream(stream, {
|
||||||
onStream: async (content) => {
|
onMessage: async (content) => {
|
||||||
if (abortController?.signal.aborted) return;
|
if (abortController?.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const jsx = await renderMessage(content, renderer, {
|
const jsx = await renderMessage(content, renderer, {
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
});
|
});
|
||||||
flushSync(() => setStreamedMessage(jsx));
|
flushSync(() => {
|
||||||
|
setStreamedMessage(jsx);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onStreamEnd: async (content) => {
|
onMessageEnd: async (content) => {
|
||||||
if (abortController?.signal.aborted) return;
|
if (abortController?.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const jsx = await renderMessage(content, renderer, {
|
const jsx = await renderMessage(content, renderer, {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
@@ -188,6 +226,24 @@ export function useRoadmapAIChat(options: Options) {
|
|||||||
setAiChatHistory(newMessages);
|
setAiChatHistory(newMessages);
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
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,
|
handleAbort,
|
||||||
clearChat,
|
clearChat,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
|
setAiChatHistory,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function htmlFromTiptapJSON(json: JSONContent): string {
|
export function htmlFromTiptapJSON(json: JSONContent): string {
|
||||||
const content = json.content;
|
const content = json.content;
|
||||||
let text = '';
|
let text = '';
|
||||||
for (const child of content || []) {
|
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() {
|
export async function getStaticPaths() {
|
||||||
const roadmapIds = await getRoadmapIds();
|
const roadmapIds = await getRoadmapIds();
|
||||||
@@ -11,19 +19,25 @@ export async function getStaticPaths() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Params extends Record<string, string | undefined> {
|
const { roadmapId } = Astro.params as Props;
|
||||||
roadmapId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { roadmapId } = Astro.params as Params;
|
const roadmapDetail = await getRoadmapById(roadmapId);
|
||||||
const roadmapFile = await import(
|
|
||||||
`../../data/roadmaps/${roadmapId}/${roadmapId}.md`
|
|
||||||
);
|
|
||||||
|
|
||||||
const roadmapData = roadmapFile.frontmatter as RoadmapFrontmatter;
|
const canonicalUrl = `https://roadmap.sh/${roadmapId}/ai`;
|
||||||
if (roadmapData.renderer !== 'editor') {
|
const roadmapBriefTitle = roadmapDetail.frontmatter.briefTitle;
|
||||||
return Astro.rewrite(`/404`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Astro.rewrite(`/ai/chat/${roadmapId}`);
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<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 SkeletonLayout from '../../../layouts/SkeletonLayout.astro';
|
||||||
import { AIChat } from '../../../components/AIChat/AIChat';
|
import { AIChatLayout } from '../../../components/AIChatHistory/AIChatLayout';
|
||||||
import { AITutorLayout } from '../../../components/AITutor/AITutorLayout';
|
import { AIChatHistory } from '../../../components/AIChatHistory/AIChatHistory';
|
||||||
import { CheckSubscriptionVerification } from '../../../components/Billing/CheckSubscriptionVerification';
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<SkeletonLayout
|
<SkeletonLayout
|
||||||
@@ -10,13 +9,5 @@ import { CheckSubscriptionVerification } from '../../../components/Billing/Check
|
|||||||
noIndex={true}
|
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.'
|
description='Learn anything with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.'
|
||||||
>
|
>
|
||||||
<AITutorLayout
|
<AIChatHistory client:load />
|
||||||
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>
|
|
||||||
</SkeletonLayout>
|
</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';
|
@import '@roadmapsh/editor/style.css';
|
||||||
|
|
||||||
@config '../../tailwind.config.cjs';
|
@config '../../tailwind.config.cjs';
|
||||||
|
@plugin 'tailwind-scrollbar';
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Balsamiq Sans';
|
font-family: 'Balsamiq Sans';
|
||||||
|
Reference in New Issue
Block a user