refactor(stage-ui): steps of onboarding dialog (#1224)

This commit is contained in:
LemonNeko
2026-03-10 16:09:25 +08:00
committed by GitHub
parent ada6e77823
commit 6e82ebef1a
9 changed files with 297 additions and 142 deletions
@@ -1,2 +1,3 @@
export { default as OnboardingDialog } from './onboarding-dialog.vue'
export { default as OnboardingScreen } from './onboarding.vue'
export type { OnboardingStep } from './types'
@@ -1,4 +1,6 @@
<script setup lang="ts">
import type { OnboardingStep } from './types'
import { useMediaQuery, useResizeObserver, useScreenSafeArea } from '@vueuse/core'
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, VisuallyHidden } from 'reka-ui'
import { DrawerContent, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot } from 'vaul-vue'
@@ -6,6 +8,10 @@ import { onMounted } from 'vue'
import Onboarding from './onboarding.vue'
const props = defineProps<{
extraSteps?: OnboardingStep[]
}>()
const emit = defineEmits<{
(e: 'configured'): void
(e: 'skipped'): void
@@ -28,7 +34,7 @@ onMounted(() => screenSafeArea.update())
<VisuallyHidden>
<DialogTitle>Onboarding</DialogTitle>
</VisuallyHidden>
<Onboarding @configured="emit('configured')" @skipped="emit('skipped')" />
<Onboarding :extra-steps="props.extraSteps" @configured="emit('configured')" @skipped="emit('skipped')" />
</DialogContent>
</DialogPortal>
</DialogRoot>
@@ -37,7 +43,7 @@ onMounted(() => screenSafeArea.update())
<DrawerOverlay class="fixed inset-0" />
<DrawerContent class="fixed bottom-0 left-0 right-0 z-1000 mt-20 h-full max-h-[96%] flex flex-col rounded-t-2xl bg-neutral-50 px-4 pt-4 outline-none backdrop-blur-md dark:bg-neutral-900/95" :style="{ paddingBottom: `${Math.max(Number.parseFloat(screenSafeArea.bottom.value.replace('px', '')), 24)}px` }">
<DrawerHandle />
<Onboarding @configured="emit('configured')" @skipped="emit('skipped')" />
<Onboarding :extra-steps="props.extraSteps" @configured="emit('configured')" @skipped="emit('skipped')" />
</DrawerContent>
</DrawerPortal>
</DrawerRoot>
@@ -1,6 +1,15 @@
<script setup lang="ts">
import type { ProviderMetadata } from '../../../../stores/providers'
import type {
OnboardingStep,
OnboardingStepGuard,
OnboardingStepNextHandler,
OnboardingStepPrevHandler,
ProviderConfigData,
} from './types'
import { storeToRefs } from 'pinia'
import { computed, nextTick, provide, ref } from 'vue'
import { computed, nextTick, ref } from 'vue'
import StepModelSelection from './step-model-selection.vue'
import StepProviderConfiguration from './step-provider-configuration.vue'
@@ -9,17 +18,19 @@ import StepWelcome from './step-welcome.vue'
import { useConsciousnessStore } from '../../../../stores/modules/consciousness'
import { useProvidersStore } from '../../../../stores/providers'
import { OnboardingContextKey } from './utils'
interface Emits {
(e: 'configured'): void
(e: 'skipped'): void
}
const { extraSteps = [] } = defineProps<{
extraSteps?: OnboardingStep[]
}>()
const emit = defineEmits<Emits>()
const step = ref(1)
const step = ref(0)
const direction = ref<'next' | 'previous'>('next')
const pendingProviderConfig = ref<ProviderConfigData | null>(null)
const providersStore = useProvidersStore()
const { providers, allChatProvidersMetadata } = storeToRefs(providersStore)
@@ -45,37 +56,20 @@ const selectedProvider = computed(() => {
})
// Reset validation state when provider changes
function selectProvider(provider: typeof popularProviders.value[0]) {
function selectProvider(provider: ProviderMetadata) {
selectedProviderId.value = provider.id
}
function handlePreviousStep() {
if (step.value > 1) {
direction.value = 'previous'
step.value--
}
const requestPreviousStep: OnboardingStepPrevHandler = () => {
return navigatePrevious()
}
async function handleNextStep(configData?: { apiKey: string, baseUrl: string, accountId: string }) {
// Step 3: Provider configuration - validate and save before proceeding
if (step.value === 3 && configData) {
await saveProviderConfiguration(configData)
direction.value = 'next'
step.value++
return
}
// Other steps: just proceed
if (step.value < 4) {
direction.value = 'next'
step.value++
}
else {
handleSave()
}
const requestNextStep: OnboardingStepNextHandler = async (configData?: ProviderConfigData) => {
pendingProviderConfig.value = configData ?? null
await navigateNext()
}
async function saveProviderConfiguration(data: { apiKey: string, baseUrl: string, accountId: string }) {
async function saveProviderConfiguration(data: ProviderConfigData) {
if (!selectedProvider.value)
return
@@ -108,77 +102,188 @@ async function handleSave() {
emit('configured')
}
provide(OnboardingContextKey, {
selectedProviderId,
selectedProvider,
selectProvider,
popularProviders,
handleNextStep,
handlePreviousStep,
handleSave,
const allSteps = computed<OnboardingStep[]>(() => {
const coreSteps: OnboardingStep[] = [
{
id: 'welcome',
component: StepWelcome,
},
{
id: 'provider-selection',
component: StepProviderSelection,
props: () => ({
selectedProviderId: selectedProviderId.value,
popularProviders: popularProviders.value,
onSelectProvider: selectProvider,
}),
},
{
id: 'provider-configuration',
component: StepProviderConfiguration,
props: () => ({
selectedProviderId: selectedProviderId.value,
selectedProvider: selectedProvider.value,
}),
beforeNext: async () => {
if (!pendingProviderConfig.value)
return false
await saveProviderConfiguration(pendingProviderConfig.value)
pendingProviderConfig.value = null
return true
},
},
...extraSteps.map(step => ({
...step,
props: () => ({
...step.props?.(),
}),
})),
{
id: 'model-selection',
component: StepModelSelection,
},
]
return coreSteps
})
const currentStep = computed(() => allSteps.value[step.value] ?? null)
const isLastStep = computed(() => step.value === allSteps.value.length - 1)
const currentStepProps = computed(() => currentStep.value?.props?.() ?? {})
async function canPassGuard(guard?: OnboardingStepGuard) {
if (!guard)
return true
return await guard()
}
async function navigateNext() {
if (!currentStep.value)
return
if (!(await canPassGuard(currentStep.value.beforeNext)))
return
if (isLastStep.value) {
await handleSave()
return
}
direction.value = 'next'
step.value++
}
async function navigatePrevious() {
if (!currentStep.value || step.value <= 0)
return
if (!(await canPassGuard(currentStep.value.beforePrev)))
return
direction.value = 'previous'
step.value--
}
</script>
<template>
<div h-full w-full>
<div class="onboarding-step-container" h-full w-full>
<Transition :name="direction === 'next' ? 'slide-next' : 'slide-prev'" mode="out-in">
<StepWelcome v-if="step === 1" :key="1" />
<StepProviderSelection v-else-if="step === 2" :key="2" />
<StepProviderConfiguration v-else-if="step === 3" :key="3" />
<StepModelSelection v-else-if="step === 4" :key="4" />
<component
:is="currentStep.component"
v-if="currentStep"
:key="currentStep.id"
v-bind="currentStepProps"
:on-next="requestNextStep"
:on-previous="requestPreviousStep"
/>
</Transition>
</div>
</template>
<style scoped>
.onboarding-step-container {
overflow-x: hidden;
}
.slide-next-enter-active,
.slide-next-leave-active {
transition: transform 0.2s ease-in-out, opacity 0.2s ease-in-out;
}
.slide-next-enter-from {
transform: translateX(100%);
opacity: 0;
}
.slide-next-enter-to {
transform: translateX(0);
opacity: 1;
}
.slide-next-leave-from {
transform: translateX(0);
opacity: 1;
}
.slide-next-leave-to {
transform: translateX(-100%);
opacity: 0;
}
/* Slide Previous Animation */
.slide-next-leave-active,
.slide-prev-enter-active,
.slide-prev-leave-active {
transition: transform 0.2s ease-in-out, opacity 0.2s ease-in-out;
will-change: transform, opacity;
}
.slide-prev-enter-from {
transform: translateX(-100%);
opacity: 0;
.slide-next-enter-active {
animation: onboarding-slide-next-in 0.2s ease-in-out both;
}
.slide-prev-enter-to {
transform: translateX(0);
opacity: 1;
.slide-next-leave-active {
animation: onboarding-slide-next-out 0.2s ease-in-out both;
}
.slide-prev-leave-from {
transform: translateX(0);
opacity: 1;
.slide-prev-enter-active {
animation: onboarding-slide-prev-in 0.2s ease-in-out both;
}
.slide-prev-leave-to {
transform: translateX(100%);
opacity: 0;
.slide-prev-leave-active {
animation: onboarding-slide-prev-out 0.2s ease-in-out both;
}
@keyframes onboarding-slide-next-in {
from {
transform: translateX(2rem);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes onboarding-slide-next-out {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(-2rem);
opacity: 0;
}
}
@keyframes onboarding-slide-prev-in {
from {
transform: translateX(-2rem);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes onboarding-slide-prev-out {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(2rem);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.slide-next-enter-active,
.slide-next-leave-active,
.slide-prev-enter-active,
.slide-prev-leave-active {
animation-duration: 1ms;
}
}
</style>
@@ -1,17 +1,20 @@
<script setup lang="ts">
import type { OnboardingStepNextHandler, OnboardingStepPrevHandler } from './types'
import { Button } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { inject } from 'vue'
import { useI18n } from 'vue-i18n'
import Alert from '../../../misc/alert.vue'
import { useConsciousnessStore } from '../../../../stores/modules/consciousness'
import { RadioCardManySelect } from '../../../menu'
import { OnboardingContextKey } from './utils'
const props = defineProps<{
onNext: OnboardingStepNextHandler
onPrevious: OnboardingStepPrevHandler
}>()
const { t } = useI18n()
const context = inject(OnboardingContextKey)!
const consciousnessStore = useConsciousnessStore()
const {
@@ -25,7 +28,7 @@ const {
<template>
<div h-full flex flex-col gap-4>
<div sticky top-0 z-100 flex flex-shrink-0 items-center gap-2>
<button outline-none @click="context.handlePreviousStep">
<button outline-none @click="props.onPrevious">
<div i-solar:alt-arrow-left-line-duotone h-5 w-5 />
</button>
<h2 class="flex-1 text-center text-xl text-neutral-800 font-semibold md:text-left md:text-2xl dark:text-neutral-100">
@@ -73,7 +76,7 @@ const {
:disabled="!activeModel"
:loading="isLoadingActiveProviderModels"
:label="t('settings.dialogs.onboarding.saveAndContinue')"
@click="context.handleSave"
@click="props.onNext"
/>
</div>
</template>
@@ -1,15 +1,24 @@
<script setup lang="ts">
import type { ProviderMetadata } from '../../../../stores/providers'
import type { OnboardingStepNextHandler, OnboardingStepPrevHandler } from './types'
import { Button, Callout, FieldInput } from '@proj-airi/ui'
import { computed, inject, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useProvidersStore } from '../../../../stores/providers'
import { Alert } from '../../../misc'
import { ProviderAccountIdInput } from '../../../scenarios/providers'
import { OnboardingContextKey } from './utils'
interface Props {
selectedProviderId: string
selectedProvider: ProviderMetadata | null
onNext: OnboardingStepNextHandler
onPrevious: OnboardingStepPrevHandler
}
const props = defineProps<Props>()
const { t } = useI18n()
const context = inject(OnboardingContextKey)!
const providersStore = useProvidersStore()
const apiKey = ref('')
@@ -21,7 +30,7 @@ const validationError = ref<any>()
// Initialize form with default values when provider changes
function initializeForm() {
const provider = context.selectedProvider.value
const provider = props.selectedProvider
if (!provider)
return
@@ -36,7 +45,7 @@ function initializeForm() {
}
// Watch for provider changes
watch(() => context.selectedProvider.value?.id, initializeForm)
watch(() => props.selectedProvider?.id, initializeForm)
watch([apiKey, baseUrl, accountId], () => {
if (validation.value === 'failed' || validation.value === 'succeed') {
@@ -47,19 +56,19 @@ watch([apiKey, baseUrl, accountId], () => {
// Computed properties
const needsApiKey = computed(() => {
if (!context.selectedProvider.value)
if (!props.selectedProvider)
return false
return context.selectedProvider.value.id !== 'ollama' && context.selectedProvider.value.id !== 'player2'
return props.selectedProvider.id !== 'ollama' && props.selectedProvider.id !== 'player2'
})
const needsBaseUrl = computed(() => {
if (!context.selectedProvider.value)
if (!props.selectedProvider)
return false
return context.selectedProvider.value.id !== 'cloudflare-workers-ai'
return props.selectedProvider.id !== 'cloudflare-workers-ai'
})
const canProceed = computed(() => {
if (!context.selectedProviderId.value)
if (!props.selectedProviderId)
return false
if (needsApiKey.value && !apiKey.value.trim())
@@ -75,7 +84,7 @@ const primaryActionLabel = computed(() => {
})
async function validateConfiguration() {
if (!context.selectedProvider.value)
if (!props.selectedProvider)
return
validation.value = 'pending'
@@ -89,11 +98,11 @@ async function validateConfiguration() {
config.apiKey = apiKey.value.trim()
if (needsBaseUrl.value)
config.baseUrl = baseUrl.value.trim()
if (context.selectedProvider.value.id === 'cloudflare-workers-ai')
if (props.selectedProvider.id === 'cloudflare-workers-ai')
config.accountId = accountId.value.trim()
// Validate using provider's validator
const metadata = providersStore.getProviderMetadata(context.selectedProvider.value.id)
const metadata = providersStore.getProviderMetadata(props.selectedProvider.id)
const validationResult = await metadata.validators.validateProviderConfig(config)
validation.value = validationResult.valid ? 'succeed' : 'failed'
if (validation.value === 'failed') {
@@ -111,7 +120,7 @@ async function validateConfiguration() {
async function handleNext() {
await validateConfiguration()
if (validation.value === 'succeed') {
await context.handleNextStep({
await props.onNext({
apiKey: apiKey.value,
baseUrl: baseUrl.value,
accountId: accountId.value,
@@ -120,15 +129,15 @@ async function handleNext() {
}
async function handleContinueAnyway() {
if (!context.selectedProvider.value)
if (!props.selectedProvider)
return
await context.handleNextStep({
await props.onNext({
apiKey: apiKey.value,
baseUrl: baseUrl.value,
accountId: accountId.value,
})
providersStore.forceProviderConfigured(context.selectedProvider.value.id)
providersStore.forceProviderConfigured(props.selectedProvider.id)
}
// Placeholder helpers
@@ -154,7 +163,7 @@ function getApiKeyPlaceholder(providerId: string): string {
}
function getBaseUrlPlaceholder(_providerId: string): string {
const defaultOptions = context.selectedProvider.value?.defaultOptions?.() || {}
const defaultOptions = props.selectedProvider?.defaultOptions?.() || {}
return (defaultOptions as any)?.baseUrl || 'https://api.example.com/v1/'
}
@@ -165,15 +174,15 @@ initializeForm()
<template>
<div h-full flex flex-col gap-4>
<div sticky top-0 z-100 flex flex-shrink-0 items-center gap-2>
<button outline-none @click="context.handlePreviousStep">
<button outline-none @click="props.onPrevious">
<div i-solar:alt-arrow-left-line-duotone h-5 w-5 />
</button>
<h2 class="flex-1 text-center text-xl text-neutral-800 font-semibold md:text-left md:text-2xl dark:text-neutral-100">
{{ t('settings.dialogs.onboarding.configureProvider', { provider: context.selectedProvider.value?.localizedName }) }}
{{ t('settings.dialogs.onboarding.configureProvider', { provider: props.selectedProvider?.localizedName }) }}
</h2>
<div h-5 w-5 />
</div>
<div v-if="context.selectedProvider.value" flex-1 overflow-y-auto space-y-4>
<div v-if="props.selectedProvider" flex-1 overflow-y-auto space-y-4>
<Callout label="Keep your API keys and credentials safe!" theme="violet">
<div>
<div>
@@ -191,7 +200,7 @@ initializeForm()
<div v-if="needsApiKey">
<FieldInput
v-model="apiKey"
:placeholder="getApiKeyPlaceholder(context.selectedProvider.value.id)"
:placeholder="getApiKeyPlaceholder(props.selectedProvider.id)"
type="password"
label="API Key"
description="Enter your API key for the selected provider."
@@ -203,7 +212,7 @@ initializeForm()
<div v-if="needsBaseUrl">
<FieldInput
v-model="baseUrl"
:placeholder="getBaseUrlPlaceholder(context.selectedProvider.value.id)"
:placeholder="getBaseUrlPlaceholder(props.selectedProvider.id)"
type="text"
label="Base URL"
description="Enter the base URL for the provider's API."
@@ -211,7 +220,7 @@ initializeForm()
</div>
<!-- Account ID for Cloudflare -->
<div v-if="context.selectedProvider.value.id === 'cloudflare-workers-ai'">
<div v-if="props.selectedProvider.id === 'cloudflare-workers-ai'">
<ProviderAccountIdInput v-model="accountId" />
</div>
</div>
@@ -1,19 +1,38 @@
<script setup lang="ts">
import type { ProviderMetadata } from '../../../../stores/providers'
import type { OnboardingStepNextHandler, OnboardingStepPrevHandler } from './types'
import { Button } from '@proj-airi/ui'
import { inject } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { RadioCardDetail } from '../../../menu'
import { OnboardingContextKey } from './utils'
interface Props {
popularProviders: ProviderMetadata[]
selectedProviderId: string
onSelectProvider: (provider: ProviderMetadata) => void
onNext: OnboardingStepNextHandler
onPrevious: OnboardingStepPrevHandler
}
const props = defineProps<Props>()
const { t } = useI18n()
const context = inject(OnboardingContextKey)!
const selectedProviderIdModel = computed({
get: () => props.selectedProviderId,
set: (providerId: string) => {
const provider = props.popularProviders.find(item => item.id === providerId)
if (provider)
props.onSelectProvider(provider)
},
})
</script>
<template>
<div h-full flex flex-col gap-4>
<div sticky top-0 z-100 flex flex-shrink-0 items-center gap-2>
<button outline-none @click="context.handlePreviousStep">
<button outline-none @click="props.onPrevious">
<div class="i-solar:alt-arrow-left-line-duotone h-5 w-5" />
</button>
<h2 class="flex-1 text-center text-xl text-neutral-800 font-semibold md:text-left md:text-2xl dark:text-neutral-100">
@@ -24,22 +43,22 @@ const context = inject(OnboardingContextKey)!
<div class="flex-1 overflow-y-auto">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<RadioCardDetail
v-for="provider in context.popularProviders.value"
v-for="provider in props.popularProviders"
:id="provider.id"
:key="provider.id"
v-model="context.selectedProviderId.value"
v-model="selectedProviderIdModel"
name="provider-selection"
:value="provider.id"
:title="provider.localizedName || provider.id"
:description="provider.localizedDescription || ''"
@click="context.selectProvider(provider)"
@click="props.onSelectProvider(provider)"
/>
</div>
</div>
<Button
:label="t('settings.dialogs.onboarding.next')"
:disabled="!context.selectedProviderId.value"
@click="context.handleNextStep"
:disabled="!selectedProviderIdModel"
@click="props.onNext"
/>
</div>
</template>
@@ -1,14 +1,17 @@
<script setup lang="ts">
import type { OnboardingStepNextHandler } from './types'
import { Button } from '@proj-airi/ui'
import { inject } from 'vue'
import { useI18n } from 'vue-i18n'
import onboardingLogo from '../../../../assets/onboarding.avif'
import { OnboardingContextKey } from './utils'
interface Props {
onNext: OnboardingStepNextHandler
}
const props = defineProps<Props>()
const { t } = useI18n()
const context = inject(OnboardingContextKey)!
</script>
<template>
@@ -17,7 +20,7 @@ const context = inject(OnboardingContextKey)!
<div
v-motion
:initial="{ opacity: 0, scale: 0.5 }"
:visible="{ opacity: 1, scale: 1 }"
:enter="{ opacity: 1, scale: 1 }"
:duration="500"
class="mb-1 flex justify-center md:mb-4 lg:pt-16 md:pt-8"
>
@@ -26,7 +29,7 @@ const context = inject(OnboardingContextKey)!
<h2
v-motion
:initial="{ opacity: 0, y: 10 }"
:visible="{ opacity: 1, y: 0 }"
:enter="{ opacity: 1, y: 0 }"
:duration="500"
class="mb-0 text-3xl text-neutral-800 font-bold md:mb-2 dark:text-neutral-100"
>
@@ -35,7 +38,7 @@ const context = inject(OnboardingContextKey)!
<p
v-motion
:initial="{ opacity: 0, y: 10 }"
:visible="{ opacity: 1, y: 0 }"
:enter="{ opacity: 1, y: 0 }"
:duration="500"
:delay="100"
class="text-sm text-neutral-600 md:text-lg dark:text-neutral-400"
@@ -46,11 +49,11 @@ const context = inject(OnboardingContextKey)!
<Button
v-motion
:initial="{ opacity: 0 }"
:visible="{ opacity: 1 }"
:enter="{ opacity: 1 }"
:duration="500"
:delay="200"
:label="t('settings.dialogs.onboarding.start')"
@click="context.handleNextStep"
@click="props.onNext"
/>
</div>
</template>
@@ -0,0 +1,24 @@
import type { Component } from 'vue'
export type OnboardingStepGuard = () => Promise<boolean>
export type OnboardingStepPrevHandler = () => Promise<void> | void
export interface ProviderConfigData {
apiKey: string
baseUrl: string
accountId: string
}
export type OnboardingStepNextHandler = (configData?: ProviderConfigData) => Promise<void> | void
export interface OnboardingStep {
id: string
component: Component<{
configData?: ProviderConfigData
onNext: OnboardingStepNextHandler
onPrevious?: OnboardingStepPrevHandler
}>
props?: () => Record<string, unknown>
beforeNext?: OnboardingStepGuard
beforePrev?: OnboardingStepGuard
}
@@ -1,15 +0,0 @@
import type { InjectionKey, Ref } from 'vue'
import type { ProviderMetadata } from '../../../../stores/providers'
export interface OnboardingContext {
selectedProviderId: Ref<string>
selectedProvider: Ref<ProviderMetadata | null>
popularProviders: Ref<ProviderMetadata[]>
selectProvider: (provider: ProviderMetadata) => void
handleNextStep: (configData?: { apiKey: string, baseUrl: string, accountId: string }) => Promise<void>
handlePreviousStep: () => void
handleSave: () => void
}
export const OnboardingContextKey: InjectionKey<OnboardingContext> = Symbol('onboarding-context')