mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Merge pull request #2196 from SiYue-ZO/feature/tour-guide
feat: add first-time tour guide for new users
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,2 +1,3 @@
|
||||
export * from "./gateway"
|
||||
export * from "./chat"
|
||||
export * from "./tour"
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user