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-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
30
pnpm-lock.yaml
generated
@@ -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: {}
|
||||||
|
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;
|
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}
|
||||||
|
@@ -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 />
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import '../RoadmapAIChat/RoadmapAIChat.css';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
@@ -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',
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
|
@@ -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>
|
||||||
|
@@ -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';
|
||||||
|
|
||||||
|
@@ -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>
|
||||||
|
@@ -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,
|
||||||
|
@@ -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>
|
||||||
|
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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