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:
389
.cursor/rules/gh-cli.mdc
Normal file
389
.cursor/rules/gh-cli.mdc
Normal 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`
|
||||
|
||||
|
||||
|
@@ -76,6 +76,7 @@
|
||||
"react-calendar-heatmap": "^1.10.0",
|
||||
"react-confetti": "^6.4.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-resizable-panels": "^3.0.2",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"react-tooltip": "^5.28.1",
|
||||
|
30
pnpm-lock.yaml
generated
30
pnpm-lock.yaml
generated
@@ -143,6 +143,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: ^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:
|
||||
specifier: ^3.0.2
|
||||
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:
|
||||
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:
|
||||
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2469,6 +2476,10 @@ packages:
|
||||
fflate@0.7.4:
|
||||
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:
|
||||
resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -3594,6 +3605,12 @@ packages:
|
||||
peerDependencies:
|
||||
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:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
@@ -5996,6 +6013,8 @@ snapshots:
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
attr-accept@2.2.5: {}
|
||||
|
||||
axobject-query@4.1.0: {}
|
||||
|
||||
bail@2.0.2: {}
|
||||
@@ -6415,6 +6434,10 @@ snapshots:
|
||||
|
||||
fflate@0.7.4: {}
|
||||
|
||||
file-selector@2.1.2:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
filename-reserved-regex@2.0.0: {}
|
||||
|
||||
filenamify@4.3.0:
|
||||
@@ -7671,6 +7694,13 @@ snapshots:
|
||||
react: 19.1.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-refresh@0.17.0: {}
|
||||
|
131
src/components/AIChat/AIChat.css
Normal file
131
src/components/AIChat/AIChat.css
Normal 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;
|
||||
}
|
496
src/components/AIChat/AIChat.tsx
Normal file
496
src/components/AIChat/AIChat.tsx
Normal 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>
|
||||
);
|
||||
}
|
51
src/components/AIChat/AIChatCouse.tsx
Normal file
51
src/components/AIChat/AIChatCouse.tsx
Normal 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>
|
||||
);
|
||||
}
|
174
src/components/AIChat/ChatHistory.tsx
Normal file
174
src/components/AIChat/ChatHistory.tsx
Normal 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>
|
||||
);
|
||||
}
|
240
src/components/AIChat/PersonalizedResponseForm.tsx
Normal file
240
src/components/AIChat/PersonalizedResponseForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
29
src/components/AIChat/QuickActionButton.tsx
Normal file
29
src/components/AIChat/QuickActionButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
86
src/components/AIChat/QuickHelpPrompts.tsx
Normal file
86
src/components/AIChat/QuickHelpPrompts.tsx
Normal 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?',
|
||||
'What’s 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>
|
||||
);
|
||||
}
|
223
src/components/AIChat/UploadResumeModal.tsx
Normal file
223
src/components/AIChat/UploadResumeModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -8,10 +8,11 @@ type AITutorLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
activeTab: AITutorTab;
|
||||
wrapperClassName?: string;
|
||||
containerClassName?: string;
|
||||
};
|
||||
|
||||
export function AITutorLayout(props: AITutorLayoutProps) {
|
||||
const { children, activeTab, wrapperClassName } = props;
|
||||
const { children, activeTab, wrapperClassName, containerClassName } = props;
|
||||
|
||||
const [isSidebarFloating, setIsSidebarFloating] = useState(false);
|
||||
|
||||
@@ -29,7 +30,17 @@ export function AITutorLayout(props: AITutorLayoutProps) {
|
||||
</button>
|
||||
</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
|
||||
onClose={() => setIsSidebarFloating(false)}
|
||||
isFloating={isSidebarFloating}
|
||||
|
@@ -4,6 +4,11 @@ import { isLoggedIn } from '../../lib/jwt';
|
||||
import { useIsPaidUser } from '../../queries/billing';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
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 { UserDropdown } from './UserDropdown';
|
||||
|
||||
@@ -53,22 +58,43 @@ export function AITutorSidebar(props: AITutorSidebarProps) {
|
||||
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||
|
||||
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
|
||||
const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false);
|
||||
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(() => {
|
||||
setIsInitialLoad(false);
|
||||
}, []);
|
||||
|
||||
const isLoading = isPaidUserLoading || isLimitsLoading;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isUpgradeModalOpen && (
|
||||
<UpgradeAccountModal onClose={() => setIsUpgradeModalOpen(false)} />
|
||||
)}
|
||||
|
||||
{showAILimitsPopup && (
|
||||
<AILimitsPopup
|
||||
onClose={() => setShowAILimitsPopup(false)}
|
||||
onUpgrade={() => {
|
||||
setIsUpgradeModalOpen(true);
|
||||
setShowAILimitsPopup(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
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
|
||||
? 'fixed top-0 bottom-0 left-0 z-50 flex border-r-0 bg-white shadow-xl'
|
||||
: 'hidden lg:flex',
|
||||
@@ -112,27 +138,38 @@ export function AITutorSidebar(props: AITutorSidebarProps) {
|
||||
</li>
|
||||
))}
|
||||
|
||||
{!isInitialLoad &&
|
||||
isLoggedIn() &&
|
||||
!isPaidUser &&
|
||||
!isPaidUserLoading && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsUpgradeModalOpen(true);
|
||||
}}
|
||||
className="mx-4 mt-4 rounded-xl bg-amber-100 p-4 text-left transition-colors hover:bg-amber-200/80"
|
||||
>
|
||||
<span className="mb-2 flex items-center gap-2">
|
||||
<Zap className="size-4 text-amber-600" />
|
||||
<span className="font-medium text-amber-900">Upgrade</span>
|
||||
{!isInitialLoad && isLoggedIn() && !isPaidUser && !isLoading && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsUpgradeModalOpen(true);
|
||||
}}
|
||||
className="animate-fade-in mx-4 mt-4 rounded-xl bg-amber-100 p-4 text-left transition-colors hover:bg-amber-200/80"
|
||||
>
|
||||
<span className="mb-2 flex items-center gap-2">
|
||||
<Zap className="size-4 text-amber-600" />
|
||||
<span className="font-medium text-amber-900">Upgrade</span>
|
||||
</span>
|
||||
<span className="mt-1 block text-left text-xs leading-4 text-amber-700">
|
||||
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 className="mt-1 block text-left text-xs leading-4 text-amber-700">
|
||||
Get access to all features and benefits of the AI Tutor.
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<div className="mx-2 mt-auto mb-2">
|
||||
<UserDropdown />
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import '../RoadmapAIChat/RoadmapAIChat.css';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
BookOpen,
|
||||
|
@@ -54,12 +54,12 @@ import {
|
||||
type TailwindScreenDimensions,
|
||||
} from '../../lib/is-mobile';
|
||||
import { ChatPersona } from '../UserPersona/ChatPersona';
|
||||
import { userPersonaOptions } from '../../queries/user-persona';
|
||||
import { userRoadmapPersonaOptions } from '../../queries/user-persona';
|
||||
import { UpdatePersonaModal } from '../UserPersona/UpdatePersonaModal';
|
||||
import { lockBodyScroll } from '../../lib/dom';
|
||||
import { TutorIntroMessage } from './TutorIntroMessage';
|
||||
|
||||
export type RoamdapAIChatHistoryType = {
|
||||
export type RoadmapAIChatHistoryType = {
|
||||
role: AllowedAIChatRole;
|
||||
isDefault?: boolean;
|
||||
|
||||
@@ -103,7 +103,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
const [activeTab, setActiveTab] = useState<RoadmapAIChatTab>('chat');
|
||||
|
||||
const [aiChatHistory, setAiChatHistory] = useState<
|
||||
RoamdapAIChatHistoryType[]
|
||||
RoadmapAIChatHistoryType[]
|
||||
>([]);
|
||||
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
|
||||
const [streamedMessage, setStreamedMessage] =
|
||||
@@ -133,7 +133,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
useQuery(billingDetailsOptions(), queryClient);
|
||||
|
||||
const { data: userPersona, isLoading: isUserPersonaLoading } = useQuery(
|
||||
userPersonaOptions(roadmapId),
|
||||
userRoadmapPersonaOptions(roadmapId),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
@@ -177,7 +177,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
const html = htmlFromTiptapJSON(json);
|
||||
const newMessages: RoamdapAIChatHistoryType[] = [
|
||||
const newMessages: RoadmapAIChatHistoryType[] = [
|
||||
...aiChatHistory,
|
||||
{
|
||||
role: 'user',
|
||||
@@ -271,13 +271,13 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
return <ShareResourceLink roadmapId={roadmapId} />;
|
||||
},
|
||||
'roadmap-recommendations': (options) => {
|
||||
return <RoadmapRecommendations roadmapId={roadmapId} {...options} />;
|
||||
return <RoadmapRecommendations {...options} />;
|
||||
},
|
||||
};
|
||||
}, [roadmapId, handleSelectTopic, totalTopicCount]);
|
||||
|
||||
const completeAITutorChat = async (
|
||||
messages: RoamdapAIChatHistoryType[],
|
||||
messages: RoadmapAIChatHistoryType[],
|
||||
abortController?: AbortController,
|
||||
) => {
|
||||
try {
|
||||
@@ -347,7 +347,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
const jsx = await renderMessage(content, renderer, {
|
||||
isLoading: false,
|
||||
});
|
||||
const newMessages: RoamdapAIChatHistoryType[] = [
|
||||
const newMessages: RoadmapAIChatHistoryType[] = [
|
||||
...messages,
|
||||
{
|
||||
role: 'assistant',
|
||||
|
@@ -1,8 +1,8 @@
|
||||
import type { RoamdapAIChatHistoryType } from './RoadmapAIChat';
|
||||
import type { RoadmapAIChatHistoryType } from './RoadmapAIChat';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { BotIcon, User2Icon } from 'lucide-react';
|
||||
|
||||
type RoadmapAIChatCardProps = RoamdapAIChatHistoryType & {
|
||||
type RoadmapAIChatCardProps = RoadmapAIChatHistoryType & {
|
||||
isIntro?: boolean;
|
||||
};
|
||||
|
||||
@@ -31,15 +31,6 @@ export function RoadmapAIChatCard(props: RoadmapAIChatCardProps) {
|
||||
<BotIcon className="size-4 stroke-[2.5]" />
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
|
@@ -35,7 +35,6 @@ function parseRoadmapSlugList(content: string): RoadmapSlugListType[] {
|
||||
}
|
||||
|
||||
type RoadmapRecommendationsProps = {
|
||||
roadmapId: 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">
|
||||
{progressItemWithText.map((item) => (
|
||||
<a
|
||||
href={`/ai/chat/${item.roadmapSlug}`}
|
||||
href={`/${item.roadmapSlug}/ai`}
|
||||
target="_blank"
|
||||
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}
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { type ChangeEvent, type FormEvent, useEffect, useRef, useState } from 'react';
|
||||
import { TOKEN_COOKIE_NAME, removeAuthToken } from '../../lib/jwt';
|
||||
|
||||
|
@@ -4,7 +4,7 @@ import { roadmapJSONOptions } from '../../queries/roadmap';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { httpPost } from '../../lib/query-http';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { userPersonaOptions } from '../../queries/user-persona';
|
||||
import { userRoadmapPersonaOptions } from '../../queries/user-persona';
|
||||
|
||||
type ChatPersonaProps = {
|
||||
roadmapId: string;
|
||||
@@ -33,7 +33,9 @@ export function ChatPersona(props: ChatPersonaProps) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
},
|
||||
onSettled: () => {
|
||||
return queryClient.invalidateQueries(userPersonaOptions(roadmapId));
|
||||
return queryClient.invalidateQueries(
|
||||
userRoadmapPersonaOptions(roadmapId),
|
||||
);
|
||||
},
|
||||
},
|
||||
queryClient,
|
||||
@@ -42,15 +44,17 @@ export function ChatPersona(props: ChatPersonaProps) {
|
||||
const roadmapTitle = roadmap?.json.title ?? '';
|
||||
|
||||
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="mb-4 sm:mb-8 text-left sm:text-center">
|
||||
<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 text-left sm:mb-8 sm:text-center">
|
||||
<img
|
||||
src="/images/gifs/wave.gif"
|
||||
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>
|
||||
<p className="mt-1 text-xs sm:text-sm text-balance text-gray-500 pr-8 sm:px-0">
|
||||
<h2 className="text-lg font-semibold sm:text-xl">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
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 { roadmapJSONOptions } from '../../queries/roadmap';
|
||||
import { Modal } from '../Modal';
|
||||
@@ -22,7 +22,7 @@ export function UpdatePersonaModal(props: UpdatePersonaModalProps) {
|
||||
queryClient,
|
||||
);
|
||||
const { data: userPersona } = useQuery(
|
||||
userPersonaOptions(roadmapId),
|
||||
userRoadmapPersonaOptions(roadmapId),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
@@ -42,7 +42,9 @@ export function UpdatePersonaModal(props: UpdatePersonaModalProps) {
|
||||
onClose();
|
||||
},
|
||||
onSettled: () => {
|
||||
return queryClient.invalidateQueries(userPersonaOptions(roadmapId));
|
||||
return queryClient.invalidateQueries(
|
||||
userRoadmapPersonaOptions(roadmapId),
|
||||
);
|
||||
},
|
||||
},
|
||||
queryClient,
|
||||
|
@@ -1,18 +1,12 @@
|
||||
---
|
||||
import { CheckSubscriptionVerification } from '../../../components/Billing/CheckSubscriptionVerification';
|
||||
import { RoadmapAIChat } from '../../../components/RoadmapAIChat/RoadmapAIChat';
|
||||
import SkeletonLayout from '../../../layouts/SkeletonLayout.astro';
|
||||
import { AIChat } from '../../../components/AIChat/AIChat';
|
||||
import { AITutorLayout } from '../../../components/AITutor/AITutorLayout';
|
||||
|
||||
type Props = {
|
||||
roadmapId: string;
|
||||
};
|
||||
|
||||
const { roadmapId } = Astro.params as Props;
|
||||
import { CheckSubscriptionVerification } from '../../../components/Billing/CheckSubscriptionVerification';
|
||||
---
|
||||
|
||||
<SkeletonLayout
|
||||
title='Roadmap AI Chat'
|
||||
title='AI Chat'
|
||||
noIndex={true}
|
||||
description='Learn anything with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.'
|
||||
>
|
||||
@@ -20,8 +14,9 @@ const { roadmapId } = Astro.params as Props;
|
||||
activeTab='chat'
|
||||
wrapperClassName='flex-row p-0 lg:p-0 overflow-hidden'
|
||||
client:load
|
||||
containerClassName='h-[calc(100vh-49px)] overflow-hidden'
|
||||
>
|
||||
<h1>Roadmap AI Chat</h1>
|
||||
<AIChat client:load />
|
||||
<CheckSubscriptionVerification client:load />
|
||||
</AITutorLayout>
|
||||
</SkeletonLayout>
|
||||
|
@@ -10,21 +10,42 @@ export interface UserPersonaDocument {
|
||||
expertise: string;
|
||||
goal: string;
|
||||
commit: string;
|
||||
about?: string;
|
||||
}[];
|
||||
|
||||
chatPreferences: {
|
||||
expertise: string;
|
||||
goal: string;
|
||||
about: string;
|
||||
specialInstructions?: string;
|
||||
};
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
type UserPersonaResponse = UserPersonaDocument['roadmaps'][number] | null;
|
||||
|
||||
export function userPersonaOptions(roadmapId: string) {
|
||||
export function userRoadmapPersonaOptions(roadmapId: string) {
|
||||
return queryOptions({
|
||||
queryKey: ['user-persona', roadmapId],
|
||||
queryFn: async () => {
|
||||
return httpGet<UserPersonaResponse>(`/v1-user-persona/${roadmapId}`);
|
||||
return httpGet<UserPersonaResponse>(
|
||||
`/v1-user-roadmap-persona/${roadmapId}`,
|
||||
);
|
||||
},
|
||||
enabled: !!roadmapId && isLoggedIn(),
|
||||
refetchOnMount: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function userPersonaOptions() {
|
||||
return queryOptions({
|
||||
queryKey: ['user-persona'],
|
||||
queryFn: async () => {
|
||||
return httpGet<UserPersonaDocument>('/v1-user-persona');
|
||||
},
|
||||
enabled: !!isLoggedIn(),
|
||||
refetchOnMount: false,
|
||||
});
|
||||
}
|
||||
|
28
src/queries/user-resume.ts
Normal file
28
src/queries/user-resume.ts
Normal 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,
|
||||
});
|
||||
}
|
Reference in New Issue
Block a user