feat: add user onboarding guide system with driver.js

- Add OnboardingProvider with driver.js integration for guided tours
- Implement multi-page navigation support (home -> settings -> provider)
- Add selectApplicableGuides logic for new users and upgrades
- Use MutationObserver for reliable element detection (replaces setTimeout)
- Add Redux slice for onboarding state persistence
- Add v1.7.0 onboarding guide with 8 steps:
  - Welcome, free model intro, settings navigation
  - Add provider, fill API key, add model
  - Use cases overview, completion
- Add i18n translations for all supported languages
- Add unit tests for selectApplicableGuides function
- Add element IDs for onboarding targets (#sidebar-settings, #navbar-settings,
  #add-provider-btn, #add-model-btn, #api-key-input)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
suyao 2026-01-12 05:32:59 +08:00
parent c7c380d706
commit 5f5f055979
No known key found for this signature in database
31 changed files with 1519 additions and 14 deletions

View File

@ -226,6 +226,7 @@
"@types/react-infinite-scroll-component": "^5.0.0",
"@types/react-transition-group": "^4.4.12",
"@types/react-window": "^1",
"@types/semver": "^7.7.1",
"@types/swagger-jsdoc": "^6",
"@types/swagger-ui-express": "^4.1.8",
"@types/tinycolor2": "^1",
@ -277,6 +278,7 @@
"dompurify": "^3.2.6",
"dotenv": "16.6.1",
"dotenv-cli": "^7.4.2",
"driver.js": "^1.4.0",
"drizzle-kit": "^0.31.4",
"drizzle-orm": "^0.44.5",
"electron": "38.7.0",

30
pnpm-lock.yaml generated
View File

@ -563,6 +563,9 @@ importers:
'@types/react-window':
specifier: ^1
version: 1.8.8
'@types/semver':
specifier: ^7.7.1
version: 7.7.1
'@types/swagger-jsdoc':
specifier: ^6
version: 6.0.4
@ -716,6 +719,9 @@ importers:
dotenv-cli:
specifier: ^7.4.2
version: 7.4.4
driver.js:
specifier: ^1.4.0
version: 1.4.0
drizzle-kit:
specifier: ^0.31.4
version: 0.31.8
@ -5287,6 +5293,9 @@ packages:
'@types/retry@0.12.0':
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
'@types/semver@7.7.1':
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
'@types/send@1.2.1':
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
@ -6968,6 +6977,9 @@ packages:
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
engines: {node: '>=12'}
driver.js@1.4.0:
resolution: {integrity: sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==}
drizzle-kit@0.31.8:
resolution: {integrity: sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg==}
hasBin: true
@ -17013,6 +17025,8 @@ snapshots:
'@types/retry@0.12.0': {}
'@types/semver@7.7.1': {}
'@types/send@1.2.1':
dependencies:
'@types/node': 22.17.2
@ -17720,11 +17734,11 @@ snapshots:
- utf-8-validate
- vite
'@vitest/browser@3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(playwright@1.57.0)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)':
'@vitest/browser@3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(playwright@1.57.0)(rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)':
dependencies:
'@testing-library/dom': 10.4.1
'@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1)
'@vitest/mocker': 3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/mocker': 3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/utils': 3.2.4
magic-string: 0.30.21
sirv: 3.0.2
@ -17778,14 +17792,14 @@ snapshots:
msw: 2.12.7(@types/node@22.17.2)(typescript@5.8.3)
vite: rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/mocker@3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))':
'@vitest/mocker@3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.12.7(@types/node@24.10.4)(typescript@5.8.3)
vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)
vite: rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/pretty-format@3.2.4':
dependencies:
@ -17816,7 +17830,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(tsx@4.21.0)(yaml@2.8.2)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.17.2)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(msw@2.12.7(@types/node@22.17.2)(typescript@5.8.3))(tsx@4.21.0)(yaml@2.8.2)
'@vitest/utils@3.2.4':
dependencies:
@ -19287,6 +19301,8 @@ snapshots:
dotenv@17.2.3: {}
driver.js@1.4.0: {}
drizzle-kit@0.31.8:
dependencies:
'@drizzle-team/brocli': 0.10.2
@ -25271,7 +25287,7 @@ snapshots:
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/mocker': 3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@ -25295,7 +25311,7 @@ snapshots:
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 24.10.4
'@vitest/browser': 3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(playwright@1.57.0)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)
'@vitest/browser': 3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(playwright@1.57.0)(rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)
'@vitest/ui': 3.2.4(vitest@3.2.4)
jsdom: 26.1.0
transitivePeerDependencies:

View File

@ -6,6 +6,7 @@ import { HashRouter, Route, Routes } from 'react-router-dom'
import Sidebar from './components/app/Sidebar'
import { ErrorBoundary } from './components/ErrorBoundary'
import { OnboardingProvider } from './components/Onboarding'
import TabsContainer from './components/Tab/TabContainer'
import NavigationHandler from './handler/NavigationHandler'
import { useNavbarPosition } from './hooks/useSettings'
@ -49,17 +50,21 @@ const Router: FC = () => {
if (navbarPosition === 'left') {
return (
<HashRouter>
<Sidebar />
{routes}
<NavigationHandler />
<OnboardingProvider>
<Sidebar />
{routes}
<NavigationHandler />
</OnboardingProvider>
</HashRouter>
)
}
return (
<HashRouter>
<NavigationHandler />
<TabsContainer>{routes}</TabsContainer>
<OnboardingProvider>
<NavigationHandler />
<TabsContainer>{routes}</TabsContainer>
</OnboardingProvider>
</HashRouter>
)
}

View File

