refactor(stage-pages): split onboarding component (#733)
This commit is contained in:
@@ -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>
|
||||
|
||||
+76
@@ -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>
|
||||
+211
@@ -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>
|
||||
+45
@@ -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')
|
||||
Reference in New Issue
Block a user