Files
triggerdotdev--trigger.dev/apps/webapp/app/components/AskAI.tsx
T
2026-07-13 13:32:57 +08:00

546 lines
18 KiB
TypeScript

import {
ArrowPathIcon,
ArrowUpIcon,
HandThumbDownIcon,
HandThumbUpIcon,
StopIcon,
} from "@heroicons/react/20/solid";
import { cn } from "~/utils/cn";
import { type FeedbackComment, KapaProvider, type QA, useChat } from "@kapaai/react-sdk";
import { useSearchParams } from "@remix-run/react";
import DOMPurify from "dompurify";
import { motion } from "framer-motion";
import { marked } from "marked";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTypedRouteLoaderData } from "remix-typedjson";
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
import { useFeatures } from "~/hooks/useFeatures";
import { type loader } from "~/root";
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 { ShortcutKey } from "./primitives/ShortcutKey";
import { Spinner } from "./primitives/Spinner";
import {
SimpleTooltip,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "./primitives/Tooltip";
import { ClientOnly } from "remix-utils/client-only";
function useKapaWebsiteId() {
const routeMatch = useTypedRouteLoaderData<typeof loader>("root");
return routeMatch?.kapa.websiteId;
}
export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) {
const { isManagedCloud } = useFeatures();
const websiteId = useKapaWebsiteId();
if (!isManagedCloud || !websiteId) {
return null;
}
return (
<ClientOnly
fallback={
<Button
variant="small-menu-item"
data-action="ask-ai"
hideShortcutKey
data-modal-override-open-class-ask-ai="true"
disabled
className={isCollapsed ? "w-full justify-center" : ""}
>
<AISparkleIcon className="size-5" />
</Button>
}
>
{() => <AskAIProvider websiteId={websiteId} isCollapsed={isCollapsed} />}
</ClientOnly>
);
}
type AskAIProviderProps = {
websiteId: string;
isCollapsed?: boolean;
};
function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) {
const [isOpen, setIsOpen] = useState(false);
const [initialQuery, setInitialQuery] = useState<string | undefined>();
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) {
// Delay to avoid hCaptcha bot detection
window.setTimeout(() => openAskAI(aiHelp), 1000);
// Clone instead of mutating in place
const next = new URLSearchParams(searchParams);
next.delete("aiHelp");
setSearchParams(next);
}
}, [searchParams, openAskAI]);
return (
<KapaProvider
integrationId={websiteId}
callbacks={{
askAI: {
onQuerySubmit: () => openAskAI(),
onAnswerGenerationCompleted: () => openAskAI(),
},
}}
botProtectionMechanism="hcaptcha"
>
<motion.div layout="position" transition={{ duration: 0.2, ease: "easeInOut" }}>
<TooltipProvider disableHoverableContent>
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("inline-flex h-8", isCollapsed && "w-full")}>
<Button
variant="small-menu-item"
data-action="ask-ai"
shortcut={{ modifiers: ["mod"], key: "i", enabledOnInputElements: true }}
hideShortcutKey
data-modal-override-open-class-ask-ai="true"
onClick={() => openAskAI()}
fullWidth={isCollapsed}
className={cn("h-full", isCollapsed && "justify-center")}
>
<AISparkleIcon className="size-5" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8} className="flex items-center gap-2 text-xs">
Ask AI
<span className="flex items-center">
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</motion.div>
<AskAIDialog
initialQuery={initialQuery}
isOpen={isOpen}
onOpenChange={setIsOpen}
closeAskAI={closeAskAI}
/>
</KapaProvider>
);
}
type AskAIDialogProps = {
initialQuery?: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
closeAskAI: () => void;
};
function AskAIDialog({ initialQuery, isOpen, onOpenChange, closeAskAI }: AskAIDialogProps) {
const handleOpenChange = (open: boolean) => {
if (!open) {
closeAskAI();
} else {
onOpenChange(open);
}
};
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="animated-gradient-glow flex max-h-[90vh] min-h-fit w-full flex-col justify-between gap-0 px-0 pb-0 pt-0 sm:max-w-prose">
<DialogHeader className="flex h-11 items-start justify-center rounded-t-md bg-background-bright pl-3">
<div className="flex items-center gap-1">
<AISparkleIcon className="size-5" />
<DialogTitle className="text-sm font-medium text-text-bright">Ask AI</DialogTitle>
</div>
</DialogHeader>
<ChatInterface initialQuery={initialQuery} />
</DialogContent>
</Dialog>
);
}
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<Set<string>>(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 (
<div className="flex-1 overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
{conversation.length === 0 ? (
<motion.div
className="flex flex-col gap-2 pb-2"
initial="hidden"
animate="visible"
variants={{
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
}}
>
<Paragraph className="mb-3 mt-1.5 pl-1">
I'm trained on docs, examples, and other content. Ask me anything about Trigger.dev.
</Paragraph>
{exampleQuestions.map((question, index) => (
<motion.button
key={index}
className="group flex w-fit items-center gap-2 rounded-full border border-dashed border-border-bright px-4 py-2 transition-colors hover:border-solid hover:border-indigo-500"
onClick={() => 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,
},
},
},
}}
>
<SparkleListIcon className="size-4 text-text-dimmed transition group-hover:text-indigo-500" />
<Paragraph variant="small" className="transition group-hover:text-text-bright">
{question}
</Paragraph>
</motion.button>
))}
</motion.div>
) : (
conversation.map((qa) => (
<div key={qa.id || `temp-${qa.question}`} className="mb-4">
<Header2 spacing>{qa.question}</Header2>
<div
className="prose prose-invert max-w-none text-text-dimmed"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(marked(qa.answer)) }}
/>
</div>
))
)}
{conversation.length > 0 &&
!isPreparingAnswer &&
!isGeneratingAnswer &&
!error &&
!latestQA?.id && (
<div className="flex items-center justify-between border-t border-grid-bright pt-3">
<Paragraph variant="small" className="text-text-dimmed">
Answer generation was stopped
</Paragraph>
<Button
variant="minimal/small"
LeadingIcon={<ArrowPathIcon className="size-4" />}
onClick={onReset}
className="w-fit pl-1.5"
iconSpacing="gap-x-1.5"
>
Reset chat
</Button>
</div>
)}
{conversation.length > 0 &&
!isPreparingAnswer &&
!isGeneratingAnswer &&
!error &&
latestQA?.id && (
<div className="flex items-center justify-between border-t border-grid-bright pt-3">
{hasFeedbackForLatestQA ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
<Paragraph variant="small" className="text-text-dimmed">
Thanks for your feedback!
</Paragraph>
</motion.div>
) : (
<div className="flex items-center gap-2">
<Paragraph variant="small" className="text-text-dimmed">
Was this helpful?
</Paragraph>
<div className="flex items-center">
<Button
variant="minimal/small"
onClick={() => {
const latestQA = conversation[conversation.length - 1];
if (latestQA?.id) {
addFeedback(latestQA.id, "upvote");
setFeedbackGivenForQAs((prev) => new Set(prev).add(latestQA.id));
}
}}
className="size-8 px-1.5"
>
<HandThumbUpIcon className="size-4 text-text-dimmed transition group-hover/button:text-success" />
</Button>
<Button
variant="minimal/small"
onClick={() => {
const latestQA = conversation[conversation.length - 1];
if (latestQA?.id) {
addFeedback(latestQA.id, "downvote");
setFeedbackGivenForQAs((prev) => new Set(prev).add(latestQA.id));
}
}}
className="size-8 px-1.5"
>
<HandThumbDownIcon className="size-4 text-text-dimmed transition group-hover/button:text-error" />
</Button>
</div>
</div>
)}
<Button
variant="minimal/small"
LeadingIcon={<ArrowPathIcon className="size-4" />}
onClick={onReset}
className="w-fit pl-1.5"
iconSpacing="gap-x-1.5"
>
Reset chat
</Button>
</div>
)}
{isPreparingAnswer && (
<div className="flex items-center gap-2">
<Spinner
color={{
background: "rgba(99, 102, 241, 1)",
foreground: "rgba(217, 70, 239, 1)",
}}
className="size-4"
/>
<Paragraph className="text-text-dimmed">Preparing answer</Paragraph>
</div>
)}
{error && (
<div className="flex flex-col">
<Callout variant="error" className="mb-4">
<Paragraph className="font-semibold text-error">Error generating answer:</Paragraph>
<Paragraph className="text-rose-300">
{error} If the problem persists after retrying, please contact support.
</Paragraph>
</Callout>
<div className="flex justify-end">
<Button
variant="secondary/small"
LeadingIcon={<ArrowPathIcon className="size-4" />}
onClick={onReset}
className="w-fit pl-1.5"
iconSpacing="gap-x-1.5"
>
Reset chat
</Button>
</div>
</div>
)}
</div>
);
}
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 (
<motion.div
className="flex h-full max-h-[90vh] grow flex-col overflow-y-auto rounded-b-md bg-background-bright"
animate={{ height: isExpanded ? "90vh" : "auto" }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
initial={{ height: "auto" }}
>
<ChatMessages
conversation={conversation}
isPreparingAnswer={isPreparingAnswer}
isGeneratingAnswer={isGeneratingAnswer}
onReset={handleReset}
onExampleClick={handleExampleClick}
error={error}
addFeedback={addFeedback}
/>
<form onSubmit={handleSubmit} className="shrink-0 border-t border-grid-bright p-4">
<div className="flex gap-3">
<input
type="text"
value={message}
onChange={(e) => 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 ? (
<SimpleTooltip
button={
<span
onClick={() => stopGeneration()}
className="group relative z-10 flex size-10 min-w-10 cursor-pointer items-center justify-center"
>
<StopIcon className="z-10 size-5 text-indigo-500 transition group-hover:text-indigo-400" />
<GradientSpinnerBackground
className="absolute inset-0 animate-spin"
hoverEffect
/>
</span>
}
content="Stop generating"
/>
) : isPreparingAnswer ? (
<GradientSpinnerBackground className="flex size-10 min-w-10 items-center justify-center">
<Spinner
color={{
background: "rgba(99, 102, 241, 1)",
foreground: "rgba(217, 70, 239, 1)",
}}
className="size-5"
/>
</GradientSpinnerBackground>
) : (
<Button
type="submit"
disabled={!message.trim()}
LeadingIcon={<ArrowUpIcon className="size-5 text-text-bright" />}
variant="primary/large"
className="size-10 min-w-10 rounded-full group-disabled/button:border-border-brighter group-disabled/button:bg-surface-control"
/>
)}
</div>
</form>
</motion.div>
);
}
function GradientSpinnerBackground({
children,
className,
hoverEffect = false,
}: {
children?: React.ReactNode;
className?: string;
hoverEffect?: boolean;
}) {
return (
<div
className={`flex rounded-full bg-linear-to-br from-indigo-500 via-purple-500 to-fuchsia-500 p-px ${className}`}
>
<div
className={`flex h-full w-full items-center justify-center rounded-full bg-surface-control ${
hoverEffect ? "transition group-hover:bg-surface-control-hover" : ""
}`}
>
{children}
</div>
</div>
);
}