1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-08-29 20:21:50 +02:00

feat: add global AI chat (#8740)

* wip: ai chat window

* wip: chat history

* wip: chat history ui

* wip: chat history ui

* wip: chat history ui

* wip: chat preferences

* wip

* wip: resume upload

* feat: process upload in background

* wip

* wip: common ai sidebar

* feat: clear chat button and scroll to bottom

* wip

* wip: regenerate message

* wip

* wip

* feat: generate course renderer

* fix: thinking card

* wip

* wip

* wip: quick help

* wip: tooltip

* wip: handle guest users

* feat: show ai limits

* Fix typo

* Update UI for upgrade message

* Update UI for upgrade message

* Update AI chat UI

* Update UI for upload resume model

* Update UI for upload resume model

* Update UI for chat history

* Add github cli rule

---------

Co-authored-by: Arik Chakma <arikchangma@gmail.com>
This commit is contained in:
Kamran Ahmed
2025-06-04 16:42:34 +01:00
committed by GitHub
parent 9910d2b268
commit f37289ea35
23 changed files with 2007 additions and 69 deletions

389
.cursor/rules/gh-cli.mdc Normal file
View File

@@ -0,0 +1,389 @@
---
description: GitHub pull requests
globs:
alwaysApply: false
---
# gh cli
Work seamlessly with GitHub from the command line.
USAGE
gh <command> <subcommand> [flags]
CORE COMMANDS
auth: Authenticate gh and git with GitHub
browse: Open repositories, issues, pull requests, and more in the browser
codespace: Connect to and manage codespaces
gist: Manage gists
issue: Manage issues
org: Manage organizations
pr: Manage pull requests
project: Work with GitHub Projects.
release: Manage releases
repo: Manage repositories
GITHUB ACTIONS COMMANDS
cache: Manage GitHub Actions caches
run: View details about workflow runs
workflow: View details about GitHub Actions workflows
ALIAS COMMANDS
co: Alias for "pr checkout"
ADDITIONAL COMMANDS
alias: Create command shortcuts
api: Make an authenticated GitHub API request
attestation: Work with artifact attestations
completion: Generate shell completion scripts
config: Manage configuration for gh
extension: Manage gh extensions
gpg-key: Manage GPG keys
label: Manage labels
preview: Execute previews for gh features
ruleset: View info about repo rulesets
search: Search for repositories, issues, and pull requests
secret: Manage GitHub secrets
ssh-key: Manage SSH keys
status: Print information about relevant issues, pull requests, and notifications across repositories
variable: Manage GitHub Actions variables
HELP TOPICS
accessibility: Learn about GitHub CLI's accessibility experiences
actions: Learn about working with GitHub Actions
environment: Environment variables that can be used with gh
exit-codes: Exit codes used by gh
formatting: Formatting options for JSON data exported from gh
mintty: Information about using gh with MinTTY
reference: A comprehensive reference of all gh commands
FLAGS
--help Show help for command
--version Show gh version
EXAMPLES
$ gh issue create
$ gh repo clone cli/cli
$ gh pr checkout 321
LEARN MORE
Use `gh <command> <subcommand> --help` for more information about a command.
Read the manual at https://cli.github.com/manual
Learn about exit codes using `gh help exit-codes`
Learn about accessibility experiences using `gh help accessibility`
## gh pr
Work with GitHub pull requests.
USAGE
gh pr <command> [flags]
GENERAL COMMANDS
create: Create a pull request
list: List pull requests in a repository
status: Show status of relevant pull requests
TARGETED COMMANDS
checkout: Check out a pull request in git
checks: Show CI status for a single pull request
close: Close a pull request
comment: Add a comment to a pull request
diff: View changes in a pull request
edit: Edit a pull request
lock: Lock pull request conversation
merge: Merge a pull request
ready: Mark a pull request as ready for review
reopen: Reopen a pull request
review: Add a review to a pull request
unlock: Unlock pull request conversation
update-branch: Update a pull request branch
view: View a pull request
FLAGS
-R, --repo [HOST/]OWNER/REPO Select another repository using the [HOST/]OWNER/REPO format
INHERITED FLAGS
--help Show help for command
ARGUMENTS
A pull request can be supplied as argument in any of the following formats:
- by number, e.g. "123";
- by URL, e.g. "https://github.com/OWNER/REPO/pull/123"; or
- by the name of its head branch, e.g. "patch-1" or "OWNER:patch-1".
EXAMPLES
$ gh pr checkout 353
$ gh pr create --fill
$ gh pr view --web
LEARN MORE
Use `gh <command> <subcommand> --help` for more information about a command.
Read the manual at https://cli.github.com/manual
Learn about exit codes using `gh help exit-codes`
Learn about accessibility experiences using `gh help accessibility`
## gh pr list
List pull requests in a GitHub repository. By default, this only lists open PRs.
The search query syntax is documented here:
<https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests>
For more information about output formatting flags, see `gh help formatting`.
USAGE
gh pr list [flags]
ALIASES
gh pr ls
FLAGS
--app string Filter by GitHub App author
-a, --assignee string Filter by assignee
-A, --author string Filter by author
-B, --base string Filter by base branch
-d, --draft Filter by draft state
-H, --head string Filter by head branch ("<owner>:<branch>" syntax not supported)
-q, --jq expression Filter JSON output using a jq expression
--json fields Output JSON with the specified fields
-l, --label strings Filter by label
-L, --limit int Maximum number of items to fetch (default 30)
-S, --search query Search pull requests with query
-s, --state string Filter by state: {open|closed|merged|all} (default "open")
-t, --template string Format JSON output using a Go template; see "gh help formatting"
-w, --web List pull requests in the web browser
INHERITED FLAGS
--help Show help for command
-R, --repo [HOST/]OWNER/REPO Select another repository using the [HOST/]OWNER/REPO format
JSON FIELDS
additions, assignees, author, autoMergeRequest, baseRefName, baseRefOid, body,
changedFiles, closed, closedAt, closingIssuesReferences, comments, commits,
createdAt, deletions, files, fullDatabaseId, headRefName, headRefOid,
headRepository, headRepositoryOwner, id, isCrossRepository, isDraft, labels,
latestReviews, maintainerCanModify, mergeCommit, mergeStateStatus, mergeable,
mergedAt, mergedBy, milestone, number, potentialMergeCommit, projectCards,
projectItems, reactionGroups, reviewDecision, reviewRequests, reviews, state,
statusCheckRollup, title, updatedAt, url
EXAMPLES
# List PRs authored by you
$ gh pr list --author "@me"
# List PRs with a specific head branch name
$ gh pr list --head "typo"
# List only PRs with all of the given labels
$ gh pr list --label bug --label "priority 1"
# Filter PRs using search syntax
$ gh pr list --search "status:success review:required"
# Find a PR that introduced a given commit
$ gh pr list --search "<SHA>" --state merged
LEARN MORE
Use `gh <command> <subcommand> --help` for more information about a command.
Read the manual at https://cli.github.com/manual
Learn about exit codes using `gh help exit-codes`
Learn about accessibility experiences using `gh help accessibility`
## gh pr diff
View changes in a pull request.
Without an argument, the pull request that belongs to the current branch
is selected.
With `--web` flag, open the pull request diff in a web browser instead.
USAGE
gh pr diff [<number> | <url> | <branch>] [flags]
FLAGS
--color string Use color in diff output: {always|never|auto} (default "auto")
--name-only Display only names of changed files
--patch Display diff in patch format
-w, --web Open the pull request diff in the browser
INHERITED FLAGS
--help Show help for command
-R, --repo [HOST/]OWNER/REPO Select another repository using the [HOST/]OWNER/REPO format
LEARN MORE
Use `gh <command> <subcommand> --help` for more information about a command.
Read the manual at https://cli.github.com/manual
Learn about exit codes using `gh help exit-codes`
Learn about accessibility experiences using `gh help accessibility`
## gh pr merge
Merge a pull request on GitHub.
Without an argument, the pull request that belongs to the current branch
is selected.
When targeting a branch that requires a merge queue, no merge strategy is required.
If required checks have not yet passed, auto-merge will be enabled.
If required checks have passed, the pull request will be added to the merge queue.
To bypass a merge queue and merge directly, pass the `--admin` flag.
USAGE
gh pr merge [<number> | <url> | <branch>] [flags]
FLAGS
--admin Use administrator privileges to merge a pull request that does not meet requirements
-A, --author-email text Email text for merge commit author
--auto Automatically merge only after necessary requirements are met
-b, --body text Body text for the merge commit
-F, --body-file file Read body text from file (use "-" to read from standard input)
-d, --delete-branch Delete the local and remote branch after merge
--disable-auto Disable auto-merge for this pull request
--match-head-commit SHA Commit SHA that the pull request head must match to allow merge
-m, --merge Merge the commits with the base branch
-r, --rebase Rebase the commits onto the base branch
-s, --squash Squash the commits into one commit and merge it into the base branch
-t, --subject text Subject text for the merge commit
INHERITED FLAGS
--help Show help for command
-R, --repo [HOST/]OWNER/REPO Select another repository using the [HOST/]OWNER/REPO format
LEARN MORE
Use `gh <command> <subcommand> --help` for more information about a command.
Read the manual at https://cli.github.com/manual
Learn about exit codes using `gh help exit-codes`
Learn about accessibility experiences using `gh help accessibility`
## gh pr review
Add a review to a pull request.
Without an argument, the pull request that belongs to the current branch is reviewed.
USAGE
gh pr review [<number> | <url> | <branch>] [flags]
FLAGS
-a, --approve Approve pull request
-b, --body string Specify the body of a review
-F, --body-file file Read body text from file (use "-" to read from standard input)
-c, --comment Comment on a pull request
-r, --request-changes Request changes on a pull request
INHERITED FLAGS
--help Show help for command
-R, --repo [HOST/]OWNER/REPO Select another repository using the [HOST/]OWNER/REPO format
EXAMPLES
# Approve the pull request of the current branch
$ gh pr review --approve
# Leave a review comment for the current branch
$ gh pr review --comment -b "interesting"
# Add a review for a specific pull request
$ gh pr review 123
# Request changes on a specific pull request
$ gh pr review 123 -r -b "needs more ASCII art"
LEARN MORE
Use `gh <command> <subcommand> --help` for more information about a command.
Read the manual at https://cli.github.com/manual
Learn about exit codes using `gh help exit-codes`
Learn about accessibility experiences using `gh help accessibility`
## gh pr checkout
Check out a pull request in git
USAGE
gh pr checkout [<number> | <url> | <branch>] [flags]
FLAGS
-b, --branch string Local branch name to use (default [the name of the head branch])
--detach Checkout PR with a detached HEAD
-f, --force Reset the existing local branch to the latest state of the pull request
--recurse-submodules Update all submodules after checkout
INHERITED FLAGS
--help Show help for command
-R, --repo [HOST/]OWNER/REPO Select another repository using the [HOST/]OWNER/REPO format
EXAMPLES
# Interactively select a PR from the 10 most recent to check out
$ gh pr checkout
# Checkout a specific PR
$ gh pr checkout 32
$ gh pr checkout https://github.com/OWNER/REPO/pull/32
$ gh pr checkout feature
LEARN MORE
Use `gh <command> <subcommand> --help` for more information about a command.
Read the manual at https://cli.github.com/manual
Learn about exit codes using `gh help exit-codes`
Learn about accessibility experiences using `gh help accessibility`
## gh pr close
Close a pull request
USAGE
gh pr close {<number> | <url> | <branch>} [flags]
FLAGS
-c, --comment string Leave a closing comment
-d, --delete-branch Delete the local and remote branch after close
INHERITED FLAGS
--help Show help for command
-R, --repo [HOST/]OWNER/REPO Select another repository using the [HOST/]OWNER/REPO format
LEARN MORE
Use `gh <command> <subcommand> --help` for more information about a command.
Read the manual at https://cli.github.com/manual
Learn about exit codes using `gh help exit-codes`
Learn about accessibility experiences using `gh help accessibility`
## gh pr comment
Add a comment to a GitHub pull request.
Without the body text supplied through flags, the command will interactively
prompt for the comment text.
USAGE
gh pr comment [<number> | <url> | <branch>] [flags]
FLAGS
-b, --body text The comment body text
-F, --body-file file Read body text from file (use "-" to read from standard input)
--create-if-none Create a new comment if no comments are found. Can be used only with --edit-last
--delete-last Delete the last comment of the current user
--edit-last Edit the last comment of the current user
-e, --editor Skip prompts and open the text editor to write the body in
-w, --web Open the web browser to write the comment
--yes Skip the delete confirmation prompt when --delete-last is provided
INHERITED FLAGS
--help Show help for command
-R, --repo [HOST/]OWNER/REPO Select another repository using the [HOST/]OWNER/REPO format
EXAMPLES
$ gh pr comment 13 --body "Hi from GitHub CLI"
LEARN MORE
Use `gh <command> <subcommand> --help` for more information about a command.
Read the manual at https://cli.github.com/manual
Learn about exit codes using `gh help exit-codes`
Learn about accessibility experiences using `gh help accessibility`

View File

@@ -76,6 +76,7 @@
"react-calendar-heatmap": "^1.10.0", "react-calendar-heatmap": "^1.10.0",
"react-confetti": "^6.4.0", "react-confetti": "^6.4.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-dropzone": "^14.3.8",
"react-resizable-panels": "^3.0.2", "react-resizable-panels": "^3.0.2",
"react-textarea-autosize": "^8.5.9", "react-textarea-autosize": "^8.5.9",
"react-tooltip": "^5.28.1", "react-tooltip": "^5.28.1",

30
pnpm-lock.yaml generated
View File

@@ -143,6 +143,9 @@ importers:
react-dom: react-dom:
specifier: ^19.1.0 specifier: ^19.1.0
version: 19.1.0(react@19.1.0) version: 19.1.0(react@19.1.0)
react-dropzone:
specifier: ^14.3.8
version: 14.3.8(react@19.1.0)
react-resizable-panels: react-resizable-panels:
specifier: ^3.0.2 specifier: ^3.0.2
version: 3.0.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 3.0.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -1981,6 +1984,10 @@ packages:
asynckit@0.4.0: asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
attr-accept@2.2.5:
resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==}
engines: {node: '>=4'}
axobject-query@4.1.0: axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2469,6 +2476,10 @@ packages:
fflate@0.7.4: fflate@0.7.4:
resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==} resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==}
file-selector@2.1.2:
resolution: {integrity: sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==}
engines: {node: '>= 12'}
filename-reserved-regex@2.0.0: filename-reserved-regex@2.0.0:
resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==} resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -3594,6 +3605,12 @@ packages:
peerDependencies: peerDependencies:
react: ^19.1.0 react: ^19.1.0
react-dropzone@14.3.8:
resolution: {integrity: sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==}
engines: {node: '>= 10.13'}
peerDependencies:
react: '>= 16.8 || 18.0.0'
react-is@16.13.1: react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -5996,6 +6013,8 @@ snapshots:
asynckit@0.4.0: {} asynckit@0.4.0: {}
attr-accept@2.2.5: {}
axobject-query@4.1.0: {} axobject-query@4.1.0: {}
bail@2.0.2: {} bail@2.0.2: {}
@@ -6415,6 +6434,10 @@ snapshots:
fflate@0.7.4: {} fflate@0.7.4: {}
file-selector@2.1.2:
dependencies:
tslib: 2.8.1
filename-reserved-regex@2.0.0: {} filename-reserved-regex@2.0.0: {}
filenamify@4.3.0: filenamify@4.3.0:
@@ -7671,6 +7694,13 @@ snapshots:
react: 19.1.0 react: 19.1.0
scheduler: 0.26.0 scheduler: 0.26.0
react-dropzone@14.3.8(react@19.1.0):
dependencies:
attr-accept: 2.2.5
file-selector: 2.1.2
prop-types: 15.8.1
react: 19.1.0
react-is@16.13.1: {} react-is@16.13.1: {}
react-refresh@0.17.0: {} react-refresh@0.17.0: {}