@ -0,0 +1,223 @@
import 'driver.js/dist/driver.css'
import './styles/driver-theme.css'
import { loggerService } from '@logger'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { completeFeatureGuide, completeOnboarding, skipOnboarding } from '@renderer/store/onboarding'
import type { GuideStep, VersionGuide } from '@renderer/types/onboarding'
import { type Driver, driver, type DriveStep } from 'driver.js'
import type { FC, PropsWithChildren } from 'react'
import { createContext, use, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router-dom'
const logger = loggerService.withContext('Onboarding')
interface OnboardingContextType {
startGuide: (guide: VersionGuide) => void
skipGuide: () => void
isGuideActive: boolean
currentGuide: VersionGuide | null
completedOnboardingVersion: string | null
completedFeatureGuides: string[]
onboardingSkipped: boolean
}
const OnboardingContext = createContext<OnboardingContextType>({
startGuide: () => {},
skipGuide: () => {},
isGuideActive: false,
currentGuide: null,
completedOnboardingVersion: null,
completedFeatureGuides: [],
onboardingSkipped: false
})
function resolveElement(step: GuideStep): Element | string | undefined {
if (!step.element) return undefined
if (typeof step.element === 'function') {
return step.element() ?? undefined
}
return step.element
}
function isOnRoute(currentPath: string, targetPath: string): boolean {
// Special case for root path - must match exactly
if (targetPath === '/') {
return currentPath === '/'
}
return currentPath === targetPath || currentPath.startsWith(targetPath + '/')
}
export const OnboardingProvider: FC<PropsWithChildren> = ({ children }) => {
const { theme } = useTheme()
const { t } = useTranslation()
const dispatch = useAppDispatch()
const location = useLocation()
const navigate = useNavigate()
const [isGuideActive, setIsGuideActive] = useState(false)
const [currentGuide, setCurrentGuide] = useState<VersionGuide | null>(null)
const driverRef = useRef<Driver | null>(null)
const guideRef = useRef<{ guide: VersionGuide | null; steps: GuideStep[] }>({ guide: null, steps: [] })
const pathRef = useRef(location.pathname)
const navigatingRef = useRef(false)
// Keep pathRef in sync
pathRef.current = location.pathname
const { completedOnboardingVersion, completedFeatureGuides, onboardingSkipped } = useAppSelector(
(state) => state.onboarding
)
const finishGuide = useCallback(
(wasCompleted: boolean) => {
const guide = guideRef.current.guide
if (!guide) return
const action = wasCompleted ? 'completed' : 'skipped'
logger.info(`Guide ${action}`, { version: guide.version, type: guide.type })
if (guide.type === 'onboarding') {
dispatch(wasCompleted ? completeOnboarding(guide.version) : skipOnboarding(guide.version))
} else {
dispatch(completeFeatureGuide(guide.version))
}
setIsGuideActive(false)
setCurrentGuide(null)
guideRef.current = { guide: null, steps: [] }
},
[dispatch]
)
const createDriverSteps = useCallback(
(guideSteps: GuideStep[]): DriveStep[] =>
guideSteps.map((step) => ({
element: resolveElement(step),
popover: {
title: t(step.titleKey),
description: t(step.descriptionKey),
side: step.side === 'over' ? undefined : step.side,
align: step.align
}
})),
[t]
)
const createAndStartDriver = useCallback(
(fromStepIndex: number) => {
const { steps: guideSteps } = guideRef.current
if (!guideSteps.length) return
const steps = createDriverSteps(guideSteps)
const driverInstance = driver({
animate: true,
showProgress: true,
overlayColor: theme === 'dark' ? 'rgba(0, 0, 0, 0.75)' : 'rgba(255, 255, 255, 0.75)',
stagePadding: 10,
stageRadius: 8,
allowClose: true,
smoothScroll: true,
progressText: t('onboarding.progress'),
nextBtnText: t('onboarding.next'),
prevBtnText: t('onboarding.previous'),
doneBtnText: t('onboarding.done'),
popoverClass: `cherry-driver-popover ${theme}`,
steps,
onHighlightStarted: () => {
// Skip if we just navigated (waiting for re-drive)
if (navigatingRef.current) return
const stepIndex = driverRef.current?.getActiveIndex() ?? 0
const guideStep = guideRef.current.steps[stepIndex]
const targetPath = guideStep?.navigateTo
if (!targetPath || isOnRoute(pathRef.current, targetPath)) return
logger.info('Navigating to', { route: targetPath, stepId: guideStep.id })
navigatingRef.current = true
navigate(targetPath)
// After navigation, re-drive from same step to re-resolve elements
setTimeout(() => {
navigatingRef.current = false
driverRef.current?.drive(stepIndex)
}, 200)
},
onDestroyStarted: () => {
if (navigatingRef.current) return
const wasCompleted = driverRef.current?.isLastStep() ?? false
finishGuide(wasCompleted)
driverRef.current?.destroy()
}
})
driverRef.current = driverInstance
driverInstance.drive(fromStepIndex)
},
[theme, t, navigate, createDriverSteps, finishGuide]
)
const startGuide = useCallback(
(guide: VersionGuide) => {
if (isGuideActive) {
logger.warn('Guide already active, ignoring request')
return
}
logger.info('Starting guide', { version: guide.version, type: guide.type })
setCurrentGuide(guide)
guideRef.current = { guide, steps: guide.steps }
setIsGuideActive(true)
createAndStartDriver(0)
},
[isGuideActive, createAndStartDriver]
)
const skipGuide = useCallback(() => {
if (driverRef.current) {
driverRef.current.destroy()
}
}, [])
// Cleanup on unmount
useEffect(() => {
return () => {
if (driverRef.current) {
driverRef.current.destroy()
}
}
}, [])
const contextValue = useMemo(
() => ({
startGuide,
skipGuide,
isGuideActive,
currentGuide,
completedOnboardingVersion,
completedFeatureGuides,
onboardingSkipped
}),
[
startGuide,
skipGuide,
isGuideActive,
currentGuide,
completedOnboardingVersion,
completedFeatureGuides,
onboardingSkipped
]
)
return <OnboardingContext value={contextValue}>{children}</OnboardingContext>
}
export const useOnboarding = () => use(OnboardingContext)

View File

@ -0,0 +1 @@
export { OnboardingProvider, useOnboarding } from './OnboardingProvider'

View File

@ -0,0 +1,131 @@
/* Cherry Studio driver.js theme customization */
/* Base popover styles using CSS variables */
/* popoverClass is applied directly to .driver-popover element */
.driver-popover.cherry-driver-popover {
background-color: var(--color-background) !important;
color: var(--color-text) !important;
border: 1px solid var(--color-border) !important;
border-radius: 12px !important;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15) !important;
max-width: 360px !important;
font-family: var(--font-family) !important;
}
.driver-popover.cherry-driver-popover .driver-popover-title {
color: var(--color-text) !important;
font-size: 16px !important;
font-weight: 600 !important;
margin-bottom: 8px !important;
}
.driver-popover.cherry-driver-popover .driver-popover-description {
color: var(--color-text-2) !important;
font-size: 14px !important;
line-height: 1.6 !important;
}
.driver-popover.cherry-driver-popover .driver-popover-progress-text {
color: var(--color-text-3) !important;
font-size: 12px !important;
}
/* Button styles */
.driver-popover.cherry-driver-popover .driver-popover-navigation-btns {
gap: 8px !important;
}
.driver-popover.cherry-driver-popover .driver-popover-next-btn,
.driver-popover.cherry-driver-popover .driver-popover-prev-btn,
.driver-popover.cherry-driver-popover .driver-popover-close-btn {
border-radius: 6px !important;
padding: 8px 16px !important;
font-size: 13px !important;
font-weight: 500 !important;
transition: all 0.2s ease !important;
text-shadow: none !important;
-webkit-font-smoothing: antialiased !important;
-moz-osx-font-smoothing: grayscale !important;
}
/* Remove any pseudo-elements that might cause text duplication */
.driver-popover.cherry-driver-popover .driver-popover-next-btn::before,
.driver-popover.cherry-driver-popover .driver-popover-next-btn::after,
.driver-popover.cherry-driver-popover .driver-popover-prev-btn::before,
.driver-popover.cherry-driver-popover .driver-popover-prev-btn::after {
content: none !important;
display: none !important;
}
.driver-popover.cherry-driver-popover .driver-popover-next-btn {
background-color: var(--color-primary) !important;
color: var(--color-white) !important;
border: none !important;
}
.driver-popover.cherry-driver-popover .driver-popover-next-btn:hover {
opacity: 0.9 !important;
}
.driver-popover.cherry-driver-popover .driver-popover-prev-btn {
background-color: transparent !important;
color: var(--color-text-2) !important;
border: 1px solid var(--color-border) !important;
}
.driver-popover.cherry-driver-popover .driver-popover-prev-btn:hover {
background-color: var(--color-background-soft) !important;
}
.driver-popover.cherry-driver-popover .driver-popover-close-btn {
background-color: transparent !important;
color: var(--color-text-3) !important;
border: none !important;
}
.driver-popover.cherry-driver-popover .driver-popover-close-btn:hover {
color: var(--color-text) !important;
}
/* Dark theme specific adjustments */
.driver-popover.cherry-driver-popover.dark {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4) !important;
}
/* Light theme specific adjustments */
.driver-popover.cherry-driver-popover.light {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1) !important;
}
/* Highlight stage */
.driver-active-element {
border-radius: 8px !important;
}
/* Arrow styles */
.driver-popover.cherry-driver-popover .driver-popover-arrow {
border-color: var(--color-background) !important;
}
.driver-popover.cherry-driver-popover .driver-popover-arrow-side-left {
border-right-color: var(--color-background) !important;
}
.driver-popover.cherry-driver-popover .driver-popover-arrow-side-right {
border-left-color: var(--color-background) !important;
}
.driver-popover.cherry-driver-popover .driver-popover-arrow-side-top {
border-bottom-color: var(--color-background) !important;
}
.driver-popover.cherry-driver-popover .driver-popover-arrow-side-bottom {
border-top-color: var(--color-background) !important;
}
/* Footer area */
.driver-popover.cherry-driver-popover .driver-popover-footer {
margin-top: 16px !important;
padding-top: 12px !important;
border-top: 1px solid var(--color-border) !important;
}

