From f37289ea357a640857b7610980ad0f1aaf49b1dd Mon Sep 17 00:00:00 2001 From: Kamran Ahmed Date: Wed, 4 Jun 2025 16:42:34 +0100 Subject: [PATCH] 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 --- .cursor/rules/gh-cli.mdc | 389 ++++++++++++++ package.json | 1 + pnpm-lock.yaml | 30 ++ src/components/AIChat/AIChat.css | 131 +++++ src/components/AIChat/AIChat.tsx | 496 ++++++++++++++++++ src/components/AIChat/AIChatCouse.tsx | 51 ++ src/components/AIChat/ChatHistory.tsx | 174 ++++++ .../AIChat/PersonalizedResponseForm.tsx | 240 +++++++++ src/components/AIChat/QuickActionButton.tsx | 29 + src/components/AIChat/QuickHelpPrompts.tsx | 86 +++ src/components/AIChat/UploadResumeModal.tsx | 223 ++++++++ src/components/AITutor/AITutorLayout.tsx | 15 +- src/components/AITutor/AITutorSidebar.tsx | 79 ++- .../GenerateCourse/AICourseLessonChat.tsx | 1 + .../RoadmapAIChat/RoadmapAIChat.tsx | 16 +- .../RoadmapAIChat/RoadmapAIChatCard.tsx | 13 +- .../RoadmapAIChat/RoadmapRecommendations.tsx | 7 +- .../UpdateProfile/UploadProfilePicture.tsx | 1 - src/components/UserPersona/ChatPersona.tsx | 18 +- .../UserPersona/UpdatePersonaModal.tsx | 8 +- src/pages/ai/chat/index.astro | 15 +- src/queries/user-persona.ts | 25 +- src/queries/user-resume.ts | 28 + 23 files changed, 2007 insertions(+), 69 deletions(-) create mode 100644 .cursor/rules/gh-cli.mdc create mode 100644 src/components/AIChat/AIChat.css create mode 100644 src/components/AIChat/AIChat.tsx create mode 100644 src/components/AIChat/AIChatCouse.tsx create mode 100644 src/components/AIChat/ChatHistory.tsx create mode 100644 src/components/AIChat/PersonalizedResponseForm.tsx create mode 100644 src/components/AIChat/QuickActionButton.tsx create mode 100644 src/components/AIChat/QuickHelpPrompts.tsx create mode 100644 src/components/AIChat/UploadResumeModal.tsx create mode 100644 src/queries/user-resume.ts diff --git a/.cursor/rules/gh-cli.mdc b/.cursor/rules/gh-cli.mdc new file mode 100644 index 000000000..b09f4ac48 --- /dev/null +++ b/.cursor/rules/gh-cli.mdc @@ -0,0 +1,389 @@ +--- +description: GitHub pull requests +globs: +alwaysApply: false +--- +# gh cli + +Work seamlessly with GitHub from the command line. + +USAGE + gh [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 --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 [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 --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: + + +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 (":" 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 "" --state merged + +LEARN MORE + Use `gh --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 [ | | ] [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 --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 [ | | ] [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 --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 [ | | ] [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 --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 [ | | ] [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 --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 { | | } [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 --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 [ | | ] [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 --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` + + + diff --git a/package.json b/package.json index a264c28d3..23b0a7828 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "react-calendar-heatmap": "^1.10.0", "react-confetti": "^6.4.0", "react-dom": "^19.1.0", + "react-dropzone": "^14.3.8", "react-resizable-panels": "^3.0.2", "react-textarea-autosize": "^8.5.9", "react-tooltip": "^5.28.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32bbc6d36..4cb7b0aa6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,6 +143,9 @@ importers: react-dom: specifier: ^19.1.0 version: 19.1.0(react@19.1.0) + react-dropzone: + specifier: ^14.3.8 + version: 14.3.8(react@19.1.0) react-resizable-panels: specifier: ^3.0.2 version: 3.0.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -1981,6 +1984,10 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + attr-accept@2.2.5: + resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==} + engines: {node: '>=4'} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -2469,6 +2476,10 @@ packages: fflate@0.7.4: resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==} + file-selector@2.1.2: + resolution: {integrity: sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==} + engines: {node: '>= 12'} + filename-reserved-regex@2.0.0: resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==} engines: {node: '>=4'} @@ -3594,6 +3605,12 @@ packages: peerDependencies: react: ^19.1.0 + react-dropzone@14.3.8: + resolution: {integrity: sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==} + engines: {node: '>= 10.13'} + peerDependencies: + react: '>= 16.8 || 18.0.0' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -5996,6 +6013,8 @@ snapshots: asynckit@0.4.0: {} + attr-accept@2.2.5: {} + axobject-query@4.1.0: {} bail@2.0.2: {} @@ -6415,6 +6434,10 @@ snapshots: fflate@0.7.4: {} + file-selector@2.1.2: + dependencies: + tslib: 2.8.1 + filename-reserved-regex@2.0.0: {} filenamify@4.3.0: @@ -7671,6 +7694,13 @@ snapshots: react: 19.1.0 scheduler: 0.26.0 + react-dropzone@14.3.8(react@19.1.0): + dependencies: + attr-accept: 2.2.5 + file-selector: 2.1.2 + prop-types: 15.8.1 + react: 19.1.0 + react-is@16.13.1: {} react-refresh@0.17.0: {} diff --git a/src/components/AIChat/AIChat.css b/src/components/AIChat/AIChat.css new file mode 100644 index 000000000..0371f9952 --- /dev/null +++ b/src/components/AIChat/AIChat.css @@ -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; +} diff --git a/src/components/AIChat/AIChat.tsx b/src/components/AIChat/AIChat.tsx new file mode 100644 index 000000000..56d39965f --- /dev/null +++ b/src/components/AIChat/AIChat.tsx @@ -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(); + + useLayoutEffect(() => { + setDeviceType(getTailwindScreenDimension()); + }, []); + + const [message, setMessage] = useState(''); + const [isStreamingMessage, setIsStreamingMessage] = useState(false); + const [streamedMessage, setStreamedMessage] = + useState(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(null); + const chatContainerRef = useRef(null); + const textareaMessageRef = useRef(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 = useMemo(() => { + return { + 'roadmap-recommendations': (options) => { + return ; + }, + 'generate-course': (options) => { + return ; + }, + }; + }, []); + + 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 ( +
+
+ {shouldShowQuickHelpPrompts && ( + { + textareaMessageRef.current?.focus(); + setMessage(question); + }} + /> + )} + {!shouldShowQuickHelpPrompts && ( + + )} +
+ + {isPersonalizedResponseFormOpen && ( + setIsPersonalizedResponseFormOpen(false)} + /> + )} + + {isUploadResumeModalOpen && ( + setIsUploadResumeModalOpen(false)} + userResume={userResume} + isUploading={isUploading} + uploadResume={uploadResume} + /> + )} + + {showUpgradeModal && ( + setShowUpgradeModal(false)} /> + )} + +
+
+
+ { + setIsPersonalizedResponseFormOpen(true); + }} + /> + { + setIsUploadResumeModalOpen(true); + }} + isLoading={isUploading} + /> +
+ +
+ {showScrollToBottomButton && ( + + )} + {aiChatHistory.length > 0 && ( + { + setAiChatHistory([]); + }} + /> + )} +
+
+ +
{ + e.preventDefault(); + if (isDataLoading) { + return; + } + + handleChatSubmit(); + }} + > + 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 && ( +
+ +

+ Limit reached for today + {isPaidUser ? '. Please wait until tomorrow.' : ''} +

+ {!isPaidUser && ( + + )} +
+ )} + +
+ +
+ +
+
+ ); +} diff --git a/src/components/AIChat/AIChatCouse.tsx b/src/components/AIChat/AIChatCouse.tsx new file mode 100644 index 000000000..e7a48ff37 --- /dev/null +++ b/src/components/AIChat/AIChatCouse.tsx @@ -0,0 +1,51 @@ +import { Book } from 'lucide-react'; + +type AIChatCourseType = { + keyword: string; + difficulty: string; +}; + +function parseAIChatCourse(content: string): AIChatCourseType | null { + const courseKeywordRegex = /(.*?)<\/keyword>/; + const courseKeyword = content.match(courseKeywordRegex)?.[1]?.trim(); + if (!courseKeyword) { + return null; + } + + const courseDifficultyRegex = /(.*?)<\/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 ( + + ); +} diff --git a/src/components/AIChat/ChatHistory.tsx b/src/components/AIChat/ChatHistory.tsx new file mode 100644 index 000000000..e16222301 --- /dev/null +++ b/src/components/AIChat/ChatHistory.tsx @@ -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 ( +
+
+
+ {chatHistory.map((chat, index) => { + return ( + + { + onDelete?.(index); + }} + onRegenerate={() => { + onRegenerate?.(index); + }} + /> + + ); + })} + + {isStreamingMessage && !streamedMessage && ( + + )} + + {streamedMessage && ( + + )} +
+
+
+ ); +}); + +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 ( +
+
+ {!!jsx && jsx} + + {!!html && ( +
+ )} +
+ + {showActions && ( +
+ copyText(content ?? '')} + tooltip={isCopied ? 'Copied' : 'Copy'} + /> + + {role === 'assistant' && onRegenerate && ( + + )} + + {onDelete && ( + + )} +
+ )} +
+ ); +}); + +type ActionButtonProps = { + icon: LucideIcon; + tooltip?: string; + onClick: () => void; +}; + +function ActionButton(props: ActionButtonProps) { + const { icon: Icon, onClick, tooltip } = props; + + return ( +
+ + + {tooltip && ( + + {tooltip} + + )} +
+ ); +} diff --git a/src/components/AIChat/PersonalizedResponseForm.tsx b/src/components/AIChat/PersonalizedResponseForm.tsx new file mode 100644 index 000000000..ea36eac60 --- /dev/null +++ b/src/components/AIChat/PersonalizedResponseForm.tsx @@ -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(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) => { + e.preventDefault(); + + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + + setChatPreferences({ + expertise, + goal, + about, + specialInstructions, + }); + }; + + const hasFormCompleted = !!expertise && !!goal && !!about; + + return ( + +
+
+
+ + setExpertise(e.target.value)} + className="h-[40px] border-gray-300 text-sm focus:border-gray-500 focus:ring-1 focus:ring-gray-500" + > + + {[ + '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) => ( + + ))} + +
+ +
+ + + handleGoalSelectionChange(e.target.value)} + className="h-[40px] border-gray-300 text-sm focus:border-gray-500 focus:ring-1 focus:ring-gray-500" + > + + {goalOptions.map((goalOption) => ( + + ))} + + + {selectedGoal === 'Other' && ( +