View File

@@ -0,0 +1,131 @@
.ai-chat .prose ul li > code,
.ai-chat .prose ol li > code,
.ai-chat p code,
.ai-chat a > code,
.ai-chat strong > code,
.ai-chat em > code,
.ai-chat h1 > code,
.ai-chat h2 > code,
.ai-chat h3 > code {
background: #ebebeb !important;
color: currentColor !important;
font-size: 14px;
font-weight: normal !important;
}
.ai-chat .course-ai-content.course-content.prose ul li > code,
.ai-chat .course-ai-content.course-content.prose ol li > code,
.ai-chat .course-ai-content.course-content.prose p code,
.ai-chat .course-ai-content.course-content.prose a > code,
.ai-chat .course-ai-content.course-content.prose strong > code,
.ai-chat .course-ai-content.course-content.prose em > code,
.ai-chat .course-ai-content.course-content.prose h1 > code,
.ai-chat .course-ai-content.course-content.prose h2 > code,
.ai-chat .course-ai-content.course-content.prose h3 > code,
.ai-chat .course-notes-content.prose ul li > code,
.ai-chat .course-notes-content.prose ol li > code,
.ai-chat .course-notes-content.prose p code,
.ai-chat .course-notes-content.prose a > code,
.ai-chat .course-notes-content.prose strong > code,
.ai-chat .course-notes-content.prose em > code,
.ai-chat .course-notes-content.prose h1 > code,
.ai-chat .course-notes-content.prose h2 > code,
.ai-chat .course-notes-content.prose h3 > code {
font-size: 12px !important;
}
.ai-chat .course-ai-content pre {
-ms-overflow-style: none;
scrollbar-width: none;
}
.ai-chat .course-ai-content pre::-webkit-scrollbar {
display: none;
}
.ai-chat .course-ai-content pre,
.ai-chat .course-notes-content pre {
overflow: scroll;
font-size: 15px;
margin: 10px 0;
}
.ai-chat .prose ul li > code:before,
.ai-chat p > code:before,
.ai-chat .prose ul li > code:after,
.prose ol li > code:before,
p > code:before,
.ai-chat .prose ol li > code:after,
.ai-chat .course-content h1 > code:after,
.ai-chat .course-content h1 > code:before,
.ai-chat .course-content h2 > code:after,
.ai-chat .course-content h2 > code:before,
.ai-chat .course-content h3 > code:after,
.ai-chat .course-content h3 > code:before,
.ai-chat .course-content h4 > code:after,
.ai-chat .course-content h4 > code:before,
.ai-chat p > code:after,
.ai-chat a > code:after,
.ai-chat a > code:before {
content: '' !important;
}
.ai-chat .course-content.prose ul li > code,
.ai-chat .course-content.prose ol li > code,
.ai-chat .course-content p code,
.ai-chat .course-content a > code,
.ai-chat .course-content strong > code,
.ai-chat .course-content em > code,
.ai-chat .course-content h1 > code,
.ai-chat .course-content h2 > code,
.ai-chat .course-content h3 > code,
.ai-chat .course-content table code {
background: #f4f4f5 !important;
border: 1px solid #282a36 !important;
color: #282a36 !important;
padding: 2px 4px;
border-radius: 5px;
font-size: 16px !important;
white-space: pre;
font-weight: normal;
}
.ai-chat .course-content blockquote {
font-style: normal;
}
.ai-chat .course-content.prose blockquote h1,
.ai-chat .course-content.prose blockquote h2,
.ai-chat .course-content.prose blockquote h3,
.ai-chat .course-content.prose blockquote h4 {
font-style: normal;
margin-bottom: 8px;
}
.ai-chat .course-content.prose ul li > code:before,
.ai-chat .course-content p > code:before,
.ai-chat .course-content.prose ul li > code:after,
.ai-chat .course-content p > code:after,
.ai-chat .course-content h2 > code:after,
.ai-chat .course-content h2 > code:before,
.ai-chat .course-content table code:before,
.ai-chat .course-content table code:after,
.ai-chat .course-content a > code:after,
.ai-chat .course-content a > code:before,
.ai-chat .course-content h2 code:after,
.ai-chat .course-content h2 code:before,
.ai-chat .course-content h2 code:after,
.ai-chat .course-content h2 code:before {
content: '' !important;
}
.ai-chat .course-content table {
border-collapse: collapse;
border: 1px solid black;
border-radius: 5px;
}
.ai-chat .course-content table td,
.ai-chat .course-content table th {
padding: 5px 10px;
}

