feat: add first-time tour guide for new users

- Add tour guide component with floating bubbles
- Guide users through: Welcome -> Configure Models -> Start Gateway -> View Docs
- Use localStorage to persist tour state
- Support i18n (Chinese and English)
- Highlight target elements with spotlight mask
- Allow skipping tour at any time
This commit is contained in:
SiYue-ZO
2026-03-31 00:43:35 +08:00
parent 7b3f47128f
commit b8327462f9
8 changed files with 368 additions and 1 deletions
+9 -1
View File
@@ -163,6 +163,7 @@ export function AppHeader() {
variant="destructive"
size="icon-sm"
className="size-8"
data-tour="gateway-button"
onClick={handleGatewayToggle}
disabled={gwLoading}
aria-label={t("header.gateway.action.stop")}
@@ -178,6 +179,7 @@ export function AppHeader() {
isStarting || isRestarting || isStopping ? "secondary" : "default"
}
size="sm"
data-tour="gateway-button"
className={`h-8 gap-2 px-3 ${
isStopped ? "bg-green-500 text-white hover:bg-green-600" : ""
}`}
@@ -209,7 +211,13 @@ export function AppHeader() {
/>
{/* Docs Link */}
<Button variant="ghost" size="icon" className="size-8" asChild>
<Button
variant="ghost"
size="icon"
className="size-8"
data-tour="docs-button"
asChild
>
<a href="https://docs.picoclaw.io" target="_blank" rel="noreferrer">
<IconBook className="size-4.5" />
</a>
@@ -3,6 +3,7 @@ import { Toaster } from "sonner"
import { AppHeader } from "@/components/app-header"
import { AppSidebar } from "@/components/app-sidebar"
import { TourGuide } from "@/components/tour/tour-guide"
import { SidebarProvider } from "@/components/ui/sidebar"
import { TooltipProvider } from "@/components/ui/tooltip"
@@ -21,6 +22,7 @@ export function AppLayout({ children }: { children: ReactNode }) {
</div>
</div>
<Toaster position="bottom-center" />
<TourGuide />
</SidebarProvider>
</TooltipProvider>
)
@@ -199,6 +199,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarMenuButton
asChild
isActive={isActive}
data-tour={item.url === "/models" ? "models-nav" : undefined}
className={`h-9 px-3 ${isActive ? "bg-accent/80 text-foreground font-medium" : "text-muted-foreground hover:bg-muted/60"}`}
>
<Link to={item.url}>
@@ -0,0 +1,242 @@
import {
IconBook,
IconChevronLeft,
IconChevronRight,
} from "@tabler/icons-react"
import { useAtom } from "jotai"
import { useTranslation } from "react-i18next"
import { Button } from "@/components/ui/button"
import {
tourAtom,
tourCurrentStepAtom,
tourIsActiveAtom,
type TourStep,
useTourActions,
} from "@/store/tour"
import { cn } from "@/lib/utils"
interface TourStepConfig {
title: string
description: string
targetSelector?: string
position: "top" | "bottom" | "left" | "right"
icon?: React.ReactNode
offsetY?: number
}
export function TourGuide() {
const { t } = useTranslation()
const [tourState] = useAtom(tourAtom)
const [, setCurrentStep] = useAtom(tourCurrentStepAtom)
const [, setIsActive] = useAtom(tourIsActiveAtom)
const { goToNextStep, goToPrevStep } = useTourActions()
if (!tourState.isActive || tourState.currentStep === "completed") {
return null
}
const steps: Record<TourStep, TourStepConfig> = {
welcome: {
title: t("tour.welcome.title"),
description: t("tour.welcome.description"),
position: "bottom",
},
models: {
title: t("tour.models.title"),
description: t("tour.models.description"),
targetSelector: "[data-tour='models-nav']",
position: "right",
},
gateway: {
title: t("tour.gateway.title"),
description: t("tour.gateway.description"),
targetSelector: "[data-tour='gateway-button']",
position: "left",
offsetY: 60,
},
docs: {
title: t("tour.docs.title"),
description: t("tour.docs.description"),
targetSelector: "[data-tour='docs-button']",
position: "left",
icon: <IconBook className="size-4" />,
offsetY: 60,
},
completed: {
title: "",
description: "",
position: "bottom",
},
}
const currentConfig = steps[tourState.currentStep]
const stepOrder: TourStep[] = [
"welcome",
"models",
"gateway",
"docs",
"completed",
]
const currentStepIndex = stepOrder.indexOf(tourState.currentStep)
const totalSteps = stepOrder.length - 1
const handleNext = () => {
const nextStep = goToNextStep(tourState.currentStep)
setCurrentStep(nextStep)
if (nextStep === "completed") {
setIsActive(false)
}
}
const handlePrev = () => {
const prevStep = goToPrevStep(tourState.currentStep)
setCurrentStep(prevStep)
}
const handleSkip = () => {
setCurrentStep("completed")
setIsActive(false)
}
const getTargetElement = () => {
if (!currentConfig.targetSelector) return null
return document.querySelector(currentConfig.targetSelector)
}
const targetElement = getTargetElement()
const getPopoverPosition = () => {
if (!targetElement) {
return {
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}
}
const rect = targetElement.getBoundingClientRect()
const offset = 12
const offsetY = currentConfig.offsetY ?? 0
switch (currentConfig.position) {
case "top":
return {
top: rect.top - offset,
left: rect.left + rect.width / 2,
transform: "translate(-50%, -100%)",
}
case "bottom":
return {
top: rect.bottom + offset,
left: rect.left + rect.width / 2,
transform: "translateX(-50%)",
}
case "left":
return {
top: rect.top + rect.height / 2 + offsetY,
left: rect.left - offset,
transform: "translate(-100%, -50%)",
}
case "right":
return {
top: rect.top + rect.height / 2 + offsetY,
left: rect.right + offset,
transform: "translateY(-50%)",
}
default:
return {
top: rect.bottom + offset,
left: rect.left + rect.width / 2,
transform: "translateX(-50%)",
}
}
}
const position = getPopoverPosition()
const isCentered = !targetElement
return (
<>
{targetElement ? (
<div
className="pointer-events-none fixed z-[100] transition-all duration-300"
style={{
top: targetElement.getBoundingClientRect().top - 8,
left: targetElement.getBoundingClientRect().left - 8,
width: targetElement.getBoundingClientRect().width + 16,
height: targetElement.getBoundingClientRect().height + 16,
boxShadow:
"0 0 0 9999px rgba(0, 0, 0, 0.2), 0 0 2px 9999px rgba(0, 0, 0, 0.1)",
borderRadius: "12px",
}}
/>
) : (
<div className="fixed inset-0 z-[100] bg-black/20 backdrop-blur-[2px]" />
)}
{targetElement && (
<div
className="pointer-events-none fixed z-[101] rounded-lg ring-2 ring-primary ring-offset-2 ring-offset-background transition-all duration-300"
style={{
top: targetElement.getBoundingClientRect().top - 4,
left: targetElement.getBoundingClientRect().left - 4,
width: targetElement.getBoundingClientRect().width + 8,
height: targetElement.getBoundingClientRect().height + 8,
}}
/>
)}
<div
className={cn(
"fixed z-[102] w-80 rounded-xl border bg-background p-4 shadow-2xl",
isCentered && "max-w-md",
)}
style={position}
>
<div className="mb-3 flex items-center gap-2">
{currentConfig.icon}
<h3 className="font-semibold">{currentConfig.title}</h3>
</div>
<p className="text-muted-foreground mb-4 text-sm leading-relaxed">
{currentConfig.description}
</p>
<div className="flex items-center justify-between">
<div className="text-muted-foreground text-xs">
{currentStepIndex + 1} / {totalSteps}
</div>
<div className="flex items-center gap-2">
{currentStepIndex > 0 && (
<Button variant="outline" size="sm" onClick={handlePrev}>
<IconChevronLeft className="size-4" />
{t("tour.prev")}
</Button>
)}
<Button size="sm" onClick={handleNext}>
{currentStepIndex === totalSteps - 1
? t("tour.finish")
: t("tour.next")}
{currentStepIndex < totalSteps - 1 && (
<IconChevronRight className="size-4" />
)}
</Button>
</div>
</div>
{currentStepIndex < totalSteps - 1 && (
<Button
variant="link"
size="sm"
className="mt-2 h-auto p-0 text-xs"
onClick={handleSkip}
>
{t("tour.skip")}
</Button>
)}
</div>
</>
)
}
+22
View File
@@ -550,5 +550,27 @@
"clear": "Clear logs",
"empty": "Waiting for logs..."
}
},
"tour": {
"skip": "Skip tour",
"prev": "Previous",
"next": "Next",
"finish": "Finish",
"welcome": {
"title": "Welcome to PicoClaw",
"description": "PicoClaw is a powerful AI assistant platform. Let's take a few seconds to help you complete the basic setup."
},
"models": {
"title": "Configure Models",
"description": "Click the \"Models\" menu on the left to configure API keys for AI providers. Only configured models can be used for chat."
},
"gateway": {
"title": "Start Gateway",
"description": "After configuring models, click the \"Start Gateway\" button at the top to begin chatting with AI."
},
"docs": {
"title": "View Documentation",
"description": "Need more help? Click the documentation button in the top right corner to view detailed guides and configuration docs."
}
}
}
+22
View File
@@ -550,5 +550,27 @@
"clear": "清空日志",
"empty": "等待日志中..."
}
},
"tour": {
"skip": "跳过引导",
"prev": "上一步",
"next": "下一步",
"finish": "完成",
"welcome": {
"title": "欢迎使用 PicoClaw",
"description": "PicoClaw 是一个强大的 AI 助手平台。让我们花几秒钟时间,帮您完成基础配置。"
},
"models": {
"title": "配置模型",
"description": "点击左侧「模型」菜单,为 AI 服务商配置 API Key。只有配置好的模型才能用于对话。"
},
"gateway": {
"title": "启动服务",
"description": "配置好模型后,点击顶部的「启动服务」按钮,即可开始与 AI 对话。"
},
"docs": {
"title": "查看文档",
"description": "需要更多帮助?点击右上角的文档按钮,查看详细的使用文档和配置指南。"
}
}
}
+1
View File
@@ -1,2 +1,3 @@
export * from "./gateway"
export * from "./chat"
export * from "./tour"
+69
View File
@@ -0,0 +1,69 @@
import { atom } from "jotai"
import { atomWithStorage } from "jotai/utils"
export type TourStep = "welcome" | "models" | "gateway" | "docs" | "completed"
export interface TourState {
currentStep: TourStep
isActive: boolean
}
const STORAGE_KEY = "picoclaw-tour-state"
const DEFAULT_TOUR_STATE: TourState = {
currentStep: "welcome",
isActive: true,
}
export const tourAtom = atomWithStorage<TourState>(
STORAGE_KEY,
DEFAULT_TOUR_STATE,
)
export const tourIsActiveAtom = atom(
(get) => get(tourAtom).isActive,
(get, set, isActive: boolean) => {
set(tourAtom, { ...get(tourAtom), isActive })
},
)
export const tourCurrentStepAtom = atom(
(get) => get(tourAtom).currentStep,
(get, set, step: TourStep) => {
set(tourAtom, { ...get(tourAtom), currentStep: step })
},
)
export function useTourActions() {
const goToNextStep = (currentStep: TourStep): TourStep => {
const steps: TourStep[] = [
"welcome",
"models",
"gateway",
"docs",
"completed",
]
const currentIndex = steps.indexOf(currentStep)
if (currentIndex < steps.length - 1) {
return steps[currentIndex + 1]
}
return "completed"
}
const goToPrevStep = (currentStep: TourStep): TourStep => {
const steps: TourStep[] = [
"welcome",
"models",
"gateway",
"docs",
"completed",
]
const currentIndex = steps.indexOf(currentStep)
if (currentIndex > 0) {
return steps[currentIndex - 1]
}
return currentStep
}
return { goToNextStep, goToPrevStep }
}