Compare commits

...

30 Commits

Author SHA1 Message Date
Arik Chakma
9414b23b68 feat: show chat history always 2025-06-12 00:34:11 +06:00
Kamran Ahmed
c1415e55b7 Update 2025-06-11 18:20:48 +01:00
Kamran Ahmed
aeb184cd05 Fix ai chat not working 2025-06-11 15:58:07 +01:00
Kamran Ahmed
7ed5d90c13 Update chat history 2025-06-11 15:54:28 +01:00
Kamran Ahmed
0ff370c684 Update chat history 2025-06-11 15:54:11 +01:00
Kamran Ahmed
8cf0a7b927 Update chat history 2025-06-11 15:20:48 +01:00
Kamran Ahmed
4a396ce3db Merge branch 'master' of github.com:kamranahmedse/developer-roadmap into feat/chat-history 2025-06-11 14:45:28 +01:00
Kamran Ahmed
ab888e8f73 Chat history UI 2025-06-11 14:45:20 +01:00
Arik Chakma
b177b0a6e4 fix: responsiveness 2025-06-11 01:31:15 +06:00
Arik Chakma
06fe7083a1 wip 2025-06-11 01:19:49 +06:00
Arik Chakma
ca96e11efe wip 2025-06-11 01:15:49 +06:00
Arik Chakma
ba0c60696a fix: chat history 2025-06-11 00:16:43 +06:00
Arik Chakma
5394ecf518 wip 2025-06-10 13:25:27 +06:00
Arik Chakma
44bb6f04b4 wip 2025-06-10 13:24:08 +06:00
Arik Chakma
b574f1ebf5 wip 2025-06-10 13:21:12 +06:00
Arik Chakma
278ff39c91 wip 2025-06-10 12:39:21 +06:00
Arik Chakma
a054982f63 wip 2025-06-10 12:25:42 +06:00
Arik Chakma
f93f8cb6ba wip 2025-06-10 12:19:47 +06:00
Arik Chakma
9ed6f00aba wip 2025-06-10 11:55:13 +06:00
Arik Chakma
71c23fca5c wip 2025-06-10 11:39:10 +06:00
Arik Chakma
4a9a8f6e91 wip: skeleton loading 2025-06-10 11:29:28 +06:00
Arik Chakma
d19298a457 wip 2025-06-10 11:17:14 +06:00
Arik Chakma
e84e5934c4 wip 2025-06-10 00:11:17 +06:00
Arik Chakma
6d052ff446 wip 2025-06-09 23:38:02 +06:00
Arik Chakma
1f8850878d wip 2025-06-09 21:56:36 +06:00
Arik Chakma
799f6eebbb wip 2025-06-09 10:57:01 +06:00
Arik Chakma
6a413cdaef wip 2025-06-05 14:42:20 +06:00
Arik Chakma
cfb06adc6a wip 2025-06-05 14:23:22 +06:00
Arik Chakma
c3160bdf3c wip 2025-06-05 12:30:39 +06:00
Arik Chakma
c55e037e86 feat: ai chat history 2025-06-05 11:50:39 +06:00
20 changed files with 1129 additions and 152 deletions

View File

@@ -119,6 +119,7 @@
"prettier": "^3.5.3",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwind-scrollbar": "^4.0.2",
"tsx": "^4.19.4"
}
}

27
pnpm-lock.yaml generated
View File

