refactor(stage-pages): split onboarding component (#733)

This commit is contained in:
Typed SIGTERM
2025-11-21 17:18:22 +08:00
committed by GitHub
parent b29ed05f75
commit 34b44096a2
10 changed files with 453 additions and 402 deletions
@@ -71,9 +71,7 @@ function updateCustomModelName(value: string) {
to="/settings/providers"
border="2px solid"
class="border-neutral-100 bg-white dark:border-neutral-900 hover:border-primary-500/30 dark:bg-neutral-900/20 dark:hover:border-primary-400/30"
flex="~ col items-center justify-center"
transition="all duration-200 ease-in-out"
relative min-w-50 w-fit rounded-xl p-4
>
@@ -202,9 +202,7 @@ function updateCustomModelName(value: string) {
to="/settings/providers#speech"
border="2px solid"
class="border-neutral-100 bg-white dark:border-neutral-900 hover:border-primary-500/30 dark:bg-neutral-900/20 dark:hover:border-primary-400/30"
flex="~ col items-center justify-center"
transition="all duration-200 ease-in-out"
relative min-w-50 w-fit rounded-xl p-4
>
@@ -5,20 +5,25 @@ const props = defineProps<{
type?: 'error' | 'warning' | 'success' | 'info' | 'loading'
}>()
defineSlots<{
title: (props: any) => any
content: (props: any) => any
}>()
const slots = useSlots()
const containerClass = computed(() => {
switch (props.type) {
case 'error':
return 'border-solid border-2 border-red-200 bg-red-50 dark:border-red-800/30 dark:bg-red-900/20'
return 'border-red-200 bg-red-50 dark:border-red-800/30 dark:bg-red-900/20'
case 'warning':
return 'border-solid border-2 border-amber-200 bg-amber-50 dark:border-amber-800/30 dark:bg-amber-900/20'
return 'border-amber-200 bg-amber-50 dark:border-amber-800/30 dark:bg-amber-900/20'
case 'success':
return 'border-solid border-2 border-green-200 bg-green-50 dark:border-green-800/30 text-green-700 dark:bg-green-900/30 dark:text-green-300'
return 'border-green-200 bg-green-50 dark:border-green-800/30 text-green-700 dark:bg-green-900/30 dark:text-green-300'
case 'info':
return 'border-solid border-2 border-blue-200 bg-blue-50 dark:border-blue-800/30 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
return 'border-blue-200 bg-blue-50 dark:border-blue-800/30 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
case 'loading':
return 'border-solid border-2 border-blue-200 bg-blue-50 dark:border-blue-800/30 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
return 'border-blue-200 bg-blue-50 dark:border-blue-800/30 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
}
return ''
})
@@ -57,7 +62,7 @@ const titleClass = computed(() => {
</script>
<template>
<div class="flex flex-col gap-3 rounded-xl p-2" :class="containerClass">
<div class="flex flex-col gap-3 border-2 rounded-xl border-solid p-2" :class="containerClass">
<div class="flex items-center gap-1.5 font-medium">
<div class="text-2xl" :class="iconClass" />
<div :class="titleClass">
@@ -23,8 +23,8 @@ onMounted(() => screenSafeArea.update())
<template>
<DialogRoot v-if="isDesktop" :open="showDialog" @update:open="value => showDialog = value">
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn" />
<DialogContent class="fixed left-1/2 top-1/2 z-[9999] max-h-full max-w-2xl w-[92dvw] transform overflow-y-scroll rounded-2xl bg-white p-6 shadow-xl outline-none backdrop-blur-md scrollbar-none -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow dark:bg-neutral-900">
<DialogOverlay class="fixed inset-0 z-9999 bg-black/50 backdrop-blur-sm data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn" />
<DialogContent class="fixed left-1/2 top-1/2 z-9999 max-h-full max-w-2xl w-[92dvw] transform overflow-y-scroll rounded-2xl bg-white p-6 shadow-xl outline-none backdrop-blur-md scrollbar-none -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow dark:bg-neutral-900">
<VisuallyHidden>
<DialogTitle>Onboarding</DialogTitle>
</VisuallyHidden>
@@ -1,19 +1,15 @@
<script setup lang="ts" xmlns:i-solar="http://www.w3.org/1999/xhtml">
import { FieldInput } from '@proj-airi/ui'
import { useDebounceFn } from '@vueuse/core'
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { computed, nextTick, provide, ref } from 'vue'
import onboardingLogo from '../../../../assets/onboarding.avif'
import Alert from '../../../misc/Alert.vue'
import StepModelSelection from './step-model-selection.vue'
import StepProviderConfiguration from './step-provider-configuration.vue'
import StepProviderSelection from './step-provider-selection.vue'
import StepWelcome from './step-welcome.vue'
import { useConsciousnessStore } from '../../../../stores/modules/consciousness'
import { useProvidersStore } from '../../../../stores/providers'
import { Callout } from '../../../layouts'
import { RadioCardDetail, RadioCardManySelect } from '../../../menu'
import { Button } from '../../../misc'
import { ProviderAccountIdInput } from '../../../scenarios/providers'
import { OnboardingContextKey } from './utils'
interface Emits {
(e: 'configured'): void
@@ -22,21 +18,14 @@ interface Emits {
const emit = defineEmits<Emits>()
const debounceTime = 500
const step = ref(1)
const direction = ref<'next' | 'previous'>('next')
const { t } = useI18n()
const providersStore = useProvidersStore()
const { providers, allChatProvidersMetadata } = storeToRefs(providersStore)
const consciousnessStore = useConsciousnessStore()
const {
activeModel,
activeProvider,
modelSearchQuery,
providerModels,
isLoadingActiveProviderModels,
} = storeToRefs(consciousnessStore)
// Popular providers for first-time setup
@@ -49,156 +38,17 @@ const popularProviders = computed(() => {
// Selected provider and form data
const selectedProviderId = ref('')
const apiKey = ref('')
const baseUrl = ref('')
const accountId = ref('')
// Computed selected provider
const selectedProvider = computed(() => {
return allChatProvidersMetadata.value.find(p => p.id === selectedProviderId.value) || null
})
// Validation state (animation)
const isValidating = ref(0)
const isValid = ref(false)
const validationMessage = ref('')
// Computed properties
const needsApiKey = computed(() => {
if (!selectedProvider.value)
return false
return selectedProvider.value.id !== 'ollama' && selectedProvider.value.id !== 'player2'
})
const needsBaseUrl = computed(() => {
if (!selectedProvider.value)
return false
return selectedProvider.value.id !== 'cloudflare-workers-ai'
})
const canSave = computed(() => {
if (!selectedProvider.value)
return false
if (needsApiKey.value && !apiKey.value.trim())
return false
if (needsBaseUrl.value && !baseUrl.value.trim())
return false
if (selectedProvider.value.id === 'cloudflare-workers-ai' && !accountId.value.trim())
return false
if (!activeModel.value)
return false
return isValid.value
})
// Provider selection
// Reset validation state when provider changes
function selectProvider(provider: typeof popularProviders.value[0]) {
selectedProviderId.value = provider.id
// Set default values
const defaultOptions = provider.defaultOptions?.() || {}
baseUrl.value = (defaultOptions as any)?.baseUrl || ''
apiKey.value = ''
accountId.value = ''
// Reset validation
isValid.value = false
validationMessage.value = ''
}
// Placeholder helpers
function getApiKeyPlaceholder(_providerId: string): string {
const placeholders: Record<string, string> = {
'openai': 'sk-...',
'anthropic': 'sk-ant-...',
'google-generative-ai': 'GEMINI_API_KEY',
'openrouter-ai': 'sk-or-...',
'deepseek': 'sk-...',
'xai': 'xai-...',
'together-ai': 'togetherapi-...',
'mistral-ai': 'mis-...',
'moonshot-ai': 'ms-...',
'modelscope': 'ms-...',
'fireworks-ai': 'fw-...',
'featherless-ai': 'fw-...',
'novita-ai': 'nvt-...',
}
return placeholders[_providerId] || 'API Key'
}
function getBaseUrlPlaceholder(_providerId: string): string {
const defaultOptions = selectedProvider.value?.defaultOptions?.() || {}
return (defaultOptions as any)?.baseUrl || 'https://api.example.com/v1/'
}
// Validation
async function validateConfiguration() {
if (!selectedProvider.value)
return
isValidating.value++
// service startup time
const startValidationTimestamp = performance.now()
let finalValidationMessage = ''
try {
// Prepare config object
const config: Record<string, unknown> = {}
if (needsApiKey.value)
config.apiKey = apiKey.value.trim()
if (needsBaseUrl.value)
config.baseUrl = baseUrl.value.trim()
if (selectedProvider.value.id === 'cloudflare-workers-ai')
config.accountId = accountId.value.trim()
// Validate using provider's validator
const metadata = providersStore.getProviderMetadata(selectedProvider.value.id)
const validationResult = await metadata.validators.validateProviderConfig(config)
isValid.value = validationResult.valid
if (!isValid.value) {
finalValidationMessage = validationResult.reason
}
}
catch (error) {
isValid.value = false
finalValidationMessage = t('settings.dialogs.onboarding.validationError', {
error: error instanceof Error ? error.message : String(error),
})
}
finally {
setTimeout(() => {
isValidating.value--
validationMessage.value = finalValidationMessage
}, debounceTime - (performance.now() - startValidationTimestamp))
}
}
// Debounced validation function
const debouncedValidateConfiguration = useDebounceFn(() => {
if (!selectedProvider.value)
return
if (needsApiKey.value && !apiKey.value.trim())
return
if (needsBaseUrl.value && !baseUrl.value.trim())
return
if (selectedProvider.value.id === 'cloudflare-workers-ai' && !accountId.value.trim())
return
validateConfiguration()
}, debounceTime)
// Watch for changes and validate
watch([apiKey, baseUrl, accountId], () => {
if (selectedProvider.value && (apiKey.value || baseUrl.value || accountId.value)) {
debouncedValidateConfiguration()
}
}, { deep: true })
function handlePreviousStep() {
if (step.value > 1) {
direction.value = 'previous'
@@ -206,7 +56,16 @@ function handlePreviousStep() {
}
}
function handleNextStep() {
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++
@@ -216,19 +75,18 @@ function handleNextStep() {
}
}
async function handleFinishProviderConfiguration() {
async function saveProviderConfiguration(data: { apiKey: string, baseUrl: string, accountId: string }) {
if (!selectedProvider.value)
return
// Save configuration to providers store
const config: Record<string, unknown> = {}
if (needsApiKey.value)
config.apiKey = apiKey.value.trim()
if (needsBaseUrl.value)
config.baseUrl = baseUrl.value.trim()
if (selectedProvider.value.id === 'cloudflare-workers-ai')
config.accountId = accountId.value.trim()
if (data.apiKey)
config.apiKey = data.apiKey.trim()
if (data.baseUrl)
config.baseUrl = data.baseUrl.trim()
if (data.accountId)
config.accountId = data.accountId.trim()
providers.value[selectedProvider.value.id] = {
...providers.value[selectedProvider.value.id],
@@ -244,241 +102,30 @@ async function handleFinishProviderConfiguration() {
catch (err) {
console.error('error', err)
}
handleNextStep()
}
async function handleSave() {
emit('configured')
}
// Initialize with first popular provider
onMounted(() => {
if (popularProviders.value.length > 0) {
selectedProviderId.value = popularProviders.value[0].id
selectProvider(popularProviders.value[0])
}
provide(OnboardingContextKey, {
selectedProviderId,
selectedProvider,
selectProvider,
popularProviders,
handleNextStep,
handlePreviousStep,
handleSave,
})
</script>
<template>
<div h-full w-full>
<Transition :name="direction === 'next' ? 'slide-next' : 'slide-prev'" mode="out-in">
<!-- Step 1 -->
<template v-if="step === 1">
<div h-full flex flex-col>
<div class="mb-2 text-center md:mb-8" flex flex-1 flex-col justify-center>
<div
v-motion
:initial="{ opacity: 0, scale: 0.5 }"
:visible="{ opacity: 1, scale: 1 }"
:duration="500"
class="mb-1 flex justify-center md:mb-4 lg:pt-16 md:pt-8"
>
<img :src="onboardingLogo" max-h="50" aspect-square h-auto w-auto object-cover>
</div>
<h2
v-motion
:initial="{ opacity: 0, y: 10 }"
:visible="{ opacity: 1, y: 0 }"
:duration="500"
class="mb-0 text-3xl text-neutral-800 font-bold md:mb-2 dark:text-neutral-100"
>
{{ t('settings.dialogs.onboarding.title') }}
</h2>
<p
v-motion
:initial="{ opacity: 0, y: 10 }"
:visible="{ opacity: 1, y: 0 }"
:duration="500"
:delay="100"
class="text-sm text-neutral-600 md:text-lg dark:text-neutral-400"
>
{{ t('settings.dialogs.onboarding.description') }}
</p>
</div>
<Button
v-motion
:initial="{ opacity: 0 }"
:visible="{ opacity: 1 }"
:duration="500"
:delay="200"
:label="t('settings.dialogs.onboarding.start')"
@click="handleNextStep"
/>
</div>
</template>
<!-- Provider Selection -->
<template v-else-if="step === 2">
<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="handlePreviousStep">
<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">
{{ t('settings.dialogs.onboarding.selectProvider') }}
</h2>
<div class="h-5 w-5" />
</div>
<div class="flex-1 overflow-y-auto">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<RadioCardDetail
v-for="provider in popularProviders"
:id="provider.id"
:key="provider.id"
v-model="selectedProviderId"
name="provider-selection"
:value="provider.id"
:title="provider.localizedName || provider.id"
:description="provider.localizedDescription || ''"
@click="selectProvider(provider)"
/>
</div>
</div>
<Button
:label="t('settings.dialogs.onboarding.next')"
:disabled="!selectedProviderId"
@click="handleNextStep"
/>
</div>
</template>
<!-- Configuration Form -->
<template v-else-if="step === 3 && selectedProvider">
<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="handlePreviousStep">
<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: selectedProvider!.localizedName }) }}
</h2>
<div h-5 w-5 />
</div>
<div v-if="selectedProvider" flex-1 overflow-y-auto space-y-4>
<Callout label="Keep your API keys and credentials safe!" theme="violet">
<div>
<div>
AIRI is running pure locally in your browser, and we will never steal your credentials for AI / LLM providers. But keep in mind that your API keys are sensitive information. Make sure to keep them safe and do not share them with anyone.
</div>
<div>
AIRI is open sourced at <div inline-flex translate-y-1 items-center gap-1>
<div i-simple-icons:github inline-block /><a decoration-underline decoration-dashed href="https://github.com/moeru-ai/airi" target="_blank" rel="noopener noreferrer">GitHub</a>
</div>, if you want to check how we handle your credentials, feel free to inspect our code.
</div>
</div>
</Callout>
<div class="space-y-4">
<!-- API Key Input -->
<div v-if="needsApiKey">
<FieldInput
v-model="apiKey"
:placeholder="getApiKeyPlaceholder(selectedProvider.id)"
type="password"
label="API Key"
description="Enter your API key for the selected provider."
required
/>
</div>
<!-- Base URL Input -->
<div v-if="needsBaseUrl">
<FieldInput
v-model="baseUrl"
:placeholder="getBaseUrlPlaceholder(selectedProvider.id)"
type="text"
label="Base URL"
description="Enter the base URL for the provider's API."
/>
</div>
<!-- Account ID for Cloudflare -->
<div v-if="selectedProvider.id === 'cloudflare-workers-ai'">
<ProviderAccountIdInput v-model="accountId" />
</div>
</div>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</div>
<!-- Action Buttons -->
<Button
:label="t('settings.dialogs.onboarding.next')"
:loading="isLoadingActiveProviderModels || isValidating > 0"
:disabled="!selectedProviderId || (needsApiKey && apiKey.trim().length === 0) || !isValid"
@click="handleFinishProviderConfiguration"
/>
</div>
</template>
<!-- Models Configuration Form -->
<template v-else-if="step === 4 && selectedProvider">
<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="handlePreviousStep">
<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.select-model') }}
</h2>
<div h-5 w-5 />
</div>
<!-- Using the new RadioCardManySelect component -->
<div flex-1>
<RadioCardManySelect
v-if="providerModels.length > 0"
v-model="activeModel"
v-model:search-query="modelSearchQuery"
:items="providerModels.toSorted((a, b) => a.id === activeModel ? -1 : b.id === activeModel ? 1 : 0)"
:searchable="true"
:search-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_placeholder')"
:search-no-results-title="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results')"
:search-no-results-description="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results_description', { query: modelSearchQuery })"
:search-results-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_results', { count: '{count}', total: '{total}' })"
:custom-input-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.custom_model_placeholder')"
:expand-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.expand')"
:collapse-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.collapse')"
list-class="max-h-[calc(100dvh-17rem)] sm:max-h-120"
/>
<Alert v-else type="error">
<template #title>
{{ t('settings.dialogs.onboarding.no-models') }}
</template>
<template #content>
<div class="whitespace-pre-wrap break-all">
{{ t('settings.dialogs.onboarding.no-models-help') }}
</div>
</template>
</Alert>
</div>
<!-- Action Buttons -->
<Button
variant="primary"
:disabled="!canSave"
:label="t('settings.dialogs.onboarding.saveAndContinue')"
@click="handleSave"
/>
</div>
</template>
<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" />
</Transition>
</div>
</template>
@@ -0,0 +1,76 @@
<script setup lang="ts">
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 { Button } from '../../../misc'
import { OnboardingContextKey } from './utils'
const { t } = useI18n()
const context = inject(OnboardingContextKey)!
const consciousnessStore = useConsciousnessStore()
const {
activeModel,
modelSearchQuery,
providerModels,
isLoadingActiveProviderModels,
} = storeToRefs(consciousnessStore)
</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">
<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.select-model') }}
</h2>
<div h-5 w-5 />
</div>
<!-- Using the new RadioCardManySelect component -->
<div flex-1>
<RadioCardManySelect
v-if="providerModels.length > 0"
v-model="activeModel"
v-model:search-query="modelSearchQuery"
:items="providerModels.toSorted((a, b) => a.id === activeModel ? -1 : b.id === activeModel ? 1 : 0)"
:searchable="true"
:search-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_placeholder')"
:search-no-results-title="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results')"
:search-no-results-description="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results_description', { query: modelSearchQuery })"
:search-results-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_results', { count: '{count}', total: '{total}' })"
:custom-input-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.custom_model_placeholder')"
:expand-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.expand')"
:collapse-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.collapse')"
list-class="max-h-[calc(100dvh-17rem)] sm:max-h-120"
/>
<Alert v-else type="error">
<template #title>
{{ t('settings.dialogs.onboarding.no-models') }}
</template>
<template #content>
<div class="whitespace-pre-wrap break-all">
{{ t('settings.dialogs.onboarding.no-models-help') }}
</div>
</template>
</Alert>
</div>
<!-- Action Buttons -->
<Button
variant="primary"
:disabled="!activeModel"
:loading="isLoadingActiveProviderModels"
:label="t('settings.dialogs.onboarding.saveAndContinue')"
@click="context.handleSave"
/>
</div>
</template>
@@ -0,0 +1,211 @@
<script setup lang="ts">
import { FieldInput } from '@proj-airi/ui'
import { computed, inject, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useProvidersStore } from '../../../../stores/providers'
import { Callout } from '../../../layouts'
import { Button, ErrorContainer } from '../../../misc'
import { ProviderAccountIdInput } from '../../../scenarios/providers'
import { OnboardingContextKey } from './utils'
const { t } = useI18n()
const context = inject(OnboardingContextKey)!
const providersStore = useProvidersStore()
const apiKey = ref('')
const baseUrl = ref('')
const accountId = ref('')
const validation = ref<'unchecked' | 'pending' | 'succeed' | 'failed'>('unchecked')
const validationError = ref<any>()
// Initialize form with default values when provider changes
function initializeForm() {
const provider = context.selectedProvider.value
if (!provider)
return
const defaultOptions = provider.defaultOptions?.() || {}
baseUrl.value = (defaultOptions as any)?.baseUrl || ''
apiKey.value = ''
accountId.value = ''
// Reset validation
validation.value = 'unchecked'
validationError.value = undefined
}
// Watch for provider changes
watch(() => context.selectedProvider.value?.id, () => {
initializeForm()
})
// Computed properties
const needsApiKey = computed(() => {
if (!context.selectedProvider.value)
return false
return context.selectedProvider.value.id !== 'ollama' && context.selectedProvider.value.id !== 'player2'
})
const needsBaseUrl = computed(() => {
if (!context.selectedProvider.value)
return false
return context.selectedProvider.value.id !== 'cloudflare-workers-ai'
})
const canProceed = computed(() => {
if (!context.selectedProviderId.value)
return false
if (needsApiKey.value && !apiKey.value.trim())
return false
return validation.value === 'unchecked' || validation.value === 'succeed'
})
async function validateConfiguration() {
if (!context.selectedProvider.value)
return
validation.value = 'pending'
try {
// Prepare config object
const config: Record<string, unknown> = {}
if (needsApiKey.value)
config.apiKey = apiKey.value.trim()
if (needsBaseUrl.value)
config.baseUrl = baseUrl.value.trim()
if (context.selectedProvider.value.id === 'cloudflare-workers-ai')
config.accountId = accountId.value.trim()
// Validate using provider's validator
const metadata = providersStore.getProviderMetadata(context.selectedProvider.value.id)
const validationResult = await metadata.validators.validateProviderConfig(config)
validation.value = validationResult.valid ? 'succeed' : 'failed'
if (validation.value === 'failed') {
validationError.value = validationResult.reason
}
}
catch (error) {
validation.value = 'failed'
validationError.value = t('settings.dialogs.onboarding.validationError', {
error: error instanceof Error ? error.message : String(error),
})
}
}
async function handleNext() {
await validateConfiguration()
if (validation.value !== 'failed') {
await context.handleNextStep({
apiKey: apiKey.value,
baseUrl: baseUrl.value,
accountId: accountId.value,
})
}
}
// Placeholder helpers
function getApiKeyPlaceholder(providerId: string): string {
const placeholders: Record<string, string> = {
'openai': 'sk-...',
'anthropic': 'sk-ant-...',
'google-generative-ai': 'AI...',
'openrouter-ai': 'sk-or-...',
'deepseek': 'sk-...',
'xai': 'xai-...',
'together-ai': 'togetherapi-...',
'mistral-ai': 'mis-...',
'moonshot-ai': 'ms-...',
'modelscope': 'ms-...',
'fireworks-ai': 'fw-...',
'featherless-ai': 'fw-...',
'novita-ai': 'nvt-...',
}
return placeholders[providerId] || 'API Key'
}
function getBaseUrlPlaceholder(_providerId: string): string {
const defaultOptions = context.selectedProvider.value?.defaultOptions?.() || {}
return (defaultOptions as any)?.baseUrl || 'https://api.example.com/v1/'
}
// Initialize on mount
initializeForm()
</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">
<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 }) }}
</h2>
<div h-5 w-5 />
</div>
<div v-if="context.selectedProvider.value" flex-1 overflow-y-auto space-y-4>
<Callout label="Keep your API keys and credentials safe!" theme="violet">
<div>
<div>
AIRI is running pure locally in your browser, and we will never steal your credentials for AI / LLM providers. But keep in mind that your API keys are sensitive information. Make sure to keep them safe and do not share them with anyone.
</div>
<div>
AIRI is open sourced at <div inline-flex translate-y-1 items-center gap-1>
<div i-simple-icons:github inline-block /><a decoration-underline decoration-dashed href="https://github.com/moeru-ai/airi" target="_blank" rel="noopener noreferrer">GitHub</a>
</div>, if you want to check how we handle your credentials, feel free to inspect our code.
</div>
</div>
</Callout>
<div class="space-y-4">
<!-- API Key Input -->
<div v-if="needsApiKey">
<FieldInput
v-model="apiKey"
:placeholder="getApiKeyPlaceholder(context.selectedProvider.value.id)"
type="password"
label="API Key"
description="Enter your API key for the selected provider."
required
/>
</div>
<!-- Base URL Input -->
<div v-if="needsBaseUrl">
<FieldInput
v-model="baseUrl"
:placeholder="getBaseUrlPlaceholder(context.selectedProvider.value.id)"
type="text"
label="Base URL"
description="Enter the base URL for the provider's API."
/>
</div>
<!-- Account ID for Cloudflare -->
<div v-if="context.selectedProvider.value.id === 'cloudflare-workers-ai'">
<ProviderAccountIdInput v-model="accountId" />
</div>
</div>
<!-- Validation Status -->
<ErrorContainer
v-if="validation === 'failed'"
:title="t('settings.dialogs.onboarding.validationFailed')"
:error="validationError"
/>
</div>
<!-- Action Buttons -->
<Button
:label="t('settings.dialogs.onboarding.next')"
:loading="validation === 'pending'"
:disabled="!canProceed"
@click="handleNext"
/>
</div>
</template>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { RadioCardDetail } from '../../../menu'
import { Button } from '../../../misc'
import { OnboardingContextKey } from './utils'
const { t } = useI18n()
const context = inject(OnboardingContextKey)!
</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">
<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">
{{ t('settings.dialogs.onboarding.selectProvider') }}
</h2>
<div class="h-5 w-5" />
</div>
<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"
:id="provider.id"
:key="provider.id"
v-model="context.selectedProviderId.value"
name="provider-selection"
:value="provider.id"
:title="provider.localizedName || provider.id"
:description="provider.localizedDescription || ''"
@click="context.selectProvider(provider)"
/>
</div>
</div>
<Button
:label="t('settings.dialogs.onboarding.next')"
:disabled="!context.selectedProviderId.value"
@click="context.handleNextStep"
/>
</div>
</template>
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { inject } from 'vue'
import { useI18n } from 'vue-i18n'
import onboardingLogo from '../../../../assets/onboarding.avif'
import { Button } from '../../../misc'
import { OnboardingContextKey } from './utils'
const { t } = useI18n()
const context = inject(OnboardingContextKey)!
</script>
<template>
<div h-full flex flex-col>
<div class="mb-2 text-center md:mb-8" flex flex-1 flex-col justify-center>
<div
v-motion
:initial="{ opacity: 0, scale: 0.5 }"
:visible="{ opacity: 1, scale: 1 }"
:duration="500"
class="mb-1 flex justify-center md:mb-4 lg:pt-16 md:pt-8"
>
<img :src="onboardingLogo" max-h="50" aspect-square h-auto w-auto object-cover>
</div>
<h2
v-motion
:initial="{ opacity: 0, y: 10 }"
:visible="{ opacity: 1, y: 0 }"
:duration="500"
class="mb-0 text-3xl text-neutral-800 font-bold md:mb-2 dark:text-neutral-100"
>
{{ t('settings.dialogs.onboarding.title') }}
</h2>
<p
v-motion
:initial="{ opacity: 0, y: 10 }"
:visible="{ opacity: 1, y: 0 }"
:duration="500"
:delay="100"
class="text-sm text-neutral-600 md:text-lg dark:text-neutral-400"
>
{{ t('settings.dialogs.onboarding.description') }}
</p>
</div>
<Button
v-motion
:initial="{ opacity: 0 }"
:visible="{ opacity: 1 }"
:duration="500"
:delay="200"
:label="t('settings.dialogs.onboarding.start')"
@click="context.handleNextStep"
/>
</div>
</template>
@@ -0,0 +1,15 @@
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')