View File

@ -283,7 +283,7 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
)}
</ThemeButton>
</Tooltip>
<SettingsButton onClick={handleSettingsClick} $active={activeTabId === 'settings'}>
<SettingsButton id="navbar-settings" onClick={handleSettingsClick} $active={activeTabId === 'settings'}>
<Settings size={16} />
</SettingsButton>
</RightButtonsContainer>

View File

@ -102,6 +102,7 @@ const Sidebar: FC = () => {
</Tooltip>
<Tooltip title={t('settings.title')} mouseEnterDelay={0.8} placement="right">
<StyledLink
id="sidebar-settings"
onClick={async () => {
hideMinappPopup()
await to('/settings/provider')

View File

@ -0,0 +1,253 @@
import type { VersionGuide } from '@renderer/types/onboarding'
import { describe, expect, it, vi } from 'vitest'
// Mock the allGuides to control test data
vi.mock('../guides', () => ({
allGuides: [] as VersionGuide[]
}))
import { allGuides } from '../guides'
import { selectApplicableGuides } from '../index'
// Helper to set mock guides
function setMockGuides(guides: VersionGuide[]) {
;(allGuides as VersionGuide[]).length = 0
;(allGuides as VersionGuide[]).push(...guides)
}
// Test fixture: minimal guide factory
function createGuide(overrides: Partial<VersionGuide> & Pick<VersionGuide, 'version' | 'type'>): VersionGuide {
return {
titleKey: 'test.title',
descriptionKey: 'test.description',
steps: [],
...overrides
}
}
describe('selectApplicableGuides', () => {
describe('New User (completedOnboardingVersion === null && !onboardingSkipped)', () => {
it('should return latest onboarding guide for new user', () => {
setMockGuides([
createGuide({ version: '1.6.0', type: 'onboarding' }),
createGuide({ version: '1.7.0', type: 'onboarding' }),
createGuide({ version: '1.5.0', type: 'onboarding' })
])
const result = selectApplicableGuides('1.7.0', null, [], false)
expect(result.isNewUser).toBe(true)
expect(result.previousVersion).toBe(null)
expect(result.guides).toHaveLength(1)
expect(result.guides[0].version).toBe('1.7.0')
})
it('should only return onboarding guides, not feature guides', () => {
setMockGuides([
createGuide({ version: '1.7.0', type: 'onboarding' }),
createGuide({ version: '1.7.0', type: 'feature' }),
createGuide({ version: '1.8.0', type: 'feature' })
])
const result = selectApplicableGuides('1.8.0', null, [], false)
expect(result.guides).toHaveLength(1)
expect(result.guides[0].type).toBe('onboarding')
})
it('should return empty array when no onboarding guides exist', () => {
setMockGuides([
createGuide({ version: '1.7.0', type: 'feature' }),
createGuide({ version: '1.8.0', type: 'feature' })
])
const result = selectApplicableGuides('1.8.0', null, [], false)
expect(result.isNewUser).toBe(true)
expect(result.guides).toHaveLength(0)
})
})
describe('Skipped User (onboardingSkipped === true)', () => {
it('should return no guides when user has skipped', () => {
setMockGuides([
createGuide({ version: '1.7.0', type: 'onboarding' }),
createGuide({ version: '1.8.0', type: 'feature' })
])
const result = selectApplicableGuides('1.8.0', null, [], true)
expect(result.isNewUser).toBe(false)
expect(result.guides).toHaveLength(0)
})
it('should preserve previousVersion when user has skipped', () => {
setMockGuides([createGuide({ version: '1.7.0', type: 'onboarding' })])
const result = selectApplicableGuides('1.8.0', '1.6.0', [], true)
expect(result.previousVersion).toBe('1.6.0')
})
})
describe('Upgrade User (completed onboarding, checking feature guides)', () => {
it('should return feature guides newer than completed version', () => {
setMockGuides([
createGuide({ version: '1.7.0', type: 'onboarding' }),
createGuide({ version: '1.7.5', type: 'feature' }),
createGuide({ version: '1.8.0', type: 'feature' })
])
const result = selectApplicableGuides('1.8.0', '1.7.0', [], false)
expect(result.isNewUser).toBe(false)
expect(result.previousVersion).toBe('1.7.0')
expect(result.guides).toHaveLength(2)
expect(result.guides.map((g) => g.version)).toEqual(['1.7.5', '1.8.0'])
})
it('should not return feature guides older than completed version', () => {
setMockGuides([
createGuide({ version: '1.5.0', type: 'feature' }),
createGuide({ version: '1.6.0', type: 'feature' }),
createGuide({ version: '1.8.0', type: 'feature' })
])
const result = selectApplicableGuides('1.8.0', '1.7.0', [], false)
expect(result.guides).toHaveLength(1)
expect(result.guides[0].version).toBe('1.8.0')
})
it('should not return feature guides newer than current app version', () => {
setMockGuides([
createGuide({ version: '1.8.0', type: 'feature' }),
createGuide({ version: '1.9.0', type: 'feature' }),
createGuide({ version: '2.0.0', type: 'feature' })
])
const result = selectApplicableGuides('1.8.0', '1.7.0', [], false)
expect(result.guides).toHaveLength(1)
expect(result.guides[0].version).toBe('1.8.0')
})
it('should exclude already completed feature guides', () => {
setMockGuides([
createGuide({ version: '1.7.5', type: 'feature' }),
createGuide({ version: '1.8.0', type: 'feature' })
])
const result = selectApplicableGuides('1.8.0', '1.7.0', ['1.7.5'], false)
expect(result.guides).toHaveLength(1)
expect(result.guides[0].version).toBe('1.8.0')
})
it('should not return onboarding guides for upgrade users', () => {
setMockGuides([
createGuide({ version: '1.8.0', type: 'onboarding' }),
createGuide({ version: '1.8.0', type: 'feature' })
])
const result = selectApplicableGuides('1.8.0', '1.7.0', [], false)
expect(result.guides).toHaveLength(1)
expect(result.guides[0].type).toBe('feature')
})
})
describe('Sorting', () => {
it('should sort feature guides by version ascending', () => {
setMockGuides([
createGuide({ version: '1.9.0', type: 'feature' }),
createGuide({ version: '1.7.5', type: 'feature' }),
createGuide({ version: '1.8.0', type: 'feature' })
])
const result = selectApplicableGuides('1.9.0', '1.7.0', [], false)
expect(result.guides.map((g) => g.version)).toEqual(['1.7.5', '1.8.0', '1.9.0'])
})
it('should sort by priority (descending) when versions are equal', () => {
setMockGuides([
createGuide({ version: '1.8.0', type: 'feature', priority: 10 }),
createGuide({ version: '1.8.0', type: 'feature', priority: 50 }),
createGuide({ version: '1.8.0', type: 'feature', priority: 30 })
])
const result = selectApplicableGuides('1.8.0', '1.7.0', [], false)
expect(result.guides.map((g) => g.priority)).toEqual([50, 30, 10])
})
it('should handle guides without priority (default to 0)', () => {
setMockGuides([
createGuide({ version: '1.8.0', type: 'feature' }),
createGuide({ version: '1.8.0', type: 'feature', priority: 20 })
])
const result = selectApplicableGuides('1.8.0', '1.7.0', [], false)
expect(result.guides[0].priority).toBe(20)
expect(result.guides[1].priority).toBeUndefined()
})
it('should sort onboarding guides by version descending (latest first)', () => {
setMockGuides([
createGuide({ version: '1.5.0', type: 'onboarding' }),
createGuide({ version: '1.8.0', type: 'onboarding' }),
createGuide({ version: '1.7.0', type: 'onboarding' })
])
const result = selectApplicableGuides('1.8.0', null, [], false)
// Only returns the latest one
expect(result.guides).toHaveLength(1)
expect(result.guides[0].version).toBe('1.8.0')
})
})
describe('Edge Cases', () => {
it('should return empty array when no guides exist', () => {
setMockGuides([])
const newUserResult = selectApplicableGuides('1.8.0', null, [], false)
expect(newUserResult.guides).toHaveLength(0)
const upgradeResult = selectApplicableGuides('1.8.0', '1.7.0', [], false)
expect(upgradeResult.guides).toHaveLength(0)
})
it('should handle semver pre-release versions', () => {
setMockGuides([
createGuide({ version: '1.8.0-beta.1', type: 'feature' }),
createGuide({ version: '1.8.0', type: 'feature' })
])
const result = selectApplicableGuides('1.8.0', '1.7.0', [], false)
// Pre-release < release for same version
expect(result.guides.map((g) => g.version)).toEqual(['1.8.0-beta.1', '1.8.0'])
})
it('should handle feature guide version equal to completed version (not shown)', () => {
setMockGuides([createGuide({ version: '1.7.0', type: 'feature' })])
const result = selectApplicableGuides('1.8.0', '1.7.0', [], false)
// 1.7.0 is not > 1.7.0, so should not be included
expect(result.guides).toHaveLength(0)
})
it('should handle current version equal to feature guide version', () => {
setMockGuides([createGuide({ version: '1.8.0', type: 'feature' })])
const result = selectApplicableGuides('1.8.0', '1.7.0', [], false)
// 1.8.0 <= 1.8.0, so should be included
expect(result.guides).toHaveLength(1)
})
})
})

View File

@ -0,0 +1,3 @@
import { v170Onboarding } from './v1.7.0-onboarding'
export const allGuides = [v170Onboarding]

View File

@ -0,0 +1,79 @@
import type { VersionGuide } from '@renderer/types/onboarding'
/**
* Initial onboarding guide for new users
* Covers: free built-in model, adding providers, adding models, use cases
*/
export const v170Onboarding: VersionGuide = {
version: '1.7.0',
type: 'onboarding',
titleKey: 'onboarding.welcome.title',
descriptionKey: 'onboarding.welcome.description',
route: '/',
priority: 100,
steps: [
{
id: 'welcome',
titleKey: 'onboarding.steps.welcome.title',
descriptionKey: 'onboarding.steps.welcome.description',
side: 'over'
},
{
id: 'free-model',
element: '.home-navbar',
titleKey: 'onboarding.steps.freeModel.title',
descriptionKey: 'onboarding.steps.freeModel.description',
side: 'bottom',
align: 'center'
},
{
id: 'settings-intro',
// TODO: improve selector to work in both sidebar and top nav layouts
element: () => document.querySelector('#sidebar-settings') || document.querySelector('#navbar-settings'),
titleKey: 'onboarding.steps.settingsIntro.title',
descriptionKey: 'onboarding.steps.settingsIntro.description',
side: 'bottom',
align: 'center'
},
{
id: 'add-provider',
navigateTo: '/settings/provider',
element: '#add-provider-btn',
titleKey: 'onboarding.steps.addProvider.title',
descriptionKey: 'onboarding.steps.addProvider.description',
side: 'top',
align: 'center'
},
{
id: 'fill-api-key',
element: '#api-key-input',
titleKey: 'onboarding.steps.fillApiKey.title',
descriptionKey: 'onboarding.steps.fillApiKey.description',
side: 'bottom',
align: 'start'
},
{
id: 'add-model',
element: '#add-model-btn',
titleKey: 'onboarding.steps.addModel.title',
descriptionKey: 'onboarding.steps.addModel.description',
side: 'bottom',
align: 'center'
},
{
id: 'use-cases',
navigateTo: '/',
element: '#inputbar',
titleKey: 'onboarding.steps.useCases.title',
descriptionKey: 'onboarding.steps.useCases.description',
side: 'top',
align: 'center'
},
{
id: 'complete',
titleKey: 'onboarding.steps.complete.title',
descriptionKey: 'onboarding.steps.complete.description',
side: 'over'
}
]
}

View File

@ -0,0 +1,68 @@
import type { VersionGuide } from '@renderer/types/onboarding'
import { compare } from 'semver'
import { allGuides } from './guides'
export interface GuideSelectionResult {
guides: VersionGuide[]
isNewUser: boolean
previousVersion: string | null
}
/**
* Select applicable guides based on user's onboarding history
*/
export function selectApplicableGuides(
currentVersion: string,
completedOnboardingVersion: string | null,
completedFeatureGuides: string[],
onboardingSkipped: boolean
): GuideSelectionResult {
const isNewUser = completedOnboardingVersion === null && !onboardingSkipped
if (isNewUser) {
// New user: Show latest onboarding guide only
const onboardingGuides = allGuides
.filter((g) => g.type === 'onboarding')
.sort((a, b) => compare(b.version, a.version))
return {
guides: onboardingGuides.slice(0, 1),
isNewUser: true,
previousVersion: null
}
}
if (onboardingSkipped) {
// User has skipped onboarding, don't show any guides automatically
return {
guides: [],
isNewUser: false,
previousVersion: completedOnboardingVersion
}
}
// Upgrade user: Show feature guides for versions between last completed and current
const pendingFeatureGuides = allGuides
.filter((g) => {
if (g.type !== 'feature') return false
if (completedFeatureGuides.includes(g.version)) return false
// Guide version must be > completed version AND <= current version
return compare(g.version, completedOnboardingVersion!) > 0 && compare(g.version, currentVersion) <= 0
})
.sort((a, b) => {
// Sort by version (ascending) then priority (descending)
const versionCompare = compare(a.version, b.version)
if (versionCompare !== 0) return versionCompare
return (b.priority ?? 0) - (a.priority ?? 0)
})
return {
guides: pendingFeatureGuides,
isNewUser: false,
previousVersion: completedOnboardingVersion
}
}
export { allGuides }

View File

@ -0,0 +1,132 @@
import { loggerService } from '@logger'
import { useOnboarding } from '@renderer/components/Onboarding'
import { selectApplicableGuides } from '@renderer/config/onboarding'
import type { GuideStep, VersionGuide } from '@renderer/types/onboarding'
import { useEffect, useState } from 'react'
import { useLocation } from 'react-router-dom'
const logger = loggerService.withContext('useOnboardingInit')
/**
* Wait for an element to appear in the DOM using MutationObserver
*/
function waitForElement(selector: string, timeout = 3000): Promise<Element | null> {
return new Promise((resolve) => {
const el = document.querySelector(selector)
if (el) return resolve(el)
const observer = new MutationObserver(() => {
const el = document.querySelector(selector)
if (el) {
observer.disconnect()
resolve(el)
}
})
observer.observe(document.body, { childList: true, subtree: true })
setTimeout(() => {
observer.disconnect()
resolve(null)
}, timeout)
})
}
/**
* Get the first element selector from guide steps
*/
function getFirstElementSelector(guide: VersionGuide): string | null {
for (const step of guide.steps) {
if (typeof step.element === 'string') {
return step.element
}
if (typeof step.element === 'function') {
// For function selectors, use a known fallback
return '.home-navbar'
}
}
return null
}
/**
* Check if a step has an element selector (not a modal-style step)
*/
function stepHasElement(step: GuideStep): boolean {
return step.element !== undefined
}
/**
* Hook to initialize and trigger onboarding guides
* Should be called in a component that renders after app is ready
*/
export function useOnboardingInit() {
const location = useLocation()
const { startGuide, isGuideActive, completedOnboardingVersion, completedFeatureGuides, onboardingSkipped } =
useOnboarding()
const [hasTriggered, setHasTriggered] = useState(false)
const [appVersion, setAppVersion] = useState<string | null>(null)
useEffect(() => {
window.api.getAppInfo().then((info) => {
setAppVersion(info.version)
})
}, [])
// Trigger onboarding after first target element is ready
useEffect(() => {
if (!appVersion || hasTriggered || isGuideActive) return
if (location.pathname !== '/') return
let cancelled = false
const triggerOnboarding = async () => {
const result = selectApplicableGuides(
appVersion,
completedOnboardingVersion,
completedFeatureGuides,
onboardingSkipped
)
if (result.guides.length === 0) return
const guide = result.guides[0]
const firstSelector = getFirstElementSelector(guide)
// Wait for first target element if guide has element-based steps
if (firstSelector && guide.steps.some(stepHasElement)) {
const element = await waitForElement(firstSelector)
if (cancelled) return
if (!element) {
logger.warn('First onboarding element not found', { selector: firstSelector })
return
}
}
if (cancelled) return
logger.info('Starting onboarding guide', {
version: guide.version,
type: guide.type,
isNewUser: result.isNewUser
})
startGuide(guide)
setHasTriggered(true)
}
triggerOnboarding()
return () => {
cancelled = true
}
}, [
appVersion,
hasTriggered,
isGuideActive,
location.pathname,
completedOnboardingVersion,
completedFeatureGuides,
onboardingSkipped,
startGuide
])
}

View File

@ -2359,6 +2359,51 @@
},
"title": "Ollama"
},
"onboarding": {
"done": "Get Started",
"next": "Next",
"previous": "Previous",
"progress": "{{current}} of {{total}}",
"skip": "Skip Tour",
"steps": {
"addModel": {
"description": "After adding a provider, click this button to add models. Different models excel at different tasks:\n• GPT-4 for complex reasoning\n• Claude for long-form content\n• GLM for Chinese conversations",
"title": "Add Models"
},
"addProvider": {
"description": "Click this button to add an AI provider. We recommend CherryIN for beginners - sign up to get free credits with access to GPT, Claude, and other popular models.",
"title": "Add Provider"
},
"complete": {
"description": "You're all set! Start chatting with AI now. Explore more features and let AI become your powerful assistant!",
"title": "Start Exploring!"
},
"fillApiKey": {
"description": "Enter your API Key here. You can get the API Key from the provider's website. After filling it in, click the \"Check\" button to verify the connection.",
"title": "Fill in API Key"
},
"freeModel": {
"description": "Cherry Studio includes a free built-in GLM 4.5 Flash model. Click the model selector in the top navbar to switch models. No configuration needed - start chatting right away!",
"title": "Free Model"
},
"settingsIntro": {
"description": "Click here to access settings. Next, we'll guide you through adding AI providers and models.",
"title": "Settings"
},
"useCases": {
"description": "Cherry Studio can help you with various tasks:\n• Intelligent Q&A\n• Document translation & summarization\n• Code writing & debugging\n• Creative writing\n• Knowledge base queries\n\nType your question in the input box to get started!",
"title": "Use Cases"
},
"welcome": {
"description": "Cherry Studio is a powerful AI desktop client supporting multiple AI models. Let's take a quick tour to get you started!",
"title": "Welcome to Cherry Studio!"
}
},
"welcome": {
"description": "Your powerful AI assistant. Let's take a quick tour to help you get started.",
"title": "Welcome to Cherry Studio"
}
},
"ovms": {
"action": {
"install": "Install",

View File

@ -2359,6 +2359,51 @@
},
"title": "Ollama"
},
"onboarding": {
"done": "开始使用",
"next": "下一步",
"previous": "上一步",
"progress": "{{current}} / {{total}}",
"skip": "跳过引导",
"steps": {
"addModel": {
"description": "添加服务商后,点击此按钮为该服务商添加模型。不同模型擅长不同任务:\n• GPT-4 适合复杂推理\n• Claude 适合长文本处理\n• GLM 适合中文对话",
"title": "添加模型"
},
"addProvider": {
"description": "点击此按钮添加 AI 服务商。推荐新手使用 CherryIN注册即可获得免费额度支持 GPT、Claude 等主流模型。",
"title": "添加服务商"
},
"complete": {
"description": "您已准备就绪!现在可以开始与 AI 对话了。探索更多功能,让 AI 成为您的得力助手!",
"title": "开始探索!"
},
"fillApiKey": {
"description": "在此处填入您的 API Key。您可以从服务商官网获取 API Key填写后点击「检查」按钮验证连接。",
"title": "填写 API Key"
},
"freeModel": {
"description": "Cherry Studio 内置了免费的 GLM 4.5 Flash 模型,点击顶部导航栏的模型选择器即可切换使用。无需任何配置,立即开始对话!",
"title": "免费模型"
},
"settingsIntro": {
"description": "点击这里进入设置页面。接下来我们将引导您添加 AI 服务商和模型。",
"title": "设置入口"
},
"useCases": {
"description": "Cherry Studio 可以帮您完成多种任务:\n• 智能对话问答\n• 文档翻译与总结\n• 代码编写与调试\n• 创意写作\n• 知识库问答\n\n在输入框中输入您的问题开始体验吧",
"title": "功能用例"
},
"welcome": {
"description": "Cherry Studio 是一款强大的 AI 桌面客户端,支持多种 AI 模型。让我们快速了解如何开始使用!",
"title": "欢迎使用 Cherry Studio"
}
},
"welcome": {
"description": "您的强大 AI 助手。让我们快速了解一下如何开始使用。",
"title": "欢迎使用 Cherry Studio"
}
},
"ovms": {
"action": {
"install": "安装",

View File

@ -2359,6 +2359,51 @@
},
"title": "Ollama"
},
"onboarding": {
"done": "[to be translated]:Get Started",
"next": "[to be translated]:Next",
"previous": "[to be translated]:Previous",
"progress": "[to be translated]:{{current}} of {{total}}",
"skip": "[to be translated]:Skip Tour",
"steps": {
"addModel": {
"description": "[to be translated]:Add the models you need from the provider page. Different models excel at different tasks like chatting, writing, coding, and more.",
"title": "[to be translated]:Add Models"
},
"addProvider": {
"description": "[to be translated]:Go to Settings to add AI providers. We recommend CherryIN for beginners - get free credits upon registration and access to various popular models.",
"title": "[to be translated]:Add Provider"
},
"complete": {
"description": "[to be translated]:You're all set! Start chatting with your first assistant. Need help? Check Settings for more options.",
"title": "[to be translated]:You're All Set!"
},
"fillApiKey": {
"description": "[to be translated]:Enter your API Key here. You can get the API Key from the provider's website. After filling it in, click the \"Check\" button to verify the connection.",
"title": "[to be translated]:Fill in API Key"
},
"freeModel": {
"description": "[to be translated]:Cherry Studio includes a free built-in GLM 4.5 Flash model. Start chatting right away without any configuration - perfect for a quick trial!",
"title": "[to be translated]:Free Model"
},
"settingsIntro": {
"description": "[to be translated]:Click here to access settings. Next, we'll guide you through adding AI providers and models.",
"title": "[to be translated]:Settings"
},
"useCases": {
"description": "[to be translated]:Cherry Studio can help you with: intelligent Q&A, document translation & summarization, code writing & debugging, creative writing, knowledge base queries, and more.",
"title": "[to be translated]:Use Cases"
},
"welcome": {
"description": "[to be translated]:Cherry Studio helps you interact with various AI models. Let's take a quick tour to help you get started.",
"title": "[to be translated]:Welcome to Cherry Studio!"
}
},
"welcome": {
"description": "[to be translated]:Your powerful AI assistant. Let's take a quick tour to help you get started.",
"title": "[to be translated]:Welcome to Cherry Studio"
}
},
"ovms": {
"action": {
"install": "安裝",

View File

@ -2359,6 +2359,51 @@
},
"title": "Ollama"
},
"onboarding": {
"done": "[to be translated]:Get Started",
"next": "[to be translated]:Next",
"previous": "[to be translated]:Previous",
"progress": "[to be translated]:{{current}} of {{total}}",
"skip": "[to be translated]:Skip Tour",
"steps": {
"addModel": {
"description": "[to be translated]:Add the models you need from the provider page. Different models excel at different tasks like chatting, writing, coding, and more.",
"title": "[to be translated]:Add Models"
},
"addProvider": {
"description": "[to be translated]:Go to Settings to add AI providers. We recommend CherryIN for beginners - get free credits upon registration and access to various popular models.",
"title": "[to be translated]:Add Provider"
},
"complete": {
"description": "[to be translated]:You're all set! Start chatting with your first assistant. Need help? Check Settings for more options.",
"title": "[to be translated]:You're All Set!"
},
"fillApiKey": {
"description": "[to be translated]:Enter your API Key here. You can get the API Key from the provider's website. After filling it in, click the \"Check\" button to verify the connection.",
"title": "[to be translated]:Fill in API Key"
},
"freeModel": {
"description": "[to be translated]:Cherry Studio includes a free built-in GLM 4.5 Flash model. Start chatting right away without any configuration - perfect for a quick trial!",
"title": "[to be translated]:Free Model"
},
"settingsIntro": {
"description": "[to be translated]:Click here to access settings. Next, we'll guide you through adding AI providers and models.",
"title": "[to be translated]:Settings"
},
"useCases": {
"description": "[to be translated]:Cherry Studio can help you with: intelligent Q&A, document translation & summarization, code writing & debugging, creative writing, knowledge base queries, and more.",
"title": "[to be translated]:Use Cases"
},
"welcome": {
"description": "[to be translated]:Cherry Studio helps you interact with various AI models. Let's take a quick tour to help you get started.",
"title": "[to be translated]:Welcome to Cherry Studio!"
}
},
"welcome": {
"description": "[to be translated]:Your powerful AI assistant. Let's take a quick tour to help you get started.",
"title": "[to be translated]:Welcome to Cherry Studio"
}
},
"ovms": {
"action": {
"install": "Installieren",

View File

@ -2359,6 +2359,51 @@
},
"title": "Ollama"
},
"onboarding": {
"done": "[to be translated]:Get Started",
"next": "[to be translated]:Next",
"previous": "[to be translated]:Previous",
"progress": "[to be translated]:{{current}} of {{total}}",
"skip": "[to be translated]:Skip Tour",
"steps": {
"addModel": {
"description": "[to be translated]:Add the models you need from the provider page. Different models excel at different tasks like chatting, writing, coding, and more.",
"title": "[to be translated]:Add Models"
},
"addProvider": {
"description": "[to be translated]:Go to Settings to add AI providers. We recommend CherryIN for beginners - get free credits upon registration and access to various popular models.",
"title": "[to be translated]:Add Provider"
},
"complete": {
"description": "[to be translated]:You're all set! Start chatting with your first assistant. Need help? Check Settings for more options.",
"title": "[to be translated]:You're All Set!"
},
"fillApiKey": {
"description": "[to be translated]:Enter your API Key here. You can get the API Key from the provider's website. After filling it in, click the \"Check\" button to verify the connection.",
"title": "[to be translated]:Fill in API Key"
},
"freeModel": {
"description": "[to be translated]:Cherry Studio includes a free built-in GLM 4.5 Flash model. Start chatting right away without any configuration - perfect for a quick trial!",
"title": "[to be translated]:Free Model"
},
"settingsIntro": {
"description": "[to be translated]:Click here to access settings. Next, we'll guide you through adding AI providers and models.",
"title": "[to be translated]:Settings"
},
"useCases": {
"description": "[to be translated]:Cherry Studio can help you with: intelligent Q&A, document translation & summarization, code writing & debugging, creative writing, knowledge base queries, and more.",
"title": "[to be translated]:Use Cases"
},
"welcome": {
"description": "[to be translated]:Cherry Studio helps you interact with various AI models. Let's take a quick tour to help you get started.",
"title": "[to be translated]:Welcome to Cherry Studio!"
}
},
"welcome": {
"description": "[to be translated]:Your powerful AI assistant. Let's take a quick tour to help you get started.",
"title": "[to be translated]:Welcome to Cherry Studio"
}
},
"ovms": {
"action": {
"install": "Εγκατάσταση",

View File

@ -2359,6 +2359,51 @@
},
"title": "Ollama"
},
"onboarding": {
"done": "[to be translated]:Get Started",
"next": "[to be translated]:Next",
"previous": "[to be translated]:Previous",
"progress": "[to be translated]:{{current}} of {{total}}",
"skip": "[to be translated]:Skip Tour",
"steps": {
"addModel": {
"description": "[to be translated]:Add the models you need from the provider page. Different models excel at different tasks like chatting, writing, coding, and more.",
"title": "[to be translated]:Add Models"
},
"addProvider": {
"description": "[to be translated]:Go to Settings to add AI providers. We recommend CherryIN for beginners - get free credits upon registration and access to various popular models.",
"title": "[to be translated]:Add Provider"
},
"complete": {
"description": "[to be translated]:You're all set! Start chatting with your first assistant. Need help? Check Settings for more options.",
"title": "[to be translated]:You're All Set!"
},
"fillApiKey": {
"description": "[to be translated]:Enter your API Key here. You can get the API Key from the provider's website. After filling it in, click the \"Check\" button to verify the connection.",
"title": "[to be translated]:Fill in API Key"
},
"freeModel": {
"description": "[to be translated]:Cherry Studio includes a free built-in GLM 4.5 Flash model. Start chatting right away without any configuration - perfect for a quick trial!",
"title": "[to be translated]:Free Model"
},
"settingsIntro": {
"description": "[to be translated]:Click here to access settings. Next, we'll guide you through adding AI providers and models.",
"title": "[to be translated]:Settings"
},
"useCases": {
"description": "[to be translated]:Cherry Studio can help you with: intelligent Q&A, document translation & summarization, code writing & debugging, creative writing, knowledge base queries, and more.",
"title": "[to be translated]:Use Cases"
},
"welcome": {
"description": "[to be translated]:Cherry Studio helps you interact with various AI models. Let's take a quick tour to help you get started.",
"title": "[to be translated]:Welcome to Cherry Studio!"
}
},
"welcome": {
"description": "[to be translated]:Your powerful AI assistant. Let's take a quick tour to help you get started.",
"title": "[to be translated]:Welcome to Cherry Studio"
}
},
"ovms": {
"action": {
"install": "Instalar",

View File

@ -2359,6 +2359,51 @@
},
"title": "Ollama"
},
"onboarding": {
"done": "[to be translated]:Get Started",
"next": "[to be translated]:Next",
"previous": "[to be translated]:Previous",
"progress": "[to be translated]:{{current}} of {{total}}",
"skip": "[to be translated]:Skip Tour",
"steps": {
"addModel": {
"description": "[to be translated]:Add the models you need from the provider page. Different models excel at different tasks like chatting, writing, coding, and more.",
"title": "[to be translated]:Add Models"
},
"addProvider": {
"description": "[to be translated]:Go to Settings to add AI providers. We recommend CherryIN for beginners - get free credits upon registration and access to various popular models.",
"title": "[to be translated]:Add Provider"
},
"complete": {
"description": "[to be translated]:You're all set! Start chatting with your first assistant. Need help? Check Settings for more options.",
"title": "[to be translated]:You're All Set!"
},
"fillApiKey": {
"description": "[to be translated]:Enter your API Key here. You can get the API Key from the provider's website. After filling it in, click the \"Check\" button to verify the connection.",
"title": "[to be translated]:Fill in API Key"
},
"freeModel": {
"description": "[to be translated]:Cherry Studio includes a free built-in GLM 4.5 Flash model. Start chatting right away without any configuration - perfect for a quick trial!",
"title": "[to be translated]:Free Model"
},
"settingsIntro": {
"description": "[to be translated]:Click here to access settings. Next, we'll guide you through adding AI providers and models.",
"title": "[to be translated]:Settings"
},
"useCases": {
"description": "[to be translated]:Cherry Studio can help you with: intelligent Q&A, document translation & summarization, code writing & debugging, creative writing, knowledge base queries, and more.",
"title": "[to be translated]:Use Cases"
},
"welcome": {
"description": "[to be translated]:Cherry Studio helps you interact with various AI models. Let's take a quick tour to help you get started.",
"title": "[to be translated]:Welcome to Cherry Studio!"
}
},
"welcome": {
"description": "[to be translated]:Your powerful AI assistant. Let's take a quick tour to help you get started.",
"title": "[to be translated]:Welcome to Cherry Studio"
}
},
"ovms": {
"action": {
"install": "Installer",

View File

@ -2359,6 +2359,51 @@
},
"title": "Ollama"
},
"onboarding": {
"done": "[to be translated]:Get Started",
"next": "[to be translated]:Next",
"previous": "[to be translated]:Previous",
"progress": "[to be translated]:{{current}} of {{total}}",
"skip": "[to be translated]:Skip Tour",
"steps": {
"addModel": {
"description": "[to be translated]:Add the models you need from the provider page. Different models excel at different tasks like chatting, writing, coding, and more.",
"title": "[to be translated]:Add Models"
},
"addProvider": {
"description": "[to be translated]:Go to Settings to add AI providers. We recommend CherryIN for beginners - get free credits upon registration and access to various popular models.",
"title": "[to be translated]:Add Provider"
},
"complete": {
"description": "[to be translated]:You're all set! Start chatting with your first assistant. Need help? Check Settings for more options.",
"title": "[to be translated]:You're All Set!"
},
"fillApiKey": {
"description": "[to be translated]:Enter your API Key here. You can get the API Key from the provider's website. After filling it in, click the \"Check\" button to verify the connection.",
"title": "[to be translated]:Fill in API Key"
},
"freeModel": {
"description": "[to be translated]:Cherry Studio includes a free built-in GLM 4.5 Flash model. Start chatting right away without any configuration - perfect for a quick trial!",
"title": "[to be translated]:Free Model"
},
"settingsIntro": {
"description": "[to be translated]:Click here to access settings. Next, we'll guide you through adding AI providers and models.",
"title": "[to be translated]:Settings"
},
"useCases": {
"description": "[to be translated]:Cherry Studio can help you with: intelligent Q&A, document translation & summarization, code writing & debugging, creative writing, knowledge base queries, and more.",
"title": "[to be translated]:Use Cases"
},
"welcome": {
"description": "[to be translated]:Cherry Studio helps you interact with various AI models. Let's take a quick tour to help you get started.",
"title": "[to be translated]:Welcome to Cherry Studio!"
}
},
"welcome": {
"description": "[to be translated]:Your powerful AI assistant. Let's take a quick tour to help you get started.",
"title": "[to be translated]:Welcome to Cherry Studio"
}
},
"ovms": {
"action": {
"install": "インストール",

View File

@ -2359,6 +2359,51 @@
},
"title": "Ollama"
},
"onboarding": {
"done": "[to be translated]:Get Started",
"next": "[to be translated]:Next",
"previous": "[to be translated]:Previous",
"progress": "[to be translated]:{{current}} of {{total}}",
"skip": "[to be translated]:Skip Tour",
"steps": {
"addModel": {
"description": "[to be translated]:Add the models you need from the provider page. Different models excel at different tasks like chatting, writing, coding, and more.",
"title": "[to be translated]:Add Models"
},
"addProvider": {
"description": "[to be translated]:Go to Settings to add AI providers. We recommend CherryIN for beginners - get free credits upon registration and access to various popular models.",
"title": "[to be translated]:Add Provider"
},
"complete": {
"description": "[to be translated]:You're all set! Start chatting with your first assistant. Need help? Check Settings for more options.",
"title": "[to be translated]:You're All Set!"
},
"fillApiKey": {
"description": "[to be translated]:Enter your API Key here. You can get the API Key from the provider's website. After filling it in, click the \"Check\" button to verify the connection.",
"title": "[to be translated]:Fill in API Key"
},
"freeModel": {
"description": "[to be translated]:Cherry Studio includes a free built-in GLM 4.5 Flash model. Start chatting right away without any configuration - perfect for a quick trial!",
"title": "[to be translated]:Free Model"
},
"settingsIntro": {
"description": "[to be translated]:Click here to access settings. Next, we'll guide you through adding AI providers and models.",
"title": "[to be translated]:Settings"
},
"useCases": {
"description": "[to be translated]:Cherry Studio can help you with: intelligent Q&A, document translation & summarization, code writing & debugging, creative writing, knowledge base queries, and more.",
"title": "[to be translated]:Use Cases"
},
"welcome": {
"description": "[to be translated]:Cherry Studio helps you interact with various AI models. Let's take a quick tour to help you get started.",
"title": "[to be translated]:Welcome to Cherry Studio!"
}
},
"welcome": {
"description": "[to be translated]:Your powerful AI assistant. Let's take a quick tour to help you get started.",
"title": "[to be translated]:Welcome to Cherry Studio"
}
},
"ovms": {
"action": {
"install": "Instalar",

View File

@ -2359,6 +2359,51 @@
},
"title": "Ollama"
},
"onboarding": {
"done": "[to be translated]:Get Started",
"next": "[to be translated]:Next",
"previous": "[to be translated]:Previous",
"progress": "[to be translated]:{{current}} of {{total}}",
"skip": "[to be translated]:Skip Tour",
"steps": {
"addModel": {
"description": "[to be translated]:Add the models you need from the provider page. Different models excel at different tasks like chatting, writing, coding, and more.",
"title": "[to be translated]:Add Models"
},
"addProvider": {
"description": "[to be translated]:Go to Settings to add AI providers. We recommend CherryIN for beginners - get free credits upon registration and access to various popular models.",
"title": "[to be translated]:Add Provider"
},
"complete": {
"description": "[to be translated]:You're all set! Start chatting with your first assistant. Need help? Check Settings for more options.",
"title": "[to be translated]:You're All Set!"
},
"fillApiKey": {
"description": "[to be translated]:Enter your API Key here. You can get the API Key from the provider's website. After filling it in, click the \"Check\" button to verify the connection.",
"title": "[to be translated]:Fill in API Key"
},
"freeModel": {
"description": "[to be translated]:Cherry Studio includes a free built-in GLM 4.5 Flash model. Start chatting right away without any configuration - perfect for a quick trial!",
"title": "[to be translated]:Free Model"
},
"settingsIntro": {
"description": "[to be translated]:Click here to access settings. Next, we'll guide you through adding AI providers and models.",
"title": "[to be translated]:Settings"
},
"useCases": {
"description": "[to be translated]:Cherry Studio can help you with: intelligent Q&A, document translation & summarization, code writing & debugging, creative writing, knowledge base queries, and more.",
"title": "[to be translated]:Use Cases"
},
"welcome": {
"description": "[to be translated]:Cherry Studio helps you interact with various AI models. Let's take a quick tour to help you get started.",
"title": "[to be translated]:Welcome to Cherry Studio!"
}
},
"welcome": {
"description": "[to be translated]:Your powerful AI assistant. Let's take a quick tour to help you get started.",
"title": "[to be translated]:Welcome to Cherry Studio"
}
},
"ovms": {
"action": {
"install": "Instalează",

View File

@ -2359,6 +2359,51 @@
},
"title": "Ollama"
},
"onboarding": {
"done": "[to be translated]:Get Started",
"next": "[to be translated]:Next",
"previous": "[to be translated]:Previous",
"progress": "[to be translated]:{{current}} of {{total}}",
"skip": "[to be translated]:Skip Tour",
"steps": {
"addModel": {
"description": "[to be translated]:Add the models you need from the provider page. Different models excel at different tasks like chatting, writing, coding, and more.",
"title": "[to be translated]:Add Models"
},
"addProvider": {
"description": "[to be translated]:Go to Settings to add AI providers. We recommend CherryIN for beginners - get free credits upon registration and access to various popular models.",
"title": "[to be translated]:Add Provider"
},
"complete": {
"description": "[to be translated]:You're all set! Start chatting with your first assistant. Need help? Check Settings for more options.",
"title": "[to be translated]:You're All Set!"
},
"fillApiKey": {
"description": "[to be translated]:Enter your API Key here. You can get the API Key from the provider's website. After filling it in, click the \"Check\" button to verify the connection.",
"title": "[to be translated]:Fill in API Key"
},
"freeModel": {
"description": "[to be translated]:Cherry Studio includes a free built-in GLM 4.5 Flash model. Start chatting right away without any configuration - perfect for a quick trial!",
"title": "[to be translated]:Free Model"
},
"settingsIntro": {
"description": "[to be translated]:Click here to access settings. Next, we'll guide you through adding AI providers and models.",
"title": "[to be translated]:Settings"
},
"useCases": {
"description": "[to be translated]:Cherry Studio can help you with: intelligent Q&A, document translation & summarization, code writing & debugging, creative writing, knowledge base queries, and more.",
"title": "[to be translated]:Use Cases"
},
"welcome": {
"description": "[to be translated]:Cherry Studio helps you interact with various AI models. Let's take a quick tour to help you get started.",
"title": "[to be translated]:Welcome to Cherry Studio!"
}
},
"welcome": {
"description": "[to be translated]:Your powerful AI assistant. Let's take a quick tour to help you get started.",
"title": "[to be translated]:Welcome to Cherry Studio"
}
},
"ovms": {
"action": {
"install": "Установить",

View File

@ -1,6 +1,7 @@
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
import { useAgentSessionInitializer } from '@renderer/hooks/agents/useAgentSessionInitializer'
import { useAssistants } from '@renderer/hooks/useAssistant'
import { useOnboardingInit } from '@renderer/hooks/useOnboardingInit'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
import { useActiveTopic } from '@renderer/hooks/useTopic'
@ -30,6 +31,9 @@ const HomePage: FC = () => {
// Initialize agent session hook
useAgentSessionInitializer()
// Initialize onboarding for new users
useOnboardingInit()
const location = useLocation()
const state = location.state

View File

@ -183,7 +183,12 @@ const ModelList: React.FC<ModelListProps> = ({ providerId }) => {
{t('button.manage')}
</Button>
{provider.id !== 'ovms' ? (
<Button type="default" onClick={onAddModel} icon={<Plus size={16} />} disabled={isHealthChecking}>
<Button
id="add-model-btn"
type="default"
onClick={onAddModel}
icon={<Plus size={16} />}
disabled={isHealthChecking}>
{t('button.add')}
</Button>
) : (

View File

@ -385,6 +385,7 @@ const ProviderList: FC = () => {
</DraggableVirtualList>
<AddButtonWrapper>
<Button
id="add-provider-btn"
style={{ width: '100%', borderRadius: 'var(--list-item-border-radius)' }}
icon={<PlusIcon size={16} />}
onClick={onAddProvider}

View File

@ -463,6 +463,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
</SettingSubtitle>
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
<Input.Password
id="api-key-input"
value={localApiKey}
placeholder={t('settings.provider.api_key.label')}
onChange={(e) => setLocalApiKey(e.target.value)}

View File

@ -38,6 +38,7 @@ import { setNotesPath } from './note'
import note from './note'
import nutstore from './nutstore'
import ocr from './ocr'
import onboarding from './onboarding'
import paintings from './paintings'
import preprocess from './preprocess'
import runtime from './runtime'
@ -76,6 +77,7 @@ const rootReducer = combineReducers({
translate,
ocr,
note,
onboarding,
toolPermissions
})

View File

@ -0,0 +1,34 @@
import type { PayloadAction } from '@reduxjs/toolkit'
import { createSlice } from '@reduxjs/toolkit'
import type { OnboardingState } from '@renderer/types/onboarding'
const initialState: OnboardingState = {
completedOnboardingVersion: null,
completedFeatureGuides: [],
onboardingSkipped: false
}
const onboardingSlice = createSlice({
name: 'onboarding',
initialState,
reducers: {
completeOnboarding: (state, action: PayloadAction<string>) => {
state.completedOnboardingVersion = action.payload
state.onboardingSkipped = false
},
completeFeatureGuide: (state, action: PayloadAction<string>) => {
if (!state.completedFeatureGuides.includes(action.payload)) {
state.completedFeatureGuides.push(action.payload)
}
},
skipOnboarding: (state, action: PayloadAction<string>) => {
state.onboardingSkipped = true
state.completedOnboardingVersion = action.payload
},
resetOnboarding: () => initialState
}
})
export const { completeOnboarding, completeFeatureGuide, skipOnboarding, resetOnboarding } = onboardingSlice.actions
export default onboardingSlice.reducer

View File

@ -0,0 +1,49 @@
/**
* Onboarding types for user guide system
*/
/** Single step in a guide tour */
export interface GuideStep {
/** Unique step ID for tracking */
id: string
/** CSS selector for target element (optional for modal-style steps) */
element?: string | (() => Element | null)
/** i18n key for title */
titleKey: string
/** i18n key for description */
descriptionKey: string
/** Popover position relative to element */
side?: 'top' | 'right' | 'bottom' | 'left' | 'over'
/** Popover alignment */
align?: 'start' | 'center' | 'end'
/** Route to navigate to before showing this step */
navigateTo?: string
}
/** Version-specific guide definition */
export interface VersionGuide {
/** Target version (semver string, e.g., "1.7.0", "1.8.0") */
version: string
/** Guide type: 'onboarding' for new users, 'feature' for upgrades */
type: 'onboarding' | 'feature'
/** i18n key for guide title */
titleKey: string
/** i18n key for guide description */
descriptionKey: string
/** Ordered list of steps */
steps: GuideStep[]
/** Route where this guide should be triggered (default: '/') */
route?: string
/** Priority for ordering when multiple guides apply (higher = first) */
priority?: number
}
/** Redux state for onboarding */
export interface OnboardingState {
/** Last completed onboarding version (e.g., "1.7.0") */
completedOnboardingVersion: string | null
/** Array of completed feature guide versions */
completedFeatureGuides: string[]
/** Whether user has explicitly skipped onboarding */
onboardingSkipped: boolean
}