View File

@@ -0,0 +1,496 @@
import './AIChat.css';
import {
ArrowDownIcon,
FileUpIcon,
LockIcon,
PersonStandingIcon,
SendIcon,
TrashIcon,
} from 'lucide-react';
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { flushSync } from 'react-dom';
import AutogrowTextarea from 'react-textarea-autosize';
import { QuickHelpPrompts } from './QuickHelpPrompts';
import { QuickActionButton } from './QuickActionButton';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
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';
import { userPersonaOptions } from '../../queries/user-persona';
import { UploadResumeModal } from './UploadResumeModal';
import { userResumeOptions } from '../../queries/user-resume';
import { httpPost } from '../../lib/query-http';
import {
renderMessage,
type MessagePartRenderer,
} from '../../lib/render-chat-message';
import { RoadmapRecommendations } from '../RoadmapAIChat/RoadmapRecommendations';
import type { RoadmapAIChatHistoryType } from '../RoadmapAIChat/RoadmapAIChat';
import { AIChatCourse } from './AIChatCouse';
import { getTailwindScreenDimension } from '../../lib/is-mobile';
import type { TailwindScreenDimensions } from '../../lib/is-mobile';
import { showLoginPopup } from '../../lib/popup';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
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[]
>([]);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [isPersonalizedResponseFormOpen, setIsPersonalizedResponseFormOpen] =
useState(false);
const [isUploadResumeModalOpen, setIsUploadResumeModalOpen] = useState(false);
const [showScrollToBottomButton, setShowScrollToBottomButton] =
useState(false);
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const chatContainerRef = useRef<HTMLDivElement>(null);
const textareaMessageRef = useRef<HTMLTextAreaElement>(null);
const { data: tokenUsage, isLoading } = useQuery(
getAiCourseLimitOptions(),
queryClient,
);
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
useQuery(billingDetailsOptions(), queryClient);
const { data: userPersona, isLoading: isUserPersonaLoading } = useQuery(
userPersonaOptions(),
queryClient,
);
const { data: userResume, isLoading: isUserResumeLoading } = useQuery(
userResumeOptions(),
queryClient,
);
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
const isPaidUser = userBillingDetails?.status === 'active';
const handleChatSubmit = () => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
if (isLimitExceeded) {
if (!isPaidUser) {
setShowUpgradeModal(true);
}
toast.error('Limit reached for today. Please wait until tomorrow.');
return;
}
const trimmedMessage = message.trim();
if (!trimmedMessage || isStreamingMessage) {
return;
}
const newMessages: RoadmapAIChatHistoryType[] = [
...aiChatHistory,
{
role: 'user',
content: trimmedMessage,
// it's just a simple message, so we can use markdownToHtml
html: markdownToHtml(trimmedMessage),
},
];
flushSync(() => {
setAiChatHistory(newMessages);
setMessage('');
});
setTimeout(() => {
scrollToBottom();
}, 0);
textareaMessageRef.current?.focus();
completeAIChat(newMessages);
};
const scrollToBottom = useCallback(() => {
const scrollableContainer = scrollableContainerRef?.current;
if (!scrollableContainer) {
return;
}
scrollableContainer.scrollTo({
top: scrollableContainer.scrollHeight,
behavior: 'smooth',
});
}, [scrollableContainerRef]);
const renderer: Record<string, MessagePartRenderer> = useMemo(() => {
return {
'roadmap-recommendations': (options) => {
return <RoadmapRecommendations {...options} />;
},
'generate-course': (options) => {
return <AIChatCourse {...options} />;
},
};
}, []);
const completeAIChat = async (
messages: RoadmapAIChatHistoryType[],
force: boolean = false,
) => {
setIsStreamingMessage(true);
const response = await fetch(`${import.meta.env.PUBLIC_API_URL}/v1-chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
messages: messages.slice(-10),
force,
}),
});
if (!response.ok) {
const data = await response.json();
toast.error(data?.message || 'Something went wrong');
setAiChatHistory([...messages].slice(0, messages.length - 1));
setIsStreamingMessage(false);
if (data.status === 401) {
removeAuthToken();
window.location.reload();
}
}
const reader = response.body?.getReader();
if (!reader) {
setIsStreamingMessage(false);
toast.error('Something went wrong');
return;
}
await readStream(reader, {
onStream: async (content) => {
const jsx = await renderMessage(content, renderer, {
isLoading: true,
});
flushSync(() => {
setStreamedMessage(jsx);
});
scrollToBottom();
},
onStreamEnd: async (content) => {
const jsx = await renderMessage(content, renderer, {
isLoading: false,
});
const newMessages: RoadmapAIChatHistoryType[] = [
...messages,
{
role: 'assistant',
content,
jsx,
},
];
flushSync(() => {
setStreamedMessage(null);
setIsStreamingMessage(false);
setAiChatHistory(newMessages);
});
queryClient.invalidateQueries(getAiCourseLimitOptions());
scrollToBottom();
},
});
setIsStreamingMessage(false);
};
const { mutate: uploadResume, isPending: isUploading } = useMutation(
{
mutationFn: (formData: FormData) => {
return httpPost('/v1-upload-resume', formData);
},
onSuccess: () => {
toast.success('Resume uploaded successfully');
setIsUploadResumeModalOpen(false);
queryClient.invalidateQueries(userResumeOptions());
},
onError: (error) => {
toast.error(error?.message || 'Failed to upload resume');
},
onMutate: () => {
setIsUploadResumeModalOpen(false);
},
},
queryClient,
);
useEffect(() => {
const scrollableContainer = scrollableContainerRef.current;
if (!scrollableContainer) {
return;
}
const abortController = new AbortController();
let timeoutId: NodeJS.Timeout;
const debouncedHandleScroll = () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
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));
}, 100);
};
debouncedHandleScroll();
scrollableContainer.addEventListener('scroll', debouncedHandleScroll, {
signal: abortController.signal,
});
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
abortController.abort();
};
}, [aiChatHistory]);
const handleRegenerate = useCallback(
(index: number) => {
if (isLimitExceeded) {
if (!isPaidUser) {
setShowUpgradeModal(true);
}
toast.error('Limit reached for today. Please wait until tomorrow.');
return;
}
const filteredChatHistory = aiChatHistory.slice(0, index);
flushSync(() => {
setAiChatHistory(filteredChatHistory);
});
scrollToBottom();
completeAIChat(filteredChatHistory, true);
},
[aiChatHistory],
);
const handleDelete = useCallback(
(index: number) => {
const filteredChatHistory = aiChatHistory.filter((_, i) => i !== index);
setAiChatHistory(filteredChatHistory);
},
[aiChatHistory],
);
const shouldShowQuickHelpPrompts =
message.length === 0 && aiChatHistory.length === 0;
const isDataLoading =
isLoading ||
isBillingDetailsLoading ||
isUserPersonaLoading ||
isUserResumeLoading;
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>
{isPersonalizedResponseFormOpen && (
<PersonalizedResponseForm
defaultValues={userPersona?.chatPreferences ?? undefined}
onClose={() => setIsPersonalizedResponseFormOpen(false)}
/>
)}
{isUploadResumeModalOpen && (
<UploadResumeModal
onClose={() => setIsUploadResumeModalOpen(false)}
userResume={userResume}
isUploading={isUploading}
uploadResume={uploadResume}
/>
)}
{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)]"
ref={chatContainerRef}
>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<QuickActionButton
icon={PersonStandingIcon}
label="Personalize"
onClick={() => {
setIsPersonalizedResponseFormOpen(true);
}}
/>
<QuickActionButton
icon={FileUpIcon}
label={isUploading ? 'Processing...' : 'Upload Resume'}
onClick={() => {
setIsUploadResumeModalOpen(true);
}}
isLoading={isUploading}
/>
</div>
<div className="flex items-center gap-2">
{showScrollToBottomButton && (
<QuickActionButton
icon={ArrowDownIcon}
label="Scroll to Bottom"
onClick={scrollToBottom}
/>
)}
{aiChatHistory.length > 0 && (
<QuickActionButton
icon={TrashIcon}
label="Clear Chat"
onClick={() => {
setAiChatHistory([]);
}}
/>
)}
</div>
</div>
<form
className="pointer-events-auto relative flex flex-col gap-2 overflow-hidden rounded-lg rounded-b-none border border-b-0 border-gray-200 bg-white p-2.5"
onSubmit={(e) => {
e.preventDefault();
if (isDataLoading) {
return;
}
handleChatSubmit();
}}
>
<AutogrowTextarea
ref={textareaMessageRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
className="min-h-10 w-full resize-none bg-transparent text-sm focus:outline-none"
placeholder="Ask me anything..."
disabled={isStreamingMessage}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
if (isDataLoading) {
return;
}
e.preventDefault();
handleChatSubmit();
}
}}
/>
{isLimitExceeded && isLoggedIn() && !isDataLoading && (
<div className="absolute inset-0 z-10 flex items-center justify-center gap-2 bg-black text-white">
<LockIcon
className="size-4 cursor-not-allowed"
strokeWidth={2.5}
/>
<p className="cursor-not-allowed">
Limit reached for today
{isPaidUser ? '. Please wait until tomorrow.' : ''}
</p>
{!isPaidUser && (
<button
type="button"
onClick={() => {
setShowUpgradeModal(true);
}}
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
>
Upgrade for more
</button>
)}
</div>
)}
<div className="flex justify-end">
<button
type="submit"
className="flex size-8 shrink-0 items-center justify-center rounded-md border border-gray-200 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isLimitExceeded || isStreamingMessage || isDataLoading}
>
<SendIcon className="size-4" />
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { Book } from 'lucide-react';
type AIChatCourseType = {
keyword: string;
difficulty: string;
};
function parseAIChatCourse(content: string): AIChatCourseType | null {
const courseKeywordRegex = /<keyword>(.*?)<\/keyword>/;
const courseKeyword = content.match(courseKeywordRegex)?.[1]?.trim();
if (!courseKeyword) {
return null;
}
const courseDifficultyRegex = /<difficulty>(.*?)<\/difficulty>/;
const courseDifficulty = content.match(courseDifficultyRegex)?.[1]?.trim();
if (!courseDifficulty) {
return null;
}
return { keyword: courseKeyword, difficulty: courseDifficulty || 'beginner' };
}
type AIChatCourseProps = {
content: string;
};
export function AIChatCourse(props: AIChatCourseProps) {
const { content } = props;
const course = parseAIChatCourse(content);
if (!course) {
return null;
}
const courseSearchUrl = `/ai/search?term=${course?.keyword}&difficulty=${course?.difficulty}`;
return (
<div className="relative my-6 flex flex-wrap gap-1 first:mt-0 last:mb-0">
<a
href={courseSearchUrl}
target="_blank"
key={course?.keyword}
className="group flex min-w-[120px] items-center gap-2 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-left text-sm text-gray-700 transition-all hover:border-gray-400 hover:text-black active:bg-gray-100"
>
<Book className="size-4 flex-shrink-0 text-gray-400" />
{course?.keyword}
</a>
</div>
);
}

View File

@@ -0,0 +1,174 @@
import { Fragment, memo } from 'react';
import { cn } from '../../lib/classname';
import {
CopyIcon,
CheckIcon,
TrashIcon,
type LucideIcon,
RotateCwIcon,
} from 'lucide-react';
import { useCopyText } from '../../hooks/use-copy-text';
import type { RoadmapAIChatHistoryType } from '../RoadmapAIChat/RoadmapAIChat';
import { Tooltip } from '../Tooltip';
type ChatHistoryProps = {
chatHistory: RoadmapAIChatHistoryType[];
onDelete?: (index: number) => void;
onRegenerate?: (index: number) => void;
isStreamingMessage: boolean;
streamedMessage: React.ReactNode;
};
export const ChatHistory = memo((props: ChatHistoryProps) => {
const {
chatHistory,
onDelete,
isStreamingMessage,
streamedMessage,
onRegenerate,
} = props;
return (
<div className="flex grow flex-col">
<div className="relative flex grow flex-col justify-end">
<div className="flex grow flex-col justify-end gap-14 py-5">
{chatHistory.map((chat, index) => {
return (
<Fragment key={`chat-${index}`}>
<AIChatCard
{...chat}
onDelete={() => {
onDelete?.(index);
}}
onRegenerate={() => {
onRegenerate?.(index);
}}
/>
</Fragment>
);
})}
{isStreamingMessage && !streamedMessage && (
<AIChatCard
role="assistant"
content=""
html="<p>Thinking...</p>"
showActions={false}
/>
)}
{streamedMessage && (
<AIChatCard
role="assistant"
content=""
jsx={streamedMessage}
showActions={false}
/>
)}
</div>
</div>
</div>
);
});
type AIChatCardProps = RoadmapAIChatHistoryType & {
onDelete?: () => void;
onRegenerate?: () => void;
showActions?: boolean;
};
export const AIChatCard = memo((props: AIChatCardProps) => {
const {
role,
content,
jsx,
html,
showActions = true,
onDelete,
onRegenerate,
} = props;
const { copyText, isCopied } = useCopyText();
return (
<div
className={cn(
'group/content relative flex w-full flex-col',
role === 'user' ? 'items-end' : 'items-start',
)}
>
<div
className={cn(
'flex max-w-full items-start gap-2.5 rounded-lg',
role === 'user' ? 'max-w-[70%] bg-gray-200 p-3' : 'w-full',
)}
>
{!!jsx && jsx}
{!!html && (
<div
className="course-content course-ai-content prose prose-sm overflow-hidden text-sm"
dangerouslySetInnerHTML={{ __html: html }}
/>
)}
</div>
{showActions && (
<div
className={cn(
'absolute -bottom-2 flex translate-y-full items-center gap-1',
role === 'user' ? 'right-0' : 'left-0',
)}
>
<ActionButton
icon={isCopied ? CheckIcon : CopyIcon}
onClick={() => copyText(content ?? '')}
tooltip={isCopied ? 'Copied' : 'Copy'}
/>
{role === 'assistant' && onRegenerate && (
<ActionButton
icon={RotateCwIcon}
onClick={onRegenerate}
tooltip="Regenerate"
/>
)}
{onDelete && (
<ActionButton
icon={TrashIcon}
onClick={onDelete}
tooltip="Delete"
/>
)}
</div>
)}
</div>
);
});
type ActionButtonProps = {
icon: LucideIcon;
tooltip?: string;
onClick: () => void;
};
function ActionButton(props: ActionButtonProps) {
const { icon: Icon, onClick, tooltip } = props;
return (
<div className="group relative">
<button
className="flex size-8 items-center justify-center rounded-lg opacity-0 transition-opacity group-hover/content:opacity-100 hover:bg-gray-200"
onClick={onClick}
>
<Icon className="size-4 stroke-[2.5]" />
</button>
{tooltip && (
<Tooltip position="top-center" additionalClass="-translate-y-1">
{tooltip}
</Tooltip>
)}
</div>
);
}

View File

@@ -0,0 +1,240 @@
import { Loader2Icon } from 'lucide-react';
import { MessageCircle } from 'lucide-react';
import { memo, useId, useRef, useState } from 'react';
import { Modal } from '../Modal';
import { cn } from '../../lib/classname';
import { SelectNative } from '../SelectNative';
import { useMutation } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client';
import { httpPost } from '../../lib/query-http';
import { userPersonaOptions } from '../../queries/user-persona';
import { useToast } from '../../hooks/use-toast';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
export type ChatPreferencesFormData = {
expertise: string;
goal: string;
about: string;
specialInstructions?: string;
};
type PersonalizedResponseFormProps = {
defaultValues?: ChatPreferencesFormData;
onClose: () => void;
};
export const PersonalizedResponseForm = memo(
(props: PersonalizedResponseFormProps) => {
const { defaultValues, onClose } = props;
const toast = useToast();
const [expertise, setExpertise] = useState(defaultValues?.expertise ?? '');
const [about, setAbout] = useState(defaultValues?.about ?? '');
const [specialInstructions, setSpecialInstructions] = useState(
defaultValues?.specialInstructions ?? ''
);
const goalOptions = [
'Finding a job',
'Learning for fun',
'Building a side project',
'Switching careers',
'Getting a promotion',
'Filling knowledge gaps',
'Other',
];
const getInitialGoalSelection = () => {
if (!defaultValues?.goal) {
return '';
}
for (const option of goalOptions.slice(0, -1)) {
if (defaultValues.goal.startsWith(option)) {
return option;
}
}
return 'Other';
};
const [selectedGoal, setSelectedGoal] = useState(getInitialGoalSelection());
const [goal, setGoal] = useState(defaultValues?.goal ?? '');
const expertiseFieldId = useId();
const goalFieldId = useId();
const goalSelectId = useId();
const aboutFieldId = useId();
const specialInstructionsFieldId = useId();
const goalRef = useRef<HTMLTextAreaElement>(null);
const handleGoalSelectionChange = (value: string) => {
setSelectedGoal(value);
if (value === 'Other') {
setGoal('');
setTimeout(() => {
goalRef.current?.focus();
}, 0);
} else {
setGoal(value);
}
};
const { mutate: setChatPreferences, isPending } = useMutation(
{
mutationFn: (data: ChatPreferencesFormData) => {
return httpPost('/v1-set-chat-preferences', data);
},
onSuccess: () => {
onClose();
queryClient.invalidateQueries(userPersonaOptions());
},
onError: (error) => {
toast.error(error?.message ?? 'Something went wrong');
},
},
queryClient
);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!isLoggedIn()) {
showLoginPopup();
return;
}
setChatPreferences({
expertise,
goal,
about,
specialInstructions,
});
};
const hasFormCompleted = !!expertise && !!goal && !!about;
return (
<Modal onClose={onClose}>
<div className="p-4">
<form onSubmit={handleSubmit} className={cn('space-y-8')}>
<div className="flex flex-col gap-3">
<label
className="text-sm font-medium text-gray-700"
htmlFor={expertiseFieldId}
>
Rate your Experience
</label>
<SelectNative
id={expertiseFieldId}
value={expertise}
defaultValue={expertise}
onChange={(e) => setExpertise(e.target.value)}
className="h-[40px] border-gray-300 text-sm focus:border-gray-500 focus:ring-1 focus:ring-gray-500"
>
<option value="">Select your expertise</option>
{[
'No experience (just starting out)',
'Beginner (less than 1 year of experience)',
'Intermediate (1-3 years of experience)',
'Expert (3-5 years of experience)',
'Master (5+ years of experience)',
].map((expertise) => (
<option key={expertise} value={expertise}>
{expertise}
</option>
))}
</SelectNative>
</div>
<div className="flex flex-col gap-3">
<label
className="text-sm font-medium text-gray-700"
htmlFor={goalSelectId}
>
What is your goal?
</label>
<SelectNative
id={goalSelectId}
value={selectedGoal}
onChange={(e) => handleGoalSelectionChange(e.target.value)}
className="h-[40px] border-gray-300 text-sm focus:border-gray-500 focus:ring-1 focus:ring-gray-500"
>
<option value="">Select your goal</option>
{goalOptions.map((goalOption) => (
<option key={goalOption} value={goalOption}>
{goalOption}
</option>
))}
</SelectNative>
{selectedGoal === 'Other' && (
<textarea
ref={goalRef}
id={goalFieldId}
className="block min-h-24 w-full resize-none rounded-lg border border-gray-300 bg-white px-4 py-3 text-sm outline-none placeholder:text-gray-400 focus:border-gray-500 focus:ring-1 focus:ring-gray-500"
placeholder="e.g. need to find a job as soon as possible"
value={goal}
onChange={(e) => setGoal(e.target.value)}
/>
)}
</div>
<div className="flex flex-col gap-3">
<label
className="text-sm font-medium text-gray-700"
htmlFor={aboutFieldId}
>
Tell us more about yourself
</label>
<textarea
id={aboutFieldId}
className="block min-h-24 w-full resize-none rounded-lg border border-gray-300 bg-white px-4 py-3 text-sm outline-none placeholder:text-gray-400 focus:border-gray-500 focus:ring-1 focus:ring-gray-500"
placeholder="e.g. I'm a software engineer with 5 years of experience"
value={about}
onChange={(e) => setAbout(e.target.value)}
/>
</div>
<div className="flex flex-col gap-3">
<label
className="text-sm font-medium text-gray-700"
htmlFor={specialInstructionsFieldId}
>
Special Instructions
</label>
<textarea
id={specialInstructionsFieldId}
className="block min-h-24 w-full resize-none rounded-lg border border-gray-300 bg-white px-4 py-3 text-sm outline-none placeholder:text-gray-400 focus:border-gray-500 focus:ring-1 focus:ring-gray-500"
placeholder="e.g. Prefer concise responses with code examples"
value={specialInstructions}
onChange={(e) => setSpecialInstructions(e.target.value)}
/>
</div>
<button
disabled={!hasFormCompleted || isPending}
type="submit"
className="mt-6 flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-black px-6 py-2 text-sm text-white transition-all hover:bg-gray-900 disabled:pointer-events-none disabled:opacity-50"
>
{isPending ? (
<Loader2Icon className="size-4 animate-spin stroke-[2.5]" />
) : (
<>
<MessageCircle className="size-4" />
{defaultValues ? 'Update Preferences' : 'Set Preferences'}
</>
)}
</button>
</form>
</div>
</Modal>
);
}
);

View File

@@ -0,0 +1,29 @@
import { Loader2Icon, type LucideIcon } from 'lucide-react';
import { cn } from '../../lib/classname';
type QuickActionButtonProps = {
icon?: LucideIcon;
label?: string;
onClick?: () => void;
className?: string;
isLoading?: boolean;
};
export function QuickActionButton(props: QuickActionButtonProps) {
const { icon: Icon, label, onClick, className, isLoading } = props;
return (
<button
className={cn(
'pointer-events-auto flex shrink-0 cursor-pointer items-center gap-2 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-sm text-gray-500 hover:bg-gray-100 hover:text-black disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
onClick={onClick}
disabled={isLoading}
>
{Icon && !isLoading && <Icon className="size-4" />}
{isLoading && Icon && <Loader2Icon className="size-4 animate-spin" />}
<span className="hidden lg:block">{label}</span>
</button>
);
}

View File

@@ -0,0 +1,86 @@
import { useState } from 'react';
import { cn } from '../../lib/classname';
type QuickHelpPromptsProps = {
onQuestionClick: (question: string) => void;
};
export function QuickHelpPrompts(props: QuickHelpPromptsProps) {
const { onQuestionClick } = props;
const [selectedActionIndex, setSelectedActionIndex] = useState<number>(0);
const quickActions = [
{
label: 'Help select a career path',
questions: [
'What roadmap should I pick?',
'What are the best jobs for me?',
'Recommend me a project based on my expertise',
'Recommend me a topic I can learn in an hour',
],
},
{
label: 'Help me find a job',
questions: [
'How can I improve my resume?',
'How to make a tech resume?',
'Whats asked in coding interviews?',
'Where to find remote dev jobs?',
],
},
{
label: 'Learn a Topic',
questions: [
'What is the best way to learn React?',
'What is an API?',
'How do databases work?',
'What is async in JS?',
],
},
{
label: 'Test my Knowledge',
questions: [
'Quiz me on arrays.',
'Test my SQL skills.',
'Ask about REST basics.',
'Test my JS async knowledge.',
],
},
];
const selectedAction = quickActions[selectedActionIndex];
return (
<div className="mt-24">
<h2 className="text-2xl font-semibold">How can I help you?</h2>
<div className="mt-6 flex flex-wrap items-center gap-2">
{quickActions.map((action, index) => (
<button
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
? 'border-gray-300 bg-white text-black hover:bg-white'
: 'border-gray-300 bg-gray-100 text-gray-500 hover:border-gray-300 hover:bg-gray-50',
)}
onClick={() => setSelectedActionIndex(index)}
>
{action.label}
</button>
))}
</div>
<div className="mt-6 divide-y divide-gray-200">
{selectedAction.questions.map((question) => (
<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)}
>
{question}
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,223 @@
import { useCallback, useState, type FormEvent } from 'react';
import { Modal } from '../Modal';
import {
useDropzone,
type DropEvent,
type FileRejection,
} from 'react-dropzone';
import { cn } from '../../lib/classname';
import { Loader2Icon, PlusIcon, XIcon } from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
import { httpDelete } from '../../lib/query-http';
import { useToast } from '../../hooks/use-toast';
import { queryClient } from '../../stores/query-client';
import {
userResumeOptions,
type UserResumeDocument,
} from '../../queries/user-resume';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
type OnDrop<T extends File = File> = (
acceptedFiles: T[],
fileRejections: FileRejection[],
event: DropEvent,
) => void;
type UploadResumeModalProps = {
userResume?: UserResumeDocument;
onClose: () => void;
isUploading: boolean;
uploadResume: (formData: FormData) => void;
};
export function UploadResumeModal(props: UploadResumeModalProps) {
const {
onClose,
userResume: defaultUserResume,
isUploading,
uploadResume,
} = props;
const toast = useToast();
const [showLinkedInExport, setShowLinkedInExport] = useState(false);
const [file, setFile] = useState<File | null>(
defaultUserResume?.resumeUrl
? new File([], defaultUserResume.fileName, {
type: defaultUserResume.fileType,
})
: null,
);
const onDrop: OnDrop = useCallback((acceptedFiles) => {
setFile(acceptedFiles[0]);
}, []);
const { mutate: deleteResume, isPending: isDeletingResume } = useMutation(
{
mutationFn: async () => {
return httpDelete('/v1-delete-resume');
},
onSuccess: () => {
setFile(null);
},
onSettled: () => {
return queryClient.invalidateQueries(userResumeOptions());
},
onError: (error) => {
toast.error(error?.message || 'Failed to delete resume');
},
},
queryClient,
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'application/pdf': ['.pdf'],
},
maxFiles: 1,
maxSize: 5 * 1024 * 1024, // 5MB
});
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!isLoggedIn()) {
showLoginPopup();
return;
}
if (!file) {
return;
}
const formData = new FormData();
formData.append('resume', file);
uploadResume(formData);
};
const size = file?.size || defaultUserResume?.fileSize || 0;
const fileSize = (size / 1024 / 1024).toFixed(2);
return (
<Modal onClose={onClose}>
{showLinkedInExport ? (
<div className="p-4 pt-8">
<h2 className="text-center text-2xl font-semibold text-black">
How to export LinkedIn Resume
</h2>
<p className="mt-2 text-center text-sm text-balance text-gray-500">
Visit your LinkedIn profile and export your resume as a PDF.
</p>
<img
src="https://assets.roadmap.sh/guest/linkedin-resume-export-w3x2f.png"
alt="LinkedIn Resume Export"
className="mt-6 min-h-[331px] rounded-xl object-cover"
/>
<button
onClick={() => setShowLinkedInExport(false)}
className="mt-4 flex w-full cursor-pointer items-center justify-center rounded-lg bg-black p-1 py-3 leading-none tracking-wide text-white transition-colors hover:bg-gray-900"
>
Back to Upload
</button>
</div>
) : (
<form
className="p-4 pt-8"
encType="multipart/form-data"
onSubmit={handleSubmit}
>
<h2 className="text-center text-2xl font-semibold text-black">
Upload your resume
</h2>
<p className="mt-2 text-center text-sm text-balance text-gray-500">
Upload your resume to get personalized responses to your questions.
</p>
{file && (
<div className="mt-8">
<div className="flex items-center justify-between gap-2 rounded-lg border border-gray-200 p-4">
<div>
<h3 className="text-base font-medium text-black">
{file.name}
</h3>
<p className="mt-0.5 text-sm text-gray-500">{fileSize} MB</p>
</div>
<button
type="button"
className="flex size-8 items-center justify-center rounded-md text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500"
disabled={isDeletingResume}
onClick={() => deleteResume()}
>
{isDeletingResume ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<XIcon className="size-4" />
)}
</button>
</div>
</div>
)}
{!file && (
<>
<div
{...getRootProps({
className: cn(
'border border-dashed border-gray-300 min-h-60 flex items-center justify-center rounded-lg p-4 mt-8 bg-gray-50 cursor-pointer hover:border-black transition-colors',
isDragActive && 'border-black bg-gray-100',
),
})}
>
<input {...getInputProps()} />
<div className="mx-auto flex max-w-2xs flex-col items-center text-center text-balance">
<PlusIcon className="size-5 text-gray-500" />
<p className="mt-4 text-gray-600">
Drag and drop your resume here or{' '}
<span className="font-semibold text-black">
click to browse
</span>
</p>
</div>
</div>
<p className="mt-4 text-center text-xs text-gray-500">
Only PDF files (max 2MB in size) are supported
</p>
</>
)}
{!defaultUserResume && (
<>
<button
type="submit"
className="mt-4 flex w-full cursor-pointer items-center justify-center rounded-lg bg-black p-1 py-3 leading-none tracking-wide text-white transition-colors hover:bg-gray-900 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 data-[loading=true]:cursor-wait"
data-loading={String(isUploading)}
disabled={!file || isUploading || isDeletingResume}
>
{isUploading ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
'Upload Resume'
)}
</button>
<p className="mt-4 text-center text-xs text-gray-500">
You can also export your resume from{' '}
<button
type="button"
onClick={() => setShowLinkedInExport(true)}
className="text-black underline underline-offset-2 hover:text-gray-600"
>
LinkedIn
</button>
</p>
</>
)}
</form>
)}
</Modal>
);
}

View File

@@ -8,10 +8,11 @@ type AITutorLayoutProps = {
children: React.ReactNode; children: React.ReactNode;
activeTab: AITutorTab; activeTab: AITutorTab;
wrapperClassName?: string; wrapperClassName?: string;
containerClassName?: string;
}; };
export function AITutorLayout(props: AITutorLayoutProps) { export function AITutorLayout(props: AITutorLayoutProps) {
const { children, activeTab, wrapperClassName } = props; const { children, activeTab, wrapperClassName, containerClassName } = props;
const [isSidebarFloating, setIsSidebarFloating] = useState(false); const [isSidebarFloating, setIsSidebarFloating] = useState(false);
@@ -29,7 +30,17 @@ export function AITutorLayout(props: AITutorLayoutProps) {
</button> </button>
</div> </div>
<div className="flex flex-grow flex-row lg:h-screen"> <div
className={cn(
'flex flex-grow flex-row lg:h-screen',
containerClassName,
)}
style={
{
'--ai-sidebar-width': '255px',
} as React.CSSProperties
}
>
<AITutorSidebar <AITutorSidebar
onClose={() => setIsSidebarFloating(false)} onClose={() => setIsSidebarFloating(false)}
isFloating={isSidebarFloating} isFloating={isSidebarFloating}

View File

@@ -4,6 +4,11 @@ import { isLoggedIn } from '../../lib/jwt';
import { useIsPaidUser } from '../../queries/billing'; import { useIsPaidUser } from '../../queries/billing';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { AITutorLogo } from '../ReactIcons/AITutorLogo'; import { AITutorLogo } from '../ReactIcons/AITutorLogo';
import { queryClient } from '../../stores/query-client';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { useQuery } from '@tanstack/react-query';
import { getPercentage } from '../../lib/number';
import { AILimitsPopup } from '../GenerateCourse/AILimitsPopup';
import { cn } from '../../lib/classname'; import { cn } from '../../lib/classname';
import { UserDropdown } from './UserDropdown'; import { UserDropdown } from './UserDropdown';
@@ -53,22 +58,43 @@ export function AITutorSidebar(props: AITutorSidebarProps) {
const [isInitialLoad, setIsInitialLoad] = useState(true); const [isInitialLoad, setIsInitialLoad] = useState(true);
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false); const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false);
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser(); const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
const { data: limits, isLoading: isLimitsLoading } = useQuery(
getAiCourseLimitOptions(),
queryClient,
);
const { used, limit } = limits ?? { used: 0, limit: 0 };
const totalPercentage = getPercentage(used, limit);
useEffect(() => { useEffect(() => {
setIsInitialLoad(false); setIsInitialLoad(false);
}, []); }, []);
const isLoading = isPaidUserLoading || isLimitsLoading;
return ( return (
<> <>
{isUpgradeModalOpen && ( {isUpgradeModalOpen && (
<UpgradeAccountModal onClose={() => setIsUpgradeModalOpen(false)} /> <UpgradeAccountModal onClose={() => setIsUpgradeModalOpen(false)} />
)} )}
{showAILimitsPopup && (
<AILimitsPopup
onClose={() => setShowAILimitsPopup(false)}
onUpgrade={() => {
setIsUpgradeModalOpen(true);
setShowAILimitsPopup(false);
}}
/>
)}
<aside <aside
className={cn( className={cn(
'flex w-[255px] shrink-0 flex-col border-r border-slate-200', 'flex w-[var(--ai-sidebar-width)] 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',
@@ -112,27 +138,38 @@ export function AITutorSidebar(props: AITutorSidebarProps) {
</li> </li>
))} ))}
{!isInitialLoad && {!isInitialLoad && isLoggedIn() && !isPaidUser && !isLoading && (
isLoggedIn() && <li>
!isPaidUser && <button
!isPaidUserLoading && ( onClick={() => {
<li> setIsUpgradeModalOpen(true);
<button }}
onClick={() => { className="animate-fade-in mx-4 mt-4 rounded-xl bg-amber-100 p-4 text-left transition-colors hover:bg-amber-200/80"
setIsUpgradeModalOpen(true); >
}} <span className="mb-2 flex items-center gap-2">
className="mx-4 mt-4 rounded-xl bg-amber-100 p-4 text-left transition-colors hover:bg-amber-200/80" <Zap className="size-4 text-amber-600" />
> <span className="font-medium text-amber-900">Upgrade</span>
<span className="mb-2 flex items-center gap-2"> </span>
<Zap className="size-4 text-amber-600" /> <span className="mt-1 block text-left text-xs leading-4 text-amber-700">
<span className="font-medium text-amber-900">Upgrade</span> Get access to all features and benefits of the AI Tutor.
</span>
<div className="mt-5">
<div className="relative h-1 w-full rounded-full bg-amber-300/40">
<div
className="absolute inset-0 h-full rounded-full bg-amber-600/80"
style={{
width: `${totalPercentage}%`,
}}
></div>
</div>
<span className="mt-2 block text-xs text-amber-700">
{totalPercentage}% of the daily limit used
</span> </span>
<span className="mt-1 block text-left text-xs leading-4 text-amber-700"> </div>
Get access to all features and benefits of the AI Tutor. </button>
</span> </li>
</button> )}
</li>
)}
</ul> </ul>
<div className="mx-2 mt-auto mb-2"> <div className="mx-2 mt-auto mb-2">
<UserDropdown /> <UserDropdown />

View File

@@ -1,3 +1,4 @@
import '../RoadmapAIChat/RoadmapAIChat.css';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
BookOpen, BookOpen,

View File

@@ -54,12 +54,12 @@ import {
type TailwindScreenDimensions, type TailwindScreenDimensions,
} from '../../lib/is-mobile'; } from '../../lib/is-mobile';
import { ChatPersona } from '../UserPersona/ChatPersona'; import { ChatPersona } from '../UserPersona/ChatPersona';
import { userPersonaOptions } from '../../queries/user-persona'; import { userRoadmapPersonaOptions } from '../../queries/user-persona';
import { UpdatePersonaModal } from '../UserPersona/UpdatePersonaModal'; import { UpdatePersonaModal } from '../UserPersona/UpdatePersonaModal';
import { lockBodyScroll } from '../../lib/dom'; import { lockBodyScroll } from '../../lib/dom';
import { TutorIntroMessage } from './TutorIntroMessage'; import { TutorIntroMessage } from './TutorIntroMessage';
export type RoamdapAIChatHistoryType = { export type RoadmapAIChatHistoryType = {
role: AllowedAIChatRole; role: AllowedAIChatRole;
isDefault?: boolean; isDefault?: boolean;
@@ -103,7 +103,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
const [activeTab, setActiveTab] = useState<RoadmapAIChatTab>('chat'); const [activeTab, setActiveTab] = useState<RoadmapAIChatTab>('chat');
const [aiChatHistory, setAiChatHistory] = useState< const [aiChatHistory, setAiChatHistory] = useState<
RoamdapAIChatHistoryType[] RoadmapAIChatHistoryType[]
>([]); >([]);
const [isStreamingMessage, setIsStreamingMessage] = useState(false); const [isStreamingMessage, setIsStreamingMessage] = useState(false);
const [streamedMessage, setStreamedMessage] = const [streamedMessage, setStreamedMessage] =
@@ -133,7 +133,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
useQuery(billingDetailsOptions(), queryClient); useQuery(billingDetailsOptions(), queryClient);
const { data: userPersona, isLoading: isUserPersonaLoading } = useQuery( const { data: userPersona, isLoading: isUserPersonaLoading } = useQuery(
userPersonaOptions(roadmapId), userRoadmapPersonaOptions(roadmapId),
queryClient, queryClient,
); );
@@ -177,7 +177,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
abortControllerRef.current = new AbortController(); abortControllerRef.current = new AbortController();
const html = htmlFromTiptapJSON(json); const html = htmlFromTiptapJSON(json);
const newMessages: RoamdapAIChatHistoryType[] = [ const newMessages: RoadmapAIChatHistoryType[] = [
...aiChatHistory, ...aiChatHistory,
{ {
role: 'user', role: 'user',
@@ -271,13 +271,13 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
return <ShareResourceLink roadmapId={roadmapId} />; return <ShareResourceLink roadmapId={roadmapId} />;
}, },
'roadmap-recommendations': (options) => { 'roadmap-recommendations': (options) => {
return <RoadmapRecommendations roadmapId={roadmapId} {...options} />; return <RoadmapRecommendations {...options} />;
}, },
}; };
}, [roadmapId, handleSelectTopic, totalTopicCount]); }, [roadmapId, handleSelectTopic, totalTopicCount]);
const completeAITutorChat = async ( const completeAITutorChat = async (
messages: RoamdapAIChatHistoryType[], messages: RoadmapAIChatHistoryType[],
abortController?: AbortController, abortController?: AbortController,
) => { ) => {
try { try {
@@ -347,7 +347,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
const jsx = await renderMessage(content, renderer, { const jsx = await renderMessage(content, renderer, {
isLoading: false, isLoading: false,
}); });
const newMessages: RoamdapAIChatHistoryType[] = [ const newMessages: RoadmapAIChatHistoryType[] = [
...messages, ...messages,
{ {
role: 'assistant', role: 'assistant',

View File

@@ -1,8 +1,8 @@
import type { RoamdapAIChatHistoryType } from './RoadmapAIChat'; import type { RoadmapAIChatHistoryType } from './RoadmapAIChat';
import { cn } from '../../lib/classname'; import { cn } from '../../lib/classname';
import { BotIcon, User2Icon } from 'lucide-react'; import { BotIcon, User2Icon } from 'lucide-react';
type RoadmapAIChatCardProps = RoamdapAIChatHistoryType & { type RoadmapAIChatCardProps = RoadmapAIChatHistoryType & {
isIntro?: boolean; isIntro?: boolean;
}; };
@@ -31,15 +31,6 @@ export function RoadmapAIChatCard(props: RoadmapAIChatCardProps) {
<BotIcon className="size-4 stroke-[2.5]" /> <BotIcon className="size-4 stroke-[2.5]" />
)} )}
</div> </div>
{!!jsx && jsx}
{!!html && (
<div
className="course-content course-ai-content prose prose-sm mt-0.5 w-full max-w-[calc(100%-38px)] overflow-hidden text-sm"
dangerouslySetInnerHTML={{ __html: html }}
/>
)}
</div> </div>
</div> </div>
); );

View File

@@ -35,7 +35,6 @@ function parseRoadmapSlugList(content: string): RoadmapSlugListType[] {
} }
type RoadmapRecommendationsProps = { type RoadmapRecommendationsProps = {
roadmapId: string;
content: string; content: string;
}; };
@@ -64,13 +63,13 @@ export function RoadmapRecommendations(props: RoadmapRecommendationsProps) {
<div className="relative my-6 flex flex-wrap gap-1 first:mt-0 last:mb-0"> <div className="relative my-6 flex flex-wrap gap-1 first:mt-0 last:mb-0">
{progressItemWithText.map((item) => ( {progressItemWithText.map((item) => (
<a <a
href={`/ai/chat/${item.roadmapSlug}`} href={`/${item.roadmapSlug}/ai`}
target="_blank" target="_blank"
key={item.roadmapSlug} key={item.roadmapSlug}
className="group flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-left text-sm text-gray-700 transition-all hover:border-gray-300 hover:bg-gray-50 hover:text-gray-900 active:bg-gray-100" className="group flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-left text-sm text-gray-700 transition-all hover:border-gray-400 hover:text-black active:bg-gray-100"
> >
{item.title} {item.title}
<SquareArrowOutUpRightIcon className="size-3.5 text-gray-400 transition-transform group-hover:text-gray-600" /> <SquareArrowOutUpRightIcon className="size-3.5 ml-1 text-gray-400 transition-transform group-hover:text-gray-600" />
</a> </a>
))} ))}
</div> </div>

View File

@@ -1,4 +1,3 @@
import Cookies from 'js-cookie';
import { type ChangeEvent, type FormEvent, useEffect, useRef, useState } from 'react'; import { type ChangeEvent, type FormEvent, useEffect, useRef, useState } from 'react';
import { TOKEN_COOKIE_NAME, removeAuthToken } from '../../lib/jwt'; import { TOKEN_COOKIE_NAME, removeAuthToken } from '../../lib/jwt';

View File

@@ -4,7 +4,7 @@ import { roadmapJSONOptions } from '../../queries/roadmap';
import { queryClient } from '../../stores/query-client'; import { queryClient } from '../../stores/query-client';
import { httpPost } from '../../lib/query-http'; import { httpPost } from '../../lib/query-http';
import { useToast } from '../../hooks/use-toast'; import { useToast } from '../../hooks/use-toast';
import { userPersonaOptions } from '../../queries/user-persona'; import { userRoadmapPersonaOptions } from '../../queries/user-persona';
type ChatPersonaProps = { type ChatPersonaProps = {
roadmapId: string; roadmapId: string;
@@ -33,7 +33,9 @@ export function ChatPersona(props: ChatPersonaProps) {
toast.error(error?.message || 'Something went wrong'); toast.error(error?.message || 'Something went wrong');
}, },
onSettled: () => { onSettled: () => {
return queryClient.invalidateQueries(userPersonaOptions(roadmapId)); return queryClient.invalidateQueries(
userRoadmapPersonaOptions(roadmapId),
);
}, },
}, },
queryClient, queryClient,
@@ -42,15 +44,17 @@ export function ChatPersona(props: ChatPersonaProps) {
const roadmapTitle = roadmap?.json.title ?? ''; const roadmapTitle = roadmap?.json.title ?? '';
return ( return (
<div className="relative mx-auto flex h-auto px-4 sm:h-full max-w-[400px] grow flex-col justify-center p-4 sm:p-4 px-2"> <div className="relative mx-auto flex h-auto max-w-[400px] grow flex-col justify-center p-4 px-2 px-4 sm:h-full sm:p-4">
<div className="mb-4 sm:mb-8 text-left sm:text-center"> <div className="mb-4 text-left sm:mb-8 sm:text-center">
<img <img
src="/images/gifs/wave.gif" src="/images/gifs/wave.gif"
alt="Wave" alt="Wave"
className="hidden sm:block mx-auto mb-3 sm:mb-5 h-16 sm:h-24 w-16 sm:w-24" className="mx-auto mb-3 hidden h-16 w-16 sm:mb-5 sm:block sm:h-24 sm:w-24"
/> />
<h2 className="text-lg sm:text-xl font-semibold">Welcome to the AI Tutor</h2> <h2 className="text-lg font-semibold sm:text-xl">
<p className="mt-1 text-xs sm:text-sm text-balance text-gray-500 pr-8 sm:px-0"> Welcome to the AI Tutor
</h2>
<p className="mt-1 pr-8 text-xs text-balance text-gray-500 sm:px-0 sm:text-sm">
Before we start, answer these questions so we can help you better. Before we start, answer these questions so we can help you better.
</p> </p>
</div> </div>

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { userPersonaOptions } from '../../queries/user-persona'; import { userRoadmapPersonaOptions } from '../../queries/user-persona';
import { queryClient } from '../../stores/query-client'; import { queryClient } from '../../stores/query-client';
import { roadmapJSONOptions } from '../../queries/roadmap'; import { roadmapJSONOptions } from '../../queries/roadmap';
import { Modal } from '../Modal'; import { Modal } from '../Modal';
@@ -22,7 +22,7 @@ export function UpdatePersonaModal(props: UpdatePersonaModalProps) {
queryClient, queryClient,
); );
const { data: userPersona } = useQuery( const { data: userPersona } = useQuery(
userPersonaOptions(roadmapId), userRoadmapPersonaOptions(roadmapId),
queryClient, queryClient,
); );
@@ -42,7 +42,9 @@ export function UpdatePersonaModal(props: UpdatePersonaModalProps) {
onClose(); onClose();
}, },
onSettled: () => { onSettled: () => {
return queryClient.invalidateQueries(userPersonaOptions(roadmapId)); return queryClient.invalidateQueries(
userRoadmapPersonaOptions(roadmapId),
);
}, },
}, },
queryClient, queryClient,

View File

@@ -1,18 +1,12 @@
--- ---
import { CheckSubscriptionVerification } from '../../../components/Billing/CheckSubscriptionVerification';
import { RoadmapAIChat } from '../../../components/RoadmapAIChat/RoadmapAIChat';
import SkeletonLayout from '../../../layouts/SkeletonLayout.astro'; import SkeletonLayout from '../../../layouts/SkeletonLayout.astro';
import { AIChat } from '../../../components/AIChat/AIChat';
import { AITutorLayout } from '../../../components/AITutor/AITutorLayout'; import { AITutorLayout } from '../../../components/AITutor/AITutorLayout';
import { CheckSubscriptionVerification } from '../../../components/Billing/CheckSubscriptionVerification';
type Props = {
roadmapId: string;
};
const { roadmapId } = Astro.params as Props;
--- ---
<SkeletonLayout <SkeletonLayout
title='Roadmap AI Chat' title='AI Chat'
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.'
> >
@@ -20,8 +14,9 @@ const { roadmapId } = Astro.params as Props;
activeTab='chat' activeTab='chat'
wrapperClassName='flex-row p-0 lg:p-0 overflow-hidden' wrapperClassName='flex-row p-0 lg:p-0 overflow-hidden'
client:load client:load
containerClassName='h-[calc(100vh-49px)] overflow-hidden'
> >
<h1>Roadmap AI Chat</h1> <AIChat client:load />
<CheckSubscriptionVerification client:load /> <CheckSubscriptionVerification client:load />
</AITutorLayout> </AITutorLayout>
</SkeletonLayout> </SkeletonLayout>

View File

@@ -10,21 +10,42 @@ export interface UserPersonaDocument {
expertise: string; expertise: string;
goal: string; goal: string;
commit: string; commit: string;
about?: string;
}[]; }[];
chatPreferences: {
expertise: string;
goal: string;
about: string;
specialInstructions?: string;
};
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
type UserPersonaResponse = UserPersonaDocument['roadmaps'][number] | null; type UserPersonaResponse = UserPersonaDocument['roadmaps'][number] | null;
export function userPersonaOptions(roadmapId: string) { export function userRoadmapPersonaOptions(roadmapId: string) {
return queryOptions({ return queryOptions({
queryKey: ['user-persona', roadmapId], queryKey: ['user-persona', roadmapId],
queryFn: async () => { queryFn: async () => {
return httpGet<UserPersonaResponse>(`/v1-user-persona/${roadmapId}`); return httpGet<UserPersonaResponse>(
`/v1-user-roadmap-persona/${roadmapId}`,
);
}, },
enabled: !!roadmapId && isLoggedIn(), enabled: !!roadmapId && isLoggedIn(),
refetchOnMount: false, refetchOnMount: false,
}); });
} }
export function userPersonaOptions() {
return queryOptions({
queryKey: ['user-persona'],
queryFn: async () => {
return httpGet<UserPersonaDocument>('/v1-user-persona');
},
enabled: !!isLoggedIn(),
refetchOnMount: false,
});
}

View File

@@ -0,0 +1,28 @@
import { queryOptions } from '@tanstack/react-query';
import { httpGet } from '../lib/query-http';
import { isLoggedIn } from '../lib/jwt';
export interface UserResumeDocument {
_id: string;
userId: string;
fileName: string;
fileType: string;
fileSize: number;
resumeUrl: string;
content: string;
createdAt: Date;
updatedAt: Date;
}
export function userResumeOptions() {
return queryOptions({
queryKey: ['user-resume'],
queryFn: async () => {
return httpGet<UserResumeDocument>('/v1-user-resume');
},
enabled: !!isLoggedIn(),
refetchOnMount: false,
});
}