@@ -267,6 +267,9 @@ importers:
prettier-plugin-tailwindcss:
specifier: ^0.6.11
version: 0.6.11(prettier-plugin-astro@0.14.1)(prettier@3.5.3)
tailwind-scrollbar:
specifier: ^4.0.2
version: 4.0.2(react@19.1.0)(tailwindcss@4.1.7)
tsx:
specifier: ^4.19.4
version: 4.19.4
@@ -3496,6 +3499,11 @@ packages:
engines: {node: '>=14'}
hasBin: true
prism-react-renderer@2.4.1:
resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==}
peerDependencies:
react: '>=16.0.0'
prismjs@1.30.0:
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
engines: {node: '>=6'}
@@ -3919,6 +3927,12 @@ packages:
tailwind-merge@3.3.0:
resolution: {integrity: sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==}
tailwind-scrollbar@4.0.2:
resolution: {integrity: sha512-wAQiIxAPqk0MNTPptVe/xoyWi27y+NRGnTwvn4PQnbvB9kp8QUBiGl/wsfoVBHnQxTmhXJSNt9NHTmcz9EivFA==}
engines: {node: '>=12.13.0'}
peerDependencies:
tailwindcss: 4.x
tailwindcss@4.1.5:
resolution: {integrity: sha512-nYtSPfWGDiWgCkwQG/m+aX83XCwf62sBgg3bIlNiiOcggnS1x3uVRDAuyelBFL+vJdOPPCGElxv9DjHJjRHiVA==}
@@ -7548,6 +7562,12 @@ snapshots:
prettier@3.5.3: {}
prism-react-renderer@2.4.1(react@19.1.0):
dependencies:
'@types/prismjs': 1.26.5
clsx: 2.1.1
react: 19.1.0
prismjs@1.30.0: {}
prompts@2.4.2:
@@ -8144,6 +8164,13 @@ snapshots:
tailwind-merge@3.3.0: {}
tailwind-scrollbar@4.0.2(react@19.1.0)(tailwindcss@4.1.7):
dependencies:
prism-react-renderer: 2.4.1(react@19.1.0)
tailwindcss: 4.1.7
transitivePeerDependencies:
- react
tailwindcss@4.1.5: {}
tailwindcss@4.1.7: {}

View File

