mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-03 14:22:41 +02:00
wip
This commit is contained in:
1
.astro/types.d.ts
vendored
1
.astro/types.d.ts
vendored
@@ -1 +1,2 @@
|
|||||||
/// <reference types="astro/client" />
|
/// <reference types="astro/client" />
|
||||||
|
/// <reference path="content.d.ts" />
|
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",
|
||||||
|
39
pnpm-lock.yaml
generated
39
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
|
||||||
@@ -1154,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:
|
||||||
@@ -5178,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)
|
||||||
|
@@ -31,13 +31,13 @@ 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 { showLoginPopup } from '../../lib/popup';
|
import { showLoginPopup } from '../../lib/popup';
|
||||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||||
import { readChatStream } from '../../lib/chat';
|
import { readChatStream } from '../../lib/chat';
|
||||||
import { chatHistoryOptions } from '../../queries/chat-history';
|
import { chatHistoryOptions } from '../../queries/chat-history';
|
||||||
import { cn } from '../../lib/classname';
|
import { cn } from '../../lib/classname';
|
||||||
|
import type { RoadmapAIChatHistoryType } from '../../hooks/use-roadmap-ai-chat';
|
||||||
|
|
||||||
export const aiChatRenderer: Record<string, MessagePartRenderer> = {
|
export const aiChatRenderer: Record<string, MessagePartRenderer> = {
|
||||||
'roadmap-recommendations': (options) => {
|
'roadmap-recommendations': (options) => {
|
||||||
|
@@ -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[];
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { queryClient } from '../../stores/query-client';
|
import { queryClient } from '../../stores/query-client';
|
||||||
import { chatHistoryOptions } from '../../queries/chat-history';
|
import { chatHistoryOptions } from '../../queries/chat-history';
|
||||||
import { AIChat } from '../AIChat/AIChat';
|
import { AIChat, aiChatRenderer } from '../AIChat/AIChat';
|
||||||
import { Loader2Icon } from 'lucide-react';
|
import { Loader2Icon } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { AIChatLayout } from './AIChatLayout';
|
import { AIChatLayout } from './AIChatLayout';
|
||||||
@@ -24,7 +24,7 @@ export function AIChatHistory(props: AIChatHistoryProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { data, error: chatHistoryError } = useQuery(
|
const { data, error: chatHistoryError } = useQuery(
|
||||||
chatHistoryOptions(chatHistoryId),
|
chatHistoryOptions(chatHistoryId, aiChatRenderer),
|
||||||
queryClient,
|
queryClient,
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,8 +1,5 @@
|
|||||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
import {
|
import { listChatHistoryOptions } from '../../queries/chat-history';
|
||||||
listChatHistoryOptions,
|
|
||||||
type ChatHistoryWithoutMessages,
|
|
||||||
} from '../../queries/chat-history';
|
|
||||||
import { queryClient } from '../../stores/query-client';
|
import { queryClient } from '../../stores/query-client';
|
||||||
import { ChatHistoryItem } from './ChatHistoryItem';
|
import { ChatHistoryItem } from './ChatHistoryItem';
|
||||||
import {
|
import {
|
||||||
@@ -13,13 +10,15 @@ import {
|
|||||||
SearchIcon,
|
SearchIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
|
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
|
||||||
import { useDebounceValue } from '../../hooks/use-debounce';
|
import { useDebounceValue } from '../../hooks/use-debounce';
|
||||||
import { ListChatHistorySkeleton } from './ListChatHistorySkeleton';
|
import { ListChatHistorySkeleton } from './ListChatHistorySkeleton';
|
||||||
import { ChatHistoryError } from './ChatHistoryError';
|
import { ChatHistoryError } from './ChatHistoryError';
|
||||||
import { cn } from '../../lib/classname';
|
import { cn } from '../../lib/classname';
|
||||||
import { getTailwindScreenDimension } from '../../lib/is-mobile';
|
import { getTailwindScreenDimension } from '../../lib/is-mobile';
|
||||||
|
import { groupChatHistory } from '../../helper/grouping';
|
||||||
|
import { SearchAIChatHistory } from './SearchAIChatHistory';
|
||||||
|
import { ChatHistoryGroup } from './ChatHistoryGroup';
|
||||||
|
|
||||||
type ListChatHistoryProps = {
|
type ListChatHistoryProps = {
|
||||||
activeChatHistoryId?: string;
|
activeChatHistoryId?: string;
|
||||||
@@ -62,44 +61,8 @@ export function ListChatHistory(props: ListChatHistoryProps) {
|
|||||||
}, [data?.pages]);
|
}, [data?.pages]);
|
||||||
|
|
||||||
const groupedChatHistory = useMemo(() => {
|
const groupedChatHistory = useMemo(() => {
|
||||||
const today = DateTime.now().startOf('day');
|
|
||||||
const allHistories = data?.pages?.flatMap((page) => page.data);
|
const allHistories = data?.pages?.flatMap((page) => page.data);
|
||||||
|
return groupChatHistory(allHistories ?? []);
|
||||||
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]);
|
}, [data?.pages]);
|
||||||
|
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
@@ -160,7 +123,7 @@ export function ListChatHistory(props: ListChatHistoryProps) {
|
|||||||
<span className="text-sm">New Chat</span>
|
<span className="text-sm">New Chat</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<SearchInput
|
<SearchAIChatHistory
|
||||||
onSearch={setQuery}
|
onSearch={setQuery}
|
||||||
isLoading={isLoadingInfiniteQuery}
|
isLoading={isLoadingInfiniteQuery}
|
||||||
/>
|
/>
|
||||||
@@ -179,31 +142,22 @@ export function ListChatHistory(props: ListChatHistoryProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key}>
|
<ChatHistoryGroup
|
||||||
<h2 className="ml-2 text-xs text-gray-500">{value.title}</h2>
|
key={key}
|
||||||
|
title={value.title}
|
||||||
|
histories={value.histories}
|
||||||
|
activeChatHistoryId={activeChatHistoryId}
|
||||||
|
onChatHistoryClick={(id) => {
|
||||||
|
if (isMobile) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
<ul className="mt-1 space-y-0.5">
|
onChatHistoryClick(id);
|
||||||
{value.histories.map((chatHistory) => {
|
}}
|
||||||
return (
|
onDelete={(id) => {
|
||||||
<ChatHistoryItem
|
onDelete?.(id);
|
||||||
key={chatHistory._id}
|
}}
|
||||||
chatHistory={chatHistory}
|
/>
|
||||||
isActive={activeChatHistoryId === chatHistory._id}
|
|
||||||
onChatHistoryClick={(id) => {
|
|
||||||
if (isMobile) {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
onChatHistoryClick(id);
|
|
||||||
}}
|
|
||||||
onDelete={() => {
|
|
||||||
onDelete?.(chatHistory._id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
@@ -232,60 +186,3 @@ export function ListChatHistory(props: ListChatHistoryProps) {
|
|||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
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 };
|
@@ -307,6 +307,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
|||||||
setActiveTab('chat');
|
setActiveTab('chat');
|
||||||
}}
|
}}
|
||||||
selectedTopicId={selectedTopicId}
|
selectedTopicId={selectedTopicId}
|
||||||
|
roadmapId={roadmapId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{activeTab === 'topic' && selectedTopicId && (
|
{activeTab === 'topic' && selectedTopicId && (
|
||||||
|
@@ -10,6 +10,7 @@ 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 = {
|
type RoadmapAIChatHeaderProps = {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -23,6 +24,8 @@ type RoadmapAIChatHeaderProps = {
|
|||||||
onTabChange: (tab: RoadmapAIChatTab) => void;
|
onTabChange: (tab: RoadmapAIChatTab) => void;
|
||||||
onCloseTopic: () => void;
|
onCloseTopic: () => void;
|
||||||
selectedTopicId: string | null;
|
selectedTopicId: string | null;
|
||||||
|
|
||||||
|
roadmapId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TabButtonProps = {
|
type TabButtonProps = {
|
||||||
@@ -76,6 +79,7 @@ export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) {
|
|||||||
onTabChange,
|
onTabChange,
|
||||||
onCloseTopic,
|
onCloseTopic,
|
||||||
selectedTopicId,
|
selectedTopicId,
|
||||||
|
roadmapId,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
|
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
|
||||||
@@ -170,6 +174,8 @@ export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<RoadmapAIChatHistory roadmapId={roadmapId} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
126
src/components/RoadmapAIChatHistory/RoadmapAIChatHistory.tsx
Normal file
126
src/components/RoadmapAIChatHistory/RoadmapAIChatHistory.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { HistoryIcon, Loader2Icon, PlusIcon } from 'lucide-react';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '../Popover';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useInfiniteQuery } 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';
|
||||||
|
|
||||||
|
type RoadmapAIChatHistoryProps = {
|
||||||
|
roadmapId: string;
|
||||||
|
activeChatHistoryId?: string;
|
||||||
|
onChatHistoryClick: (id: string) => void;
|
||||||
|
onDelete?: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RoadmapAIChatHistory(props: RoadmapAIChatHistoryProps) {
|
||||||
|
const { roadmapId, activeChatHistoryId, onChatHistoryClick, onDelete } =
|
||||||
|
props;
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: chatHistory,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
isLoading: isLoadingInfiniteQuery,
|
||||||
|
} = useInfiniteQuery(
|
||||||
|
{
|
||||||
|
...listChatHistoryOptions({
|
||||||
|
roadmapId,
|
||||||
|
query,
|
||||||
|
}),
|
||||||
|
enabled: !!roadmapId && isLoggedIn() && isOpen,
|
||||||
|
},
|
||||||
|
queryClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
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={setIsOpen}>
|
||||||
|
<PopoverTrigger className="flex size-8 items-center justify-center rounded-lg hover:bg-gray-200">
|
||||||
|
<HistoryIcon className="size-4" />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-96 overflow-hidden p-0"
|
||||||
|
align="end"
|
||||||
|
sideOffset={4}
|
||||||
|
>
|
||||||
|
<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) => {
|
||||||
|
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 className="flex items-center justify-center border-t border-gray-200">
|
||||||
|
<button className="flex w-full items-center justify-center gap-2 p-2 text-sm text-gray-500 hover:bg-gray-200 hover:text-black">
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
New Chat
|
||||||
|
</button>
|
||||||
|
</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[] }
|
||||||
|
>,
|
||||||
|
);
|
||||||
|
}
|
@@ -17,6 +17,46 @@ 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';
|
||||||
|
|
||||||
|
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;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
@@ -31,15 +71,22 @@ type Options = {
|
|||||||
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;
|
||||||
|
defaultMessages?: RoadmapAIChatHistoryType[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useRoadmapAIChat(options: Options) {
|
export function useRoadmapAIChat(options: Options) {
|
||||||
const { roadmapId, totalTopicCount, scrollareaRef, onSelectTopic } = options;
|
const {
|
||||||
|
roadmapId,
|
||||||
|
totalTopicCount,
|
||||||
|
scrollareaRef,
|
||||||
|
onSelectTopic,
|
||||||
|
defaultMessages,
|
||||||
|
} = options;
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const [aiChatHistory, setAiChatHistory] = useState<
|
const [aiChatHistory, setAiChatHistory] = useState<
|
||||||
RoadmapAIChatHistoryType[]
|
RoadmapAIChatHistoryType[]
|
||||||
>([]);
|
>(defaultMessages ?? []);
|
||||||
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);
|
||||||
|
@@ -1,29 +1,35 @@
|
|||||||
---
|
---
|
||||||
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 } from '../../lib/roadmap';
|
||||||
|
|
||||||
export const prerender = true;
|
export const prerender = false;
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
type Props = {
|
||||||
const roadmapIds = await getRoadmapIds();
|
|
||||||
|
|
||||||
return roadmapIds.map((roadmapId) => ({
|
|
||||||
params: { roadmapId },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Params extends Record<string, string | undefined> {
|
|
||||||
roadmapId: string;
|
roadmapId: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
const { roadmapId } = Astro.params as Params;
|
const { roadmapId } = Astro.params as Props;
|
||||||
const roadmapFile = await import(
|
|
||||||
`../../data/roadmaps/${roadmapId}/${roadmapId}.md`
|
|
||||||
);
|
|
||||||
|
|
||||||
const roadmapData = roadmapFile.frontmatter as RoadmapFrontmatter;
|
const roadmapDetail = await getRoadmapById(roadmapId);
|
||||||
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>
|
||||||
|
@@ -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,10 +1,13 @@
|
|||||||
import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query';
|
import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query';
|
||||||
import { httpGet } from '../lib/query-http';
|
import { httpGet } from '../lib/query-http';
|
||||||
import { isLoggedIn } from '../lib/jwt';
|
import { isLoggedIn } from '../lib/jwt';
|
||||||
import type { RoadmapAIChatHistoryType } from '../components/RoadmapAIChat/RoadmapAIChat';
|
|
||||||
import { markdownToHtml } from '../lib/markdown';
|
import { markdownToHtml } from '../lib/markdown';
|
||||||
import { aiChatRenderer } from '../components/AIChat/AIChat';
|
import { aiChatRenderer } from '../components/AIChat/AIChat';
|
||||||
import { renderMessage } from '../lib/render-chat-message';
|
import {
|
||||||
|
type MessagePartRenderer,
|
||||||
|
renderMessage,
|
||||||
|
} from '../lib/render-chat-message';
|
||||||
|
import type { RoadmapAIChatHistoryType } from '../hooks/use-roadmap-ai-chat';
|
||||||
|
|
||||||
export type ChatHistoryMessage = {
|
export type ChatHistoryMessage = {
|
||||||
_id: string;
|
_id: string;
|
||||||
@@ -16,6 +19,7 @@ export interface ChatHistoryDocument {
|
|||||||
_id: string;
|
_id: string;
|
||||||
|
|
||||||
userId: string;
|
userId: string;
|
||||||
|
roadmapId?: string;
|
||||||
title: string;
|
title: string;
|
||||||
messages: ChatHistoryMessage[];
|
messages: ChatHistoryMessage[];
|
||||||
|
|
||||||
@@ -23,7 +27,10 @@ export interface ChatHistoryDocument {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function chatHistoryOptions(chatHistoryId?: string) {
|
export function chatHistoryOptions(
|
||||||
|
chatHistoryId?: string,
|
||||||
|
renderer?: Record<string, MessagePartRenderer>,
|
||||||
|
) {
|
||||||
return queryOptions({
|
return queryOptions({
|
||||||
queryKey: ['chat-history-details', chatHistoryId],
|
queryKey: ['chat-history-details', chatHistoryId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -44,7 +51,7 @@ export function chatHistoryOptions(chatHistoryId?: string) {
|
|||||||
html: markdownToHtml(message.content),
|
html: markdownToHtml(message.content),
|
||||||
}),
|
}),
|
||||||
...(message.role === 'assistant' && {
|
...(message.role === 'assistant' && {
|
||||||
jsx: await renderMessage(message.content, aiChatRenderer, {
|
jsx: await renderMessage(message.content, renderer ?? {}, {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -64,6 +71,7 @@ type ListChatHistoryQuery = {
|
|||||||
perPage?: string;
|
perPage?: string;
|
||||||
currPage?: string;
|
currPage?: string;
|
||||||
query?: string;
|
query?: string;
|
||||||
|
roadmapId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChatHistoryWithoutMessages = Omit<ChatHistoryDocument, 'messages'>;
|
export type ChatHistoryWithoutMessages = Omit<ChatHistoryDocument, 'messages'>;
|
||||||
@@ -79,6 +87,7 @@ type ListChatHistoryResponse = {
|
|||||||
export function listChatHistoryOptions(
|
export function listChatHistoryOptions(
|
||||||
query: ListChatHistoryQuery = {
|
query: ListChatHistoryQuery = {
|
||||||
query: '',
|
query: '',
|
||||||
|
roadmapId: '',
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
return infiniteQueryOptions({
|
return infiniteQueryOptions({
|
||||||
@@ -86,6 +95,7 @@ export function listChatHistoryOptions(
|
|||||||
queryFn: ({ pageParam }) => {
|
queryFn: ({ pageParam }) => {
|
||||||
return httpGet<ListChatHistoryResponse>('/v1-list-chat-history', {
|
return httpGet<ListChatHistoryResponse>('/v1-list-chat-history', {
|
||||||
...(query?.query ? { query: query.query } : {}),
|
...(query?.query ? { query: query.query } : {}),
|
||||||
|
...(query?.roadmapId ? { roadmapId: query.roadmapId } : {}),
|
||||||
...(pageParam ? { currPage: pageParam } : {}),
|
...(pageParam ? { currPage: pageParam } : {}),
|
||||||
perPage: '21',
|
perPage: '21',
|
||||||
});
|
});
|
||||||
|
Reference in New Issue
Block a user