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 (
<svg
<svg
width="24"
height="24"
viewBox="0 0 24 24"
@ -10,7 +16,7 @@ const DogIcon = (props: React.SVGProps<SVGSVGElement>) => {
>
<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"
stroke="#0F62FE"
stroke={strokeColor}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"

View file

@ -1,6 +1,7 @@
import { Bot, GitBranch } from "lucide-react";
import { MarkdownRenderer } from "@/components/markdown-renderer";
import { FunctionCalls } from "./function-calls";
import { Message } from "./message";
import type { FunctionCall } from "../types";
import DogIcon from "@/components/logo/dog-icon";
@ -29,24 +30,14 @@ export function AssistantMessage({
const IconComponent = updatedOnboarding ? DogIcon : Bot;
return (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none">
<IconComponent className="h-4 w-4 text-accent-foreground" />
</div>
<div className="flex-1 min-w-0">
<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>
{showForkButton && onFork && (
<div className="flex-shrink-0 ml-2">
<Message
icon={
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none">
<IconComponent className="h-4 w-4 text-accent-foreground" />
</div>
}
actions={
showForkButton && onFork ? (
<button
onClick={onFork}
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" />
</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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useAuth } from "@/contexts/auth-context";
import { Message } from "./message";
interface UserMessageProps {
content: string;
@ -10,22 +11,23 @@ export function UserMessage({ content }: UserMessageProps) {
const { user } = useAuth();
return (
<div className="flex gap-3">
<Avatar className="w-8 h-8 flex-shrink-0 select-none">
<AvatarImage draggable={false} src={user?.picture} alt={user?.name} />
<AvatarFallback className="text-sm bg-primary/20 text-primary">
{user?.name ? (
user.name.charAt(0).toUpperCase()
) : (
<User className="h-4 w-4" />
)}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<p className="text-foreground whitespace-pre-wrap break-words overflow-wrap-anywhere">
{content}
</p>
</div>
</div>
<Message
icon={
<Avatar className="w-8 h-8 flex-shrink-0 select-none">
<AvatarImage draggable={false} src={user?.picture} alt={user?.name} />
<AvatarFallback className="text-sm bg-primary/20 text-primary">
{user?.name ? (
user.name.charAt(0).toUpperCase()
) : (
<User className="h-4 w-4" />
)}
</AvatarFallback>
</Avatar>
}
>
<p className="text-foreground whitespace-pre-wrap break-words overflow-wrap-anywhere">
{content}
</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 OllamaLogo from "@/components/logo/ollama-logo";
import {
Accordion,
AccordionContent,
@ -39,6 +38,8 @@ export function AdvancedOnboarding({
languageModels !== undefined &&
languageModel !== undefined &&
setLanguageModel !== undefined;
const updatedOnboarding = process.env.UPDATED_ONBOARDING === "true";
return (
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
@ -74,18 +75,20 @@ export function AdvancedOnboarding({
/>
</LabelWrapper>
)}
{(hasLanguageModels || hasEmbeddingModels) && <Separator />}
<LabelWrapper
label="Sample dataset"
description="Load sample data to chat with immediately."
id="sample-dataset"
flex
>
<Switch
checked={sampleDataset}
onCheckedChange={setSampleDataset}
/>
</LabelWrapper>
{(hasLanguageModels || hasEmbeddingModels) && !updatedOnboarding && <Separator />}
{!updatedOnboarding && (
<LabelWrapper
label="Sample dataset"
description="Load sample data to chat with immediately."
id="sample-dataset"
flex
>
<Switch
checked={sampleDataset}
onCheckedChange={setSampleDataset}
/>
</LabelWrapper>
)}
</AccordionContent>
</AccordionItem>
</Accordion>

View file

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

View file

@ -1,14 +1,28 @@
"use client";
import { Suspense } from "react";
import { DoclingHealthBanner, useDoclingHealth } from "@/components/docling-health-banner";
import { Suspense, useEffect } from "react";
import { useRouter } from "next/navigation";
import { DoclingHealthBanner } from "@/components/docling-health-banner";
import { ProtectedRoute } from "@/components/protected-route";
import { DotPattern } from "@/components/ui/dot-pattern";
import { cn } from "@/lib/utils";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import OnboardingCard from "./components/onboarding-card";
function OnboardingPage() {
const { isHealthy: isDoclingHealthy } = useDoclingHealth();
function LegacyOnboardingPage() {
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 (
<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
</h1>
</div>
<OnboardingCard isDoclingHealthy={isDoclingHealthy} />
<OnboardingCard onComplete={handleComplete} />
</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() {
return (
<ProtectedRoute>
<Suspense fallback={<div>Loading onboarding...</div>}>
<OnboardingPage />
<OnboardingRouter />
</Suspense>
</ProtectedRoute>
);

View file

@ -55,7 +55,7 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
};
// 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 isOnKnowledgePage = pathname.startsWith("/knowledge");

View file

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