@@ -7,14 +7,7 @@ import {
SendIcon,
TrashIcon,
} from 'lucide-react';
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import AutogrowTextarea from 'react-textarea-autosize';
import { QuickHelpPrompts } from './QuickHelpPrompts';
@@ -25,7 +18,6 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client';
import { billingDetailsOptions } from '../../queries/billing';
import { useToast } from '../../hooks/use-toast';
import { readStream } from '../../lib/ai';
import { markdownToHtml } from '../../lib/markdown';
import { ChatHistory } from './ChatHistory';
import { PersonalizedResponseForm } from './PersonalizedResponseForm';
@@ -38,31 +30,47 @@ import {
type MessagePartRenderer,
} from '../../lib/render-chat-message';
import { RoadmapRecommendations } from '../RoadmapAIChat/RoadmapRecommendations';
import type { RoadmapAIChatHistoryType } from '../RoadmapAIChat/RoadmapAIChat';
import type { RoadmapAIChatHistoryType } from '../../hooks/use-roadmap-ai-chat';
import { AIChatCourse } from './AIChatCouse';
import { getTailwindScreenDimension } from '../../lib/is-mobile';
import type { TailwindScreenDimensions } from '../../lib/is-mobile';
import { showLoginPopup } from '../../lib/popup';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { readChatStream } from '../../lib/chat';
import { chatHistoryOptions } from '../../queries/chat-history';
import { cn } from '../../lib/classname';
export const aiChatRenderer: Record<string, MessagePartRenderer> = {
'roadmap-recommendations': (options) => {
return <RoadmapRecommendations {...options} />;
},
'generate-course': (options) => {
return <AIChatCourse {...options} />;
},
};
type AIChatProps = {
messages?: RoadmapAIChatHistoryType[];
chatHistoryId?: string;
setChatHistoryId?: (chatHistoryId: string) => void;
onUpgrade?: () => void;
};
export function AIChat(props: AIChatProps) {
const {
messages: defaultMessages,
chatHistoryId: defaultChatHistoryId,
setChatHistoryId: setDefaultChatHistoryId,
onUpgrade,
} = props;
export function AIChat() {
const toast = useToast();
const [deviceType, setDeviceType] = useState<TailwindScreenDimensions>();
useLayoutEffect(() => {
setDeviceType(getTailwindScreenDimension());
}, []);
const [message, setMessage] = useState('');
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
const [streamedMessage, setStreamedMessage] =
useState<React.ReactNode | null>(null);
const [aiChatHistory, setAiChatHistory] = useState<
RoadmapAIChatHistoryType[]
>([]);
>(defaultMessages ?? []);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [isPersonalizedResponseFormOpen, setIsPersonalizedResponseFormOpen] =
useState(false);
const [isUploadResumeModalOpen, setIsUploadResumeModalOpen] = useState(false);
@@ -89,6 +97,25 @@ export function AIChat() {
userResumeOptions(),
queryClient,
);
const { mutate: deleteChatMessage, isPending: isDeletingChatMessage } =
useMutation(
{
mutationFn: (messages: RoadmapAIChatHistoryType[]) => {
return httpPost(`/v1-delete-chat-message/${defaultChatHistoryId}`, {
messages,
});
},
onSuccess: () => {
queryClient.invalidateQueries(
chatHistoryOptions(defaultChatHistoryId),
);
},
onError: (error) => {
toast.error(error?.message || 'Failed to delete message');
},
},
queryClient,
);
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
const isPaidUser = userBillingDetails?.status === 'active';
@@ -101,7 +128,7 @@ export function AIChat() {
if (isLimitExceeded) {
if (!isPaidUser) {
setShowUpgradeModal(true);
onUpgrade?.();
}
toast.error('Limit reached for today. Please wait until tomorrow.');
@@ -136,29 +163,39 @@ export function AIChat() {
completeAIChat(newMessages);
};
const scrollToBottom = useCallback(() => {
const canScrollToBottom = useCallback(() => {
const scrollableContainer = scrollableContainerRef?.current;
if (!scrollableContainer) {
return;
return false;
}
scrollableContainer.scrollTo({
top: scrollableContainer.scrollHeight,
behavior: 'smooth',
});
}, [scrollableContainerRef]);
const paddingBottom = parseInt(
getComputedStyle(scrollableContainer).paddingBottom,
);
const renderer: Record<string, MessagePartRenderer> = useMemo(() => {
return {
'roadmap-recommendations': (options) => {
return <RoadmapRecommendations {...options} />;
},
'generate-course': (options) => {
return <AIChatCourse {...options} />;
},
};
const distanceFromBottom =
scrollableContainer.scrollHeight -
(scrollableContainer.scrollTop + scrollableContainer.clientHeight) -
paddingBottom;
return distanceFromBottom > -(paddingBottom - 80);
}, []);
const scrollToBottom = useCallback(
(behavior: 'instant' | 'smooth' = 'smooth') => {
const scrollableContainer = scrollableContainerRef?.current;
if (!scrollableContainer) {
return;
}
scrollableContainer.scrollTo({
top: scrollableContainer.scrollHeight,
behavior: behavior === 'instant' ? 'instant' : 'smooth',
});
},
[scrollableContainerRef],
);
const completeAIChat = async (
messages: RoadmapAIChatHistoryType[],
force: boolean = false,
@@ -172,6 +209,7 @@ export function AIChat() {
},
credentials: 'include',
body: JSON.stringify({
chatHistoryId: defaultChatHistoryId,
messages: messages.slice(-10),
force,
}),
@@ -198,20 +236,19 @@ export function AIChat() {
return;
}
await readStream(reader, {
onStream: async (content) => {
const jsx = await renderMessage(content, renderer, {
await readChatStream(reader, {
onMessage: async (content) => {
const jsx = await renderMessage(content, aiChatRenderer, {
isLoading: true,
});
flushSync(() => {
setStreamedMessage(jsx);
});
scrollToBottom();
setShowScrollToBottomButton(canScrollToBottom());
},
onStreamEnd: async (content) => {
const jsx = await renderMessage(content, renderer, {
onMessageEnd: async (content) => {
const jsx = await renderMessage(content, aiChatRenderer, {
isLoading: false,
});
@@ -231,7 +268,20 @@ export function AIChat() {
});
queryClient.invalidateQueries(getAiCourseLimitOptions());
scrollToBottom();
queryClient.invalidateQueries({
predicate: (query) => {
return query.queryKey[0] === 'list-chat-history';
},
});
},
onDetails: (details) => {
const detailsJson = JSON.parse(details);
const chatHistoryId = detailsJson?.chatHistoryId;
if (!chatHistoryId) {
return;
}
setDefaultChatHistoryId?.(chatHistoryId);
},
});
@@ -272,17 +322,7 @@ export function AIChat() {
}
timeoutId = setTimeout(() => {
const paddingBottom = parseInt(
getComputedStyle(scrollableContainer).paddingBottom,
);
const distanceFromBottom =
scrollableContainer.scrollHeight -
// scroll from the top + the container height
(scrollableContainer.scrollTop + scrollableContainer.clientHeight) -
paddingBottom;
setShowScrollToBottomButton(distanceFromBottom > -(paddingBottom - 80));
setShowScrollToBottomButton(canScrollToBottom());
}, 100);
};
@@ -303,7 +343,7 @@ export function AIChat() {
(index: number) => {
if (isLimitExceeded) {
if (!isPaidUser) {
setShowUpgradeModal(true);
onUpgrade?.();
}
toast.error('Limit reached for today. Please wait until tomorrow.');
@@ -325,6 +365,7 @@ export function AIChat() {
(index: number) => {
const filteredChatHistory = aiChatHistory.filter((_, i) => i !== index);
setAiChatHistory(filteredChatHistory);
deleteChatMessage(filteredChatHistory);
},
[aiChatHistory],
);
@@ -337,29 +378,40 @@ export function AIChat() {
isUserPersonaLoading ||
isUserResumeLoading;
useEffect(() => {
scrollToBottom('instant');
}, []);
const shouldShowUpgradeBanner = !isPaidUser && aiChatHistory.length > 0;
return (
<div
className="ai-chat relative flex min-h-screen w-full flex-col gap-2 overflow-y-auto bg-gray-100 pb-55"
ref={scrollableContainerRef}
>
<div className="relative mx-auto w-full max-w-3xl grow px-4">
{shouldShowQuickHelpPrompts && (
<QuickHelpPrompts
onQuestionClick={(question) => {
textareaMessageRef.current?.focus();
setMessage(question);
}}
/>
)}
{!shouldShowQuickHelpPrompts && (
<ChatHistory
chatHistory={aiChatHistory}
isStreamingMessage={isStreamingMessage}
streamedMessage={streamedMessage}
onDelete={handleDelete}
onRegenerate={handleRegenerate}
/>
<div className="ai-chat relative flex grow flex-col gap-2 bg-gray-100">
<div
className={cn(
'scrollbar-none absolute inset-0 overflow-y-auto pb-55',
shouldShowUpgradeBanner ? 'pb-60' : 'pb-55',
)}
ref={scrollableContainerRef}
>
<div className="relative mx-auto w-full max-w-3xl grow px-4">
{shouldShowQuickHelpPrompts && (
<QuickHelpPrompts
onQuestionClick={(question) => {
textareaMessageRef.current?.focus();
setMessage(question);
}}
/>
)}
{!shouldShowQuickHelpPrompts && (
<ChatHistory
chatHistory={aiChatHistory}
isStreamingMessage={isStreamingMessage}
streamedMessage={streamedMessage}
onDelete={handleDelete}
onRegenerate={handleRegenerate}
/>
)}
</div>
</div>
{isPersonalizedResponseFormOpen && (
@@ -378,14 +430,28 @@ export function AIChat() {
/>
)}
{showUpgradeModal && (
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
)}
<div
className="pointer-events-none fixed right-0 bottom-0 left-0 mx-auto w-full max-w-3xl px-4 lg:left-[var(--ai-sidebar-width)]"
className="pointer-events-none absolute right-0 bottom-0 left-0 mx-auto w-full max-w-3xl px-4"
ref={chatContainerRef}
>
{shouldShowUpgradeBanner && (
<div className="mb-2 rounded-lg border border-yellow-200 bg-yellow-50 p-3 text-sm text-yellow-800">
<div className="pointer-events-auto flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<LockIcon className="size-4" strokeWidth={2.5} />
<p>Your chat history is not saved.</p>
</div>
<button
type="button"
onClick={() => onUpgrade?.()}
className="shrink-0 cursor-pointer rounded-md bg-yellow-200 px-2 py-1 text-xs font-medium text-yellow-800 hover:bg-yellow-200"
>
Upgrade to Pro
</button>
</div>
</div>
)}
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<QuickActionButton
@@ -413,12 +479,13 @@ export function AIChat() {
onClick={scrollToBottom}
/>
)}
{aiChatHistory.length > 0 && (
{aiChatHistory.length > 0 && !isPaidUser && (
<QuickActionButton
icon={TrashIcon}
label="Clear Chat"
onClick={() => {
setAiChatHistory([]);
deleteChatMessage([]);
}}
/>
)}
@@ -470,7 +537,7 @@ export function AIChat() {
<button
type="button"
onClick={() => {
setShowUpgradeModal(true);
onUpgrade?.();
}}
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
>

View File

@@ -57,6 +57,7 @@ export function QuickHelpPrompts(props: QuickHelpPromptsProps) {
<div className="mt-6 flex flex-wrap items-center gap-2">
{quickActions.map((action, index) => (
<button
key={action.label}
className={cn(
'pointer-events-auto flex shrink-0 cursor-pointer items-center gap-2 rounded-lg border bg-white px-2 py-1.5 text-sm hover:bg-gray-100 hover:text-black',
selectedActionIndex === index
@@ -73,6 +74,7 @@ export function QuickHelpPrompts(props: QuickHelpPromptsProps) {
<div className="mt-6 divide-y divide-gray-200">
{selectedAction.questions.map((question) => (
<button
type="button"
key={question}
className="block w-full cursor-pointer p-2 text-left text-sm text-gray-500 hover:bg-gray-100 hover:text-black"
onClick={() => onQuestionClick(question)}

View 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 } 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),
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>
);
}

View 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>
);
}

View 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">
{!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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,329 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import {
listChatHistoryOptions,
type ChatHistoryWithoutMessages,
} from '../../queries/chat-history';
import { queryClient } from '../../stores/query-client';
import { ChatHistoryItem } from './ChatHistoryItem';
import {
Loader2Icon,
LockIcon,
PanelLeftCloseIcon,
PanelLeftIcon,
PlusIcon,
SearchIcon,
XIcon,
} from 'lucide-react';
import { DateTime } from 'luxon';
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { useDebounceValue } from '../../hooks/use-debounce';
import { ListChatHistorySkeleton } from './ListChatHistorySkeleton';
import { ChatHistoryError } from './ChatHistoryError';
import { cn } from '../../lib/classname';
import { getTailwindScreenDimension } from '../../lib/is-mobile';
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);
setIsOpen(!isMediumSize);
setIsMobile(isMediumSize);
}, []);
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 today = DateTime.now().startOf('day');
const allHistories = data?.pages?.flatMap((page) => page.data);
return allHistories?.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[] }
>,
);
}, [data?.pages]);
if (!isOpen) {
return (
<div className="absolute top-2 left-2 z-20">
<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>
</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 (
<div className={cn(classNames, 'relative')}>
<div className="absolute top-2 right-2">{closeButton}</div>
<div className="flex grow flex-col items-center justify-center">
<LockIcon className="size-8 text-gray-500" />
<p className="mt-4 text-center text-sm text-balance text-gray-500">
Upgrade to Pro to keep your chat history.
</p>
<button
type="button"
className="mt-2 shrink-0 cursor-pointer rounded-md bg-yellow-200 px-2.5 py-1.5 text-sm font-medium text-yellow-800 hover:bg-yellow-200"
onClick={() => {
onUpgrade?.();
}}
>
Upgrade to Pro
</button>
</div>
</div>
);
}
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>
<SearchInput
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 (
<div key={key}>
<h2 className="ml-2 text-xs text-gray-500">{value.title}</h2>
<ul className="mt-1 space-y-0.5">
{value.histories.map((chatHistory) => {
return (
<ChatHistoryItem
key={chatHistory._id}
chatHistory={chatHistory}
isActive={activeChatHistoryId === chatHistory._id}
onChatHistoryClick={(id) => {
if (isMobile) {
setIsOpen(false);
}
onChatHistoryClick(id);
}}
onDelete={() => {
onDelete?.(chatHistory._id);
}}
/>
);
})}
</ul>
</div>
);
})}
{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 SearchInputProps = {
onSearch: (search: string) => void;
isLoading?: boolean;
};
function SearchInput(props: SearchInputProps) {
const { onSearch, isLoading } = props;
const [search, setSearch] = useState('');
const debouncedSearch = useDebounceValue(search, 300);
useEffect(() => {
onSearch(debouncedSearch);
}, [debouncedSearch, onSearch]);
return (
<form
className="relative mt-2 flex grow items-center"
onSubmit={(e) => {
e.preventDefault();
onSearch(search);
}}
>
<input
type="text"
placeholder="Search folder by name"
className="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"
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>
);
}

View File

@@ -0,0 +1,38 @@
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 className="absolute top-1/2 left-2.5 -translate-y-1/2">
<div className="h-4 w-4 animate-pulse rounded-full bg-gray-300" />
</div>
</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>
</>
);
}

View File

@@ -35,11 +35,6 @@ export function AITutorLayout(props: AITutorLayoutProps) {
'flex flex-grow flex-row lg:h-screen',
containerClassName,
)}
style={
{
'--ai-sidebar-width': '255px',
} as React.CSSProperties
}
>
<AITutorSidebar
onClose={() => setIsSidebarFloating(false)}

View File

@@ -94,7 +94,7 @@ export function AITutorSidebar(props: AITutorSidebarProps) {
<aside
className={cn(
'flex w-[var(--ai-sidebar-width)] shrink-0 flex-col border-r border-slate-200',
'flex w-[255px] shrink-0 flex-col border-r border-slate-200',
isFloating
? 'fixed top-0 bottom-0 left-0 z-50 flex border-r-0 bg-white shadow-xl'
: 'hidden lg:flex',

56
src/lib/chat.ts Normal file
View File

@@ -0,0 +1,56 @@
export const CHAT_RESPONSE_PREFIX = {
message: '0:',
details: 'd:',
} as const;
export async function readChatStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
{
onMessage,
onMessageEnd,
onDetails,
}: {
onMessage?: (message: string) => Promise<void>;
onMessageEnd?: (message: string) => Promise<void>;
onDetails?: (details: string) => Promise<void> | void;
},
) {
const decoder = new TextDecoder('utf-8');
let result = '';
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
const textWithNewLine = decoder.decode(value);
const text = textWithNewLine.replace(/\n$/, '');
if (text.startsWith(CHAT_RESPONSE_PREFIX.message)) {
const textWithoutPrefix = text.replace(CHAT_RESPONSE_PREFIX.message, '');
// basically we need to split the text by new line
// and send it to the onMessage callback
// so that we don't have broken tags for our rendering
let start = 0;
for (let i = 0; i < textWithoutPrefix.length; i++) {
if (textWithoutPrefix[i] === '\n') {
result += textWithoutPrefix.slice(start, i + 1);
await onMessage?.(result);
start = i + 1;
}
}
if (start < textWithoutPrefix.length) {
result += textWithoutPrefix.slice(start);
}
} else if (text.startsWith(CHAT_RESPONSE_PREFIX.details)) {
const textWithoutPrefix = text.replace(CHAT_RESPONSE_PREFIX.details, '');
await onDetails?.(textWithoutPrefix);
}
}
await onMessageEnd?.(result);
reader.releaseLock();
}

View File

@@ -1,5 +1,13 @@
---
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';
type Props = {
roadmapId: string;
};
export const prerender = true;
@@ -11,19 +19,25 @@ export async function getStaticPaths() {
}));
}
interface Params extends Record<string, string | undefined> {
roadmapId: string;
}
const { roadmapId } = Astro.params as Props;
const { roadmapId } = Astro.params as Params;
const roadmapFile = await import(
`../../data/roadmaps/${roadmapId}/${roadmapId}.md`
);
const roadmapDetail = await getRoadmapById(roadmapId);
const roadmapData = roadmapFile.frontmatter as RoadmapFrontmatter;
if (roadmapData.renderer !== 'editor') {
return Astro.rewrite(`/404`);
}
return Astro.rewrite(`/ai/chat/${roadmapId}`);
const canonicalUrl = `https://roadmap.sh/${roadmapId}/ai`;
const roadmapBriefTitle = roadmapDetail.frontmatter.briefTitle;
---
<SkeletonLayout
title={`${roadmapBriefTitle} AI Mentor`}
description=`Learn anything ${roadmapBriefTitle} with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.`
canonicalUrl={canonicalUrl}
>
<AITutorLayout
activeTab='chat'
wrapperClassName='flex-row p-0 lg:p-0 overflow-hidden'
client:load
>
<RoadmapAIChat roadmapId={roadmapId} client:load />
<CheckSubscriptionVerification client:load />
</AITutorLayout>
</SkeletonLayout>

View 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>

View File

@@ -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>

View File

@@ -1,8 +1,7 @@
---
import SkeletonLayout from '../../../layouts/SkeletonLayout.astro';
import { AIChat } from '../../../components/AIChat/AIChat';
import { AITutorLayout } from '../../../components/AITutor/AITutorLayout';
import { CheckSubscriptionVerification } from '../../../components/Billing/CheckSubscriptionVerification';
import { AIChatLayout } from '../../../components/AIChatHistory/AIChatLayout';
import { AIChatHistory } from '../../../components/AIChatHistory/AIChatHistory';
---
<SkeletonLayout
@@ -10,13 +9,5 @@ import { CheckSubscriptionVerification } from '../../../components/Billing/Check
noIndex={true}
description='Learn anything with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.'
>
<AITutorLayout
activeTab='chat'
wrapperClassName='flex-row p-0 lg:p-0 overflow-hidden'
client:load
containerClassName='h-[calc(100vh-49px)] overflow-hidden'
>
<AIChat client:load />
<CheckSubscriptionVerification client:load />
</AITutorLayout>
<AIChatHistory client:load />
</SkeletonLayout>

101
src/queries/chat-history.ts Normal file
View File

@@ -0,0 +1,101 @@
import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query';
import { httpGet } from '../lib/query-http';
import { isLoggedIn } from '../lib/jwt';
import type { RoadmapAIChatHistoryType } from '../components/RoadmapAIChat/RoadmapAIChat';
import { markdownToHtml } from '../lib/markdown';
import { aiChatRenderer } from '../components/AIChat/AIChat';
import { renderMessage } from '../lib/render-chat-message';
export type ChatHistoryMessage = {
_id: string;
role: 'user' | 'assistant';
content: string;
};
export interface ChatHistoryDocument {
_id: string;
userId: string;
title: string;
messages: ChatHistoryMessage[];
createdAt: Date;
updatedAt: Date;
}
export function chatHistoryOptions(chatHistoryId?: string) {
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' && {
html: markdownToHtml(message.content),
}),
...(message.role === 'assistant' && {
jsx: await renderMessage(message.content, aiChatRenderer, {
isLoading: false,
}),
}),
});
}
return {
...data,
messages,
};
},
enabled: !!isLoggedIn() && !!chatHistoryId,
});
}
type ListChatHistoryQuery = {
perPage?: string;
currPage?: string;
query?: 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: '',
},
) {
return infiniteQueryOptions({
queryKey: ['list-chat-history', query],
queryFn: ({ pageParam }) => {
return httpGet<ListChatHistoryResponse>('/v1-list-chat-history', {
...(query?.query ? { query: query.query } : {}),
...(pageParam ? { currPage: pageParam } : {}),
perPage: '21',
});
},
enabled: !!isLoggedIn(),
getNextPageParam: (lastPage, pages) => {
return lastPage.currPage < lastPage.totalPages
? lastPage.currPage + 1
: undefined;
},
initialPageParam: 1,
});
}

View File

@@ -2,6 +2,7 @@
@import '@roadmapsh/editor/style.css';
@config '../../tailwind.config.cjs';
@plugin 'tailwind-scrollbar';
@font-face {
font-family: 'Balsamiq Sans';