create new onboarding experience

This commit is contained in:
Mike Fortman 2025-10-15 13:55:02 -05:00
parent 391d2671a0
commit 71edefc710
12 changed files with 349 additions and 85 deletions

View file

@ -1,6 +1,12 @@
const DogIcon = (props: React.SVGProps<SVGSVGElement>) => { interface DogIconProps extends React.SVGProps<SVGSVGElement> {
disabled?: boolean;
}
const DogIcon = ({ disabled = false, stroke, ...props }: DogIconProps) => {
const strokeColor = disabled ? "#71717A" : (stroke || "#0F62FE");
return ( return (
<svg <svg
width="24" width="24"
height="24" height="24"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -10,7 +16,7 @@ const DogIcon = (props: React.SVGProps<SVGSVGElement>) => {
> >
<path <path
d="M19.9049 23H17.907C17.907 23 15.4096 20.5 16.908 16C17.3753 14.2544 17.3813 12.4181 17.2439 11C17.161 10.1434 17.0256 9.43934 16.908 9C16.7416 8.33333 16.8081 7 18.4065 7C19.5457 7 20.9571 6.92944 21.4034 6.5C22.3268 5.61145 21.9029 4 21.9029 4C21.9029 4 20.9039 3 18.906 3C18.7395 2.33333 17.7072 1 14.9101 1C12.113 1 11.5835 2.16589 10.9143 4C10.4155 5.36686 10.423 6.99637 11.1692 7.71747M14.4106 4C14.2441 5.33333 14.4106 8 11.9132 8C11.5968 8 11.3534 7.89548 11.1692 7.71747M14.9101 23H12.4127M7.91738 23H12.4127M10.4148 15.5C11.5715 16.1667 13.5905 18.6 12.4127 23M3.42204 15C1.02177 18.5 1.64205 23 5.41997 23C5.41997 22 5.71966 19.2 6.91841 16C8.41686 12 11.1692 11.4349 11.1692 7.71747M16.908 4V4.5" d="M19.9049 23H17.907C17.907 23 15.4096 20.5 16.908 16C17.3753 14.2544 17.3813 12.4181 17.2439 11C17.161 10.1434 17.0256 9.43934 16.908 9C16.7416 8.33333 16.8081 7 18.4065 7C19.5457 7 20.9571 6.92944 21.4034 6.5C22.3268 5.61145 21.9029 4 21.9029 4C21.9029 4 20.9039 3 18.906 3C18.7395 2.33333 17.7072 1 14.9101 1C12.113 1 11.5835 2.16589 10.9143 4C10.4155 5.36686 10.423 6.99637 11.1692 7.71747M14.4106 4C14.2441 5.33333 14.4106 8 11.9132 8C11.5968 8 11.3534 7.89548 11.1692 7.71747M14.9101 23H12.4127M7.91738 23H12.4127M10.4148 15.5C11.5715 16.1667 13.5905 18.6 12.4127 23M3.42204 15C1.02177 18.5 1.64205 23 5.41997 23C5.41997 22 5.71966 19.2 6.91841 16C8.41686 12 11.1692 11.4349 11.1692 7.71747M16.908 4V4.5"
stroke="#0F62FE" stroke={strokeColor}
strokeWidth="1.5" strokeWidth="1.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"

View file

@ -1,6 +1,7 @@
import { Bot, GitBranch } from "lucide-react"; import { Bot, GitBranch } from "lucide-react";
import { MarkdownRenderer } from "@/components/markdown-renderer"; import { MarkdownRenderer } from "@/components/markdown-renderer";
import { FunctionCalls } from "./function-calls"; import { FunctionCalls } from "./function-calls";
import { Message } from "./message";
import type { FunctionCall } from "../types"; import type { FunctionCall } from "../types";
import DogIcon from "@/components/logo/dog-icon"; import DogIcon from "@/components/logo/dog-icon";
@ -29,24 +30,14 @@ export function AssistantMessage({
const IconComponent = updatedOnboarding ? DogIcon : Bot; const IconComponent = updatedOnboarding ? DogIcon : Bot;
return ( return (
<div className="flex gap-3"> <Message
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none"> icon={
<IconComponent className="h-4 w-4 text-accent-foreground" /> <div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none">
</div> <IconComponent className="h-4 w-4 text-accent-foreground" />
<div className="flex-1 min-w-0"> </div>
<FunctionCalls }
functionCalls={functionCalls} actions={
messageIndex={messageIndex} showForkButton && onFork ? (
expandedFunctionCalls={expandedFunctionCalls}
onToggle={onToggle}
/>
<MarkdownRenderer chatMessage={content} />
{isStreaming && (
<span className="inline-block w-2 h-4 bg-blue-400 ml-1 animate-pulse"></span>
)}
</div>
{showForkButton && onFork && (
<div className="flex-shrink-0 ml-2">
<button <button
onClick={onFork} onClick={onFork}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-accent rounded text-muted-foreground hover:text-foreground" className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-accent rounded text-muted-foreground hover:text-foreground"
@ -54,8 +45,19 @@ export function AssistantMessage({
> >
<GitBranch className="h-3 w-3" /> <GitBranch className="h-3 w-3" />
</button> </button>
</div> ) : undefined
}
>
<FunctionCalls
functionCalls={functionCalls}
messageIndex={messageIndex}
expandedFunctionCalls={expandedFunctionCalls}
onToggle={onToggle}
/>
<MarkdownRenderer chatMessage={content} />
{isStreaming && (
<span className="inline-block w-2 h-4 bg-blue-400 ml-1 animate-pulse"></span>
)} )}
</div> </Message>
); );
} }

View file

@ -0,0 +1,17 @@
import { ReactNode } from "react";
interface MessageProps {
icon: ReactNode;
children: ReactNode;
actions?: ReactNode;
}
export function Message({ icon, children, actions }: MessageProps) {
return (
<div className="flex gap-3">
{icon}
<div className="flex-1 min-w-0">{children}</div>
{actions && <div className="flex-shrink-0 ml-2">{actions}</div>}
</div>
);
}

View file

@ -1,6 +1,7 @@
import { User } from "lucide-react"; import { User } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useAuth } from "@/contexts/auth-context"; import { useAuth } from "@/contexts/auth-context";
import { Message } from "./message";
interface UserMessageProps { interface UserMessageProps {
content: string; content: string;
@ -10,22 +11,23 @@ export function UserMessage({ content }: UserMessageProps) {
const { user } = useAuth(); const { user } = useAuth();
return ( return (
<div className="flex gap-3"> <Message
<Avatar className="w-8 h-8 flex-shrink-0 select-none"> icon={
<AvatarImage draggable={false} src={user?.picture} alt={user?.name} /> <Avatar className="w-8 h-8 flex-shrink-0 select-none">
<AvatarFallback className="text-sm bg-primary/20 text-primary"> <AvatarImage draggable={false} src={user?.picture} alt={user?.name} />
{user?.name ? ( <AvatarFallback className="text-sm bg-primary/20 text-primary">
user.name.charAt(0).toUpperCase() {user?.name ? (
) : ( user.name.charAt(0).toUpperCase()
<User className="h-4 w-4" /> ) : (
)} <User className="h-4 w-4" />
</AvatarFallback> )}
</Avatar> </AvatarFallback>
<div className="flex-1"> </Avatar>
<p className="text-foreground whitespace-pre-wrap break-words overflow-wrap-anywhere"> }
{content} >
</p> <p className="text-foreground whitespace-pre-wrap break-words overflow-wrap-anywhere">
</div> {content}
</div> </p>
</Message>
); );
} }

View file

@ -0,0 +1,78 @@
import { ReactNode, useEffect, useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { Message } from "@/app/chat/components/message";
import DogIcon from "@/components/logo/dog-icon";
interface OnboardingStepProps {
text: string;
children: ReactNode;
isVisible: boolean;
isCompleted?: boolean;
}
export function OnboardingStep({ text, children, isVisible, isCompleted = false }: OnboardingStepProps) {
const [displayedText, setDisplayedText] = useState("");
const [showChildren, setShowChildren] = useState(false);
useEffect(() => {
if (!isVisible) {
setDisplayedText("");
setShowChildren(false);
return;
}
let currentIndex = 0;
setDisplayedText("");
setShowChildren(false);
const interval = setInterval(() => {
if (currentIndex < text.length) {
setDisplayedText(text.slice(0, currentIndex + 1));
currentIndex++;
} else {
clearInterval(interval);
setShowChildren(true);
}
}, 10); // 10ms per character
return () => clearInterval(interval);
}, [text, isVisible]);
if (!isVisible) return null;
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className={isCompleted ? "opacity-50" : ""}
>
<Message
icon={
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none">
<DogIcon className="h-6 w-6 text-accent-foreground" disabled={isCompleted} />
</div>
}
>
<div className="space-y-4">
<p className={`text-foreground text-sm py-1.5 ${isCompleted ? "text-placeholder-foreground" : ""}`}>
{displayedText}
{!showChildren && !isCompleted && <span className="inline-block w-1 h-4 bg-primary ml-1 animate-pulse" />}
</p>
<AnimatePresence>
{showChildren && !isCompleted && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
{children}
</motion.div>
)}
</AnimatePresence>
</div>
</Message>
</motion.div>
);
}

View file

@ -0,0 +1,27 @@
interface ProgressBarProps {
currentStep: number;
totalSteps: number;
}
export function ProgressBar({ currentStep, totalSteps }: ProgressBarProps) {
const progressPercentage = ((currentStep + 1) / totalSteps) * 100;
return (
<div className="w-full">
<div className="flex items-center max-w-md mx-auto gap-3">
<div className="flex-1 h-2 bg-background rounded-full overflow-hidden">
<div
className="h-full transition-all duration-300 ease-in-out"
style={{
width: `${progressPercentage}%`,
background: 'linear-gradient(to right, #818CF8, #F472B6)'
}}
/>
</div>
<span className="text-sm text-muted-foreground whitespace-nowrap">
{currentStep + 1}/{totalSteps}
</span>
</div>
</div>
);
}

View file

@ -0,0 +1,109 @@
"use client";
import { Suspense, useState } from "react";
import { ProtectedRoute } from "@/components/protected-route";
import { DoclingHealthBanner } from "@/components/docling-health-banner";
import { DotPattern } from "@/components/ui/dot-pattern";
import { cn } from "@/lib/utils";
import { OnboardingStep } from "./components/onboarding-step";
import { ProgressBar } from "./components/progress-bar";
import OnboardingCard from "../onboarding/components/onboarding-card";
const TOTAL_STEPS = 4;
function NewOnboardingPage() {
const [currentStep, setCurrentStep] = useState(0);
const handleStepComplete = () => {
if (currentStep < TOTAL_STEPS - 1) {
setCurrentStep(currentStep + 1);
}
};
return (
<div className="min-h-dvh w-full flex gap-5 flex-col items-center justify-center bg-primary-foreground relative p-4">
<DoclingHealthBanner className="absolute top-0 left-0 right-0 w-full z-20" />
{/* Chat-like content area */}
<div className="flex flex-col items-center gap-5 w-full max-w-3xl z-10">
<div className="w-full h-[872px] bg-background border rounded-lg p-4 shadow-sm overflow-y-auto">
<div className="space-y-6">
<OnboardingStep
isVisible={currentStep >= 0}
isCompleted={currentStep > 0}
text="Let's get started by setting up your model provider."
>
<OnboardingCard onComplete={handleStepComplete} />
</OnboardingStep>
<OnboardingStep
isVisible={currentStep >= 1}
isCompleted={currentStep > 1}
text="Step 1: Configure your settings"
>
<div className="space-y-4">
<p className="text-muted-foreground">
Let's configure some basic settings for your account.
</p>
<button
onClick={handleStepComplete}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
>
Continue
</button>
</div>
</OnboardingStep>
<OnboardingStep
isVisible={currentStep >= 2}
isCompleted={currentStep > 2}
text="Step 2: Connect your model"
>
<div className="space-y-4">
<p className="text-muted-foreground">
Choose and connect your preferred AI model provider.
</p>
<button
onClick={handleStepComplete}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
>
Continue
</button>
</div>
</OnboardingStep>
<OnboardingStep
isVisible={currentStep >= 3}
isCompleted={currentStep > 3}
text="Step 3: You're all set!"
>
<div className="space-y-4">
<p className="text-muted-foreground">
Your account is ready to use. Let's start chatting!
</p>
<button
onClick={() => window.location.href = "/chat"}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
>
Go to Chat
</button>
</div>
</OnboardingStep>
</div>
</div>
<ProgressBar currentStep={currentStep} totalSteps={TOTAL_STEPS} />
</div>
</div>
);
}
export default function ProtectedNewOnboardingPage() {
return (
<ProtectedRoute>
<Suspense fallback={<div>Loading...</div>}>
<NewOnboardingPage />
</Suspense>
</ProtectedRoute>
);
}

View file

@ -1,5 +1,4 @@
import { LabelWrapper } from "@/components/label-wrapper"; import { LabelWrapper } from "@/components/label-wrapper";
import OllamaLogo from "@/components/logo/ollama-logo";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
@ -39,6 +38,8 @@ export function AdvancedOnboarding({
languageModels !== undefined && languageModels !== undefined &&
languageModel !== undefined && languageModel !== undefined &&
setLanguageModel !== undefined; setLanguageModel !== undefined;
const updatedOnboarding = process.env.UPDATED_ONBOARDING === "true";
return ( return (
<Accordion type="single" collapsible> <Accordion type="single" collapsible>
<AccordionItem value="item-1"> <AccordionItem value="item-1">
@ -74,18 +75,20 @@ export function AdvancedOnboarding({
/> />
</LabelWrapper> </LabelWrapper>
)} )}
{(hasLanguageModels || hasEmbeddingModels) && <Separator />} {(hasLanguageModels || hasEmbeddingModels) && !updatedOnboarding && <Separator />}
<LabelWrapper {!updatedOnboarding && (
label="Sample dataset" <LabelWrapper
description="Load sample data to chat with immediately." label="Sample dataset"
id="sample-dataset" description="Load sample data to chat with immediately."
flex id="sample-dataset"
> flex
<Switch >
checked={sampleDataset} <Switch
onCheckedChange={setSampleDataset} checked={sampleDataset}
/> onCheckedChange={setSampleDataset}
</LabelWrapper> />
</LabelWrapper>
)}
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>

View file

@ -1,13 +1,12 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
type OnboardingVariables, type OnboardingVariables,
useOnboardingMutation, useOnboardingMutation,
} from "@/app/api/mutations/useOnboardingMutation"; } from "@/app/api/mutations/useOnboardingMutation";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery"; import { useDoclingHealth } from "@/components/docling-health-banner";
import IBMLogo from "@/components/logo/ibm-logo"; import IBMLogo from "@/components/logo/ibm-logo";
import OllamaLogo from "@/components/logo/ollama-logo"; import OllamaLogo from "@/components/logo/ollama-logo";
import OpenAILogo from "@/components/logo/openai-logo"; import OpenAILogo from "@/components/logo/openai-logo";
@ -28,24 +27,13 @@ import { IBMOnboarding } from "./ibm-onboarding";
import { OllamaOnboarding } from "./ollama-onboarding"; import { OllamaOnboarding } from "./ollama-onboarding";
import { OpenAIOnboarding } from "./openai-onboarding"; import { OpenAIOnboarding } from "./openai-onboarding";
const OnboardingCard = ({ interface OnboardingCardProps {
isDoclingHealthy, onComplete: () => void;
}: { }
isDoclingHealthy: boolean;
}) => { const OnboardingCard = ({ onComplete }: OnboardingCardProps) => {
const updatedOnboarding = process.env.UPDATED_ONBOARDING === "true"; const updatedOnboarding = process.env.UPDATED_ONBOARDING === "true";
const { data: settingsDb, isLoading: isSettingsLoading } = const { isHealthy: isDoclingHealthy } = useDoclingHealth();
useGetSettingsQuery();
const redirect = "/";
const router = useRouter();
// Redirect if already authenticated or in no-auth mode
useEffect(() => {
if (!isSettingsLoading && settingsDb && settingsDb.edited) {
router.push(redirect);
}
}, [isSettingsLoading, settingsDb, router]);
const [modelProvider, setModelProvider] = useState<string>("openai"); const [modelProvider, setModelProvider] = useState<string>("openai");
@ -71,7 +59,7 @@ const OnboardingCard = ({
const onboardingMutation = useOnboardingMutation({ const onboardingMutation = useOnboardingMutation({
onSuccess: (data) => { onSuccess: (data) => {
console.log("Onboarding completed successfully", data); console.log("Onboarding completed successfully", data);
router.push(redirect); onComplete();
}, },
onError: (error) => { onError: (error) => {
toast.error("Failed to complete onboarding", { toast.error("Failed to complete onboarding", {
@ -124,7 +112,7 @@ const OnboardingCard = ({
defaultValue={modelProvider} defaultValue={modelProvider}
onValueChange={handleSetModelProvider} onValueChange={handleSetModelProvider}
> >
<CardHeader> <CardHeader className={`${updatedOnboarding ? "px-0" : ""}`}>
<TabsList> <TabsList>
<TabsTrigger value="openai"> <TabsTrigger value="openai">
<OpenAILogo className="w-4 h-4" /> <OpenAILogo className="w-4 h-4" />
@ -140,7 +128,7 @@ const OnboardingCard = ({
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</CardHeader> </CardHeader>
<CardContent> <CardContent className={`${updatedOnboarding ? "px-0" : ""}`}>
<TabsContent value="openai"> <TabsContent value="openai">
<OpenAIOnboarding <OpenAIOnboarding
setSettings={setSettings} setSettings={setSettings}
@ -164,7 +152,7 @@ const OnboardingCard = ({
</TabsContent> </TabsContent>
</CardContent> </CardContent>
</Tabs> </Tabs>
<CardFooter className={`flex ${updatedOnboarding ? "" : "justify-end"}`}> <CardFooter className={`flex ${updatedOnboarding ? "px-0" : "justify-end"}`}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div> <div>

View file

@ -1,14 +1,28 @@
"use client"; "use client";
import { Suspense } from "react"; import { Suspense, useEffect } from "react";
import { DoclingHealthBanner, useDoclingHealth } from "@/components/docling-health-banner"; import { useRouter } from "next/navigation";
import { DoclingHealthBanner } from "@/components/docling-health-banner";
import { ProtectedRoute } from "@/components/protected-route"; import { ProtectedRoute } from "@/components/protected-route";
import { DotPattern } from "@/components/ui/dot-pattern"; import { DotPattern } from "@/components/ui/dot-pattern";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import OnboardingCard from "./components/onboarding-card"; import OnboardingCard from "./components/onboarding-card";
function OnboardingPage() { function LegacyOnboardingPage() {
const { isHealthy: isDoclingHealthy } = useDoclingHealth(); const router = useRouter();
const { data: settingsDb, isLoading: isSettingsLoading } = useGetSettingsQuery();
// Redirect if already completed onboarding
useEffect(() => {
if (!isSettingsLoading && settingsDb && settingsDb.edited) {
router.push("/");
}
}, [isSettingsLoading, settingsDb, router]);
const handleComplete = () => {
router.push("/");
};
return ( return (
<div className="min-h-dvh w-full flex gap-5 flex-col items-center justify-center bg-background relative p-4"> <div className="min-h-dvh w-full flex gap-5 flex-col items-center justify-center bg-background relative p-4">
@ -32,17 +46,34 @@ function OnboardingPage() {
Connect a model provider Connect a model provider
</h1> </h1>
</div> </div>
<OnboardingCard isDoclingHealthy={isDoclingHealthy} /> <OnboardingCard onComplete={handleComplete} />
</div> </div>
</div> </div>
); );
} }
function OnboardingRouter() {
const updatedOnboarding = process.env.UPDATED_ONBOARDING === "true";
const router = useRouter();
useEffect(() => {
if (updatedOnboarding) {
router.push("/new-onboarding");
}
}, [updatedOnboarding, router]);
if (updatedOnboarding) {
return null;
}
return <LegacyOnboardingPage />;
}
export default function ProtectedOnboardingPage() { export default function ProtectedOnboardingPage() {
return ( return (
<ProtectedRoute> <ProtectedRoute>
<Suspense fallback={<div>Loading onboarding...</div>}> <Suspense fallback={<div>Loading onboarding...</div>}>
<OnboardingPage /> <OnboardingRouter />
</Suspense> </Suspense>
</ProtectedRoute> </ProtectedRoute>
); );

View file

@ -55,7 +55,7 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
}; };
// List of paths that should not show navigation // List of paths that should not show navigation
const authPaths = ["/login", "/auth/callback", "/onboarding"]; const authPaths = ["/login", "/auth/callback", "/onboarding", "/new-onboarding"];
const isAuthPage = authPaths.includes(pathname); const isAuthPage = authPaths.includes(pathname);
const isOnKnowledgePage = pathname.startsWith("/knowledge"); const isOnKnowledgePage = pathname.startsWith("/knowledge");

View file

@ -39,7 +39,8 @@ export function ProtectedRoute({ children }: ProtectedRouteProps) {
} }
if (!isLoading && !isSettingsLoading && !settings.edited) { if (!isLoading && !isSettingsLoading && !settings.edited) {
router.push("/onboarding"); const updatedOnboarding = process.env.UPDATED_ONBOARDING === "true";
router.push(updatedOnboarding ? "/new-onboarding" : "/onboarding");
} }
}, [ }, [
isLoading, isLoading,