diff --git a/apps/webapp/app/assets/icons/SparkleListIcon.tsx b/apps/webapp/app/assets/icons/SparkleListIcon.tsx new file mode 100644 index 0000000000..264fc227c8 --- /dev/null +++ b/apps/webapp/app/assets/icons/SparkleListIcon.tsx @@ -0,0 +1,14 @@ +export function SparkleListIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx new file mode 100644 index 0000000000..23b575b771 --- /dev/null +++ b/apps/webapp/app/components/AskAI.tsx @@ -0,0 +1,542 @@ +import { + ArrowPathIcon, + ArrowUpIcon, + HandThumbDownIcon, + HandThumbUpIcon, + StopIcon, +} from "@heroicons/react/20/solid"; +import { type FeedbackComment, KapaProvider, type QA, useChat } from "@kapaai/react-sdk"; +import { useSearchParams } from "@remix-run/react"; +import { motion } from "framer-motion"; +import { marked } from "marked"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { AISparkleIcon } from "~/assets/icons/AISparkleIcon"; +import { SparkleListIcon } from "~/assets/icons/SparkleListIcon"; +import { Button } from "./primitives/Buttons"; +import { Callout } from "./primitives/Callout"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./primitives/Dialog"; +import { Header2 } from "./primitives/Headers"; +import { Paragraph } from "./primitives/Paragraph"; +import { Spinner } from "./primitives/Spinner"; +import { + SimpleTooltip, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "./primitives/Tooltip"; +import DOMPurify from "dompurify"; + +type AskAIContextType = { + isOpen: boolean; + openAskAI: (question?: string) => void; + closeAskAI: () => void; + websiteId: string | null; +}; + +const AskAIContext = createContext(null); + +export function useAskAI() { + const context = useContext(AskAIContext); + if (!context) { + throw new Error("useAskAI must be used within an AskAIProvider"); + } + return context; +} + +type AskAIProviderProps = { + children: ReactNode; + websiteId: string | null; +}; + +export function AskAIProvider({ children, websiteId }: AskAIProviderProps) { + const [isOpen, setIsOpen] = useState(false); + const [initialQuery, setInitialQuery] = useState(); + const [searchParams, setSearchParams] = useSearchParams(); + + const openAskAI = useCallback((question?: string) => { + if (question) { + setInitialQuery(question); + } else { + setInitialQuery(undefined); + } + setIsOpen(true); + }, []); + + const closeAskAI = useCallback(() => { + setIsOpen(false); + setInitialQuery(undefined); + }, []); + + // Handle URL param functionality + useEffect(() => { + const aiHelp = searchParams.get("aiHelp"); + if (aiHelp) { + const decodedAiHelp = decodeURIComponent(aiHelp); + + // Delay to avoid hCaptcha bot detection + const timeoutId = window.setTimeout(() => openAskAI(decodedAiHelp), 1000); + + // Clone instead of mutating in place + const next = new URLSearchParams(searchParams); + next.delete("aiHelp"); + setSearchParams(next); + + return () => clearTimeout(timeoutId); + } + }, [searchParams.toString(), openAskAI]); + + const contextValue: AskAIContextType = { + isOpen, + openAskAI, + closeAskAI, + websiteId, + }; + + if (!websiteId) { + return {children}; + } + + return ( + + openAskAI(), + onAnswerGenerationCompleted: () => openAskAI(), + }, + }} + botProtectionMechanism="hcaptcha" + > + {children} + + + + ); +} + +type AskAIDialogProps = { + initialQuery?: string; + isOpen: boolean; + onOpenChange: (open: boolean) => void; +}; + +function AskAIDialog({ initialQuery, isOpen, onOpenChange }: AskAIDialogProps) { + const { closeAskAI } = useAskAI(); + + const handleOpenChange = (open: boolean) => { + if (!open) { + closeAskAI(); + } else { + onOpenChange(open); + } + }; + + return ( + + + +
+ + Ask AI +
+
+ +
+
+ ); +} + +function ChatMessages({ + conversation, + isPreparingAnswer, + isGeneratingAnswer, + onReset, + onExampleClick, + error, + addFeedback, +}: { + conversation: QA[]; + isPreparingAnswer: boolean; + isGeneratingAnswer: boolean; + onReset: () => void; + onExampleClick: (question: string) => void; + error: string | null; + addFeedback: ( + questionAnswerId: string, + reaction: "upvote" | "downvote", + comment?: FeedbackComment + ) => void; +}) { + const [feedbackGivenForQAs, setFeedbackGivenForQAs] = useState>(new Set()); + + // Reset feedback state when conversation is reset + useEffect(() => { + if (conversation.length === 0) { + setFeedbackGivenForQAs(new Set()); + } + }, [conversation.length]); + + // Check if feedback has been given for the latest QA + const latestQA = conversation[conversation.length - 1]; + const hasFeedbackForLatestQA = latestQA?.id ? feedbackGivenForQAs.has(latestQA.id) : false; + + const exampleQuestions = [ + "How do I increase my concurrency limit?", + "How do I debug errors in my task?", + "How do I deploy my task?", + ]; + + return ( +
+ {conversation.length === 0 ? ( + + + I'm trained on docs, examples, and other content. Ask me anything about Trigger.dev. + + {exampleQuestions.map((question, index) => ( + onExampleClick(question)} + variants={{ + hidden: { + opacity: 0, + x: 20, + }, + visible: { + opacity: 1, + x: 0, + transition: { + opacity: { + duration: 0.5, + ease: "linear", + }, + x: { + type: "spring", + stiffness: 300, + damping: 25, + }, + }, + }, + }} + > + + + {question} + + + ))} + + ) : ( + conversation.map((qa) => ( +
+ {qa.question} +
+
+ )) + )} + {conversation.length > 0 && + !isPreparingAnswer && + !isGeneratingAnswer && + !error && + !latestQA?.id && ( +
+ + Answer generation was stopped + + +
+ )} + {conversation.length > 0 && + !isPreparingAnswer && + !isGeneratingAnswer && + !error && + latestQA?.id && ( +
+ {hasFeedbackForLatestQA ? ( + + + Thanks for your feedback! + + + ) : ( +
+ + Was this helpful? + +
+ + +
+
+ )} + +
+ )} + {isPreparingAnswer && ( +
+ + Preparing answer… +
+ )} + {error && ( +
+ + Error generating answer: + + {error} If the problem persists after retrying, please contact support. + + +
+ +
+
+ )} +
+ ); +} + +function ChatInterface({ initialQuery }: { initialQuery?: string }) { + const [message, setMessage] = useState(""); + const [isExpanded, setIsExpanded] = useState(false); + const hasSubmittedInitialQuery = useRef(false); + const { + conversation, + submitQuery, + isGeneratingAnswer, + isPreparingAnswer, + resetConversation, + stopGeneration, + error, + addFeedback, + } = useChat(); + + useEffect(() => { + if (initialQuery && !hasSubmittedInitialQuery.current) { + hasSubmittedInitialQuery.current = true; + setIsExpanded(true); + submitQuery(initialQuery); + } + }, [initialQuery, submitQuery]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (message.trim()) { + setIsExpanded(true); + submitQuery(message); + setMessage(""); + } + }; + + const handleExampleClick = (question: string) => { + setIsExpanded(true); + submitQuery(question); + }; + + const handleReset = () => { + resetConversation(); + setIsExpanded(false); + }; + + return ( + + +
+
+ setMessage(e.target.value)} + placeholder="Ask a question..." + disabled={isGeneratingAnswer} + autoFocus + className="flex-1 rounded-md border border-grid-bright bg-background-dimmed px-3 py-2 text-text-bright placeholder:text-text-dimmed focus-visible:focus-custom" + /> + {isGeneratingAnswer ? ( + stopGeneration()} + className="group relative z-10 flex size-10 min-w-10 cursor-pointer items-center justify-center" + > + + + + } + content="Stop generating" + /> + ) : isPreparingAnswer ? ( + + + + ) : ( +
+
+
+ ); +} + +function GradientSpinnerBackground({ + children, + className, + hoverEffect = false, +}: { + children?: React.ReactNode; + className?: string; + hoverEffect?: boolean; +}) { + return ( +
+
+ {children} +
+
+ ); +} + +export function AskAIButton({ question }: { question?: string }) { + const { openAskAI } = useAskAI(); + + return ( + + + +
+ +
+
+ + Ask AI + +
+
+ ); +} diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index d9170d68e9..0cf70c0153 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -22,11 +22,13 @@ import { useNavigation } from "@remix-run/react"; import { useEffect, useRef, useState, type ReactNode } from "react"; import simplur from "simplur"; import { AISparkleIcon } from "~/assets/icons/AISparkleIcon"; +import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; import { RunsIconExtraSmall } from "~/assets/icons/RunsIcon"; import { TaskIconSmall } from "~/assets/icons/TaskIcon"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; import { Avatar } from "~/components/primitives/Avatar"; import { type MatchedEnvironment } from "~/hooks/useEnvironment"; +import { useFeatures } from "~/hooks/useFeatures"; import { type MatchedOrganization } from "~/hooks/useOrganizations"; import { type MatchedProject } from "~/hooks/useProject"; import { useHasAdminAccess } from "~/hooks/useUser"; @@ -61,7 +63,7 @@ import { v3UsagePath, v3WaitpointTokensPath, } from "~/utils/pathBuilder"; -import { useKapaWidget } from "../../hooks/useKapaWidget"; +import { useAskAI } from "../AskAI"; import { FreePlanUsage } from "../billing/FreePlanUsage"; import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence"; import { ImpersonationBanner } from "../ImpersonationBanner"; @@ -80,14 +82,12 @@ import { TextLink } from "../primitives/TextLink"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip"; import { ShortcutsAutoOpen } from "../Shortcuts"; import { UserProfilePhoto } from "../UserProfilePhoto"; +import { V4Badge } from "../V4Badge"; import { EnvironmentSelector } from "./EnvironmentSelector"; import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; import { SideMenuSection } from "./SideMenuSection"; -import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; -import { V4Badge } from "../V4Badge"; -import { useFeatures } from "~/hooks/useFeatures"; type SideMenuUser = Pick & { isImpersonating: boolean }; export type SideMenuProject = Pick< @@ -586,12 +586,14 @@ function SelectorDivider() { } function HelpAndAI() { - const { isKapaEnabled, isKapaOpen, openKapa } = useKapaWidget(); + const features = useFeatures(); + const { openAskAI, websiteId } = useAskAI(); + const isKapaEnabled = features.isManagedCloud && websiteId; return ( <> - + {isKapaEnabled && ( @@ -603,9 +605,7 @@ function HelpAndAI() { shortcut={{ modifiers: ["mod"], key: "/", enabledOnInputElements: true }} hideShortcutKey data-modal-override-open-class-ask-ai="true" - onClick={() => { - openKapa(); - }} + onClick={() => openAskAI()} > diff --git a/apps/webapp/app/components/primitives/Dialog.tsx b/apps/webapp/app/components/primitives/Dialog.tsx index 7a78b5e02b..5ac179646b 100644 --- a/apps/webapp/app/components/primitives/Dialog.tsx +++ b/apps/webapp/app/components/primitives/Dialog.tsx @@ -50,22 +50,15 @@ const DialogContent = React.forwardRef< >
{children} - -
- - - Close -
+ + + + Close diff --git a/apps/webapp/app/components/primitives/ShortcutKey.tsx b/apps/webapp/app/components/primitives/ShortcutKey.tsx index acfd599c73..04b1f36737 100644 --- a/apps/webapp/app/components/primitives/ShortcutKey.tsx +++ b/apps/webapp/app/components/primitives/ShortcutKey.tsx @@ -13,8 +13,8 @@ const medium = export const variants = { small: - "text-[0.6rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase", - medium, + "text-[0.6rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase", + medium: cn(medium, "group-hover:border-charcoal-550"), "medium/bright": cn(medium, "bg-charcoal-750 text-text-bright border-charcoal-650"), }; diff --git a/apps/webapp/app/hooks/useKapaWidget.tsx b/apps/webapp/app/hooks/useKapaWidget.tsx deleted file mode 100644 index 3f0f8eef44..0000000000 --- a/apps/webapp/app/hooks/useKapaWidget.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { useMatches, useSearchParams } from "@remix-run/react"; -import { useCallback, useEffect, useState } from "react"; -import { useFeatures } from "~/hooks/useFeatures"; -import { type loader } from "~/root"; -import { useShortcuts } from "../components/primitives/ShortcutsProvider"; -import { useTypedMatchesData } from "./useTypedMatchData"; - -type OpenOptions = { mode: string; query: string; submit: boolean }; - -declare global { - interface Window { - Kapa: ( - command: string, - options?: (() => void) | { onRender?: () => void } | OpenOptions, - remove?: string | { onRender?: () => void } - ) => void; - } -} - -export function KapaScripts({ websiteId }: { websiteId?: string }) { - if (!websiteId) return null; - - return ( - <> - -