fix: casing

This commit is contained in:
Neko Ayaka
2025-10-08 17:26:51 +08:00
parent 82a089605f
commit 23b9fe5bb2
3 changed files with 539 additions and 2 deletions
@@ -1,2 +1,2 @@
export { default as OnboardingDialog } from './onboarding-dialog.vue'
export { default as OnboardingScreen } from './onboarding.vue'
export { default as OnboardingScreen } from './Onboarding.vue'
@@ -0,0 +1,537 @@
<script setup lang="ts" xmlns:i-solar="http://www.w3.org/1999/xhtml">
import { FieldInput } from '@proj-airi/ui'
import { useDebounceFn } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import onboardingLogo from '../../../../assets/onboarding.avif'
import Alert from '../../../misc/Alert.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'
interface Emits {
(e: 'configured'): void
(e: 'skipped'): void
}
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
const popularProviders = computed(() => {
const popular = ['openai', 'anthropic', 'google-generative-ai', 'openrouter-ai', 'ollama', 'deepseek', 'player2', 'openai-compatible']
return allChatProvidersMetadata.value
.filter(provider => popular.includes(provider.id))
.sort((a, b) => popular.indexOf(a.id) - popular.indexOf(b.id))
})
// 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
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'
step.value--
}
}
function handleNextStep() {
if (step.value < 4) {
direction.value = 'next'
step.value++
}
else {
handleSave()
}
}
async function handleFinishProviderConfiguration() {
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()
providers.value[selectedProvider.value.id] = {
...providers.value[selectedProvider.value.id],
...config,
}
activeProvider.value = selectedProvider.value.id
await nextTick()
try {
await consciousnessStore.loadModelsForProvider(selectedProvider.value.id)
}
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])
}
})
</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>
</Transition>
</div>
</template>
<style scoped>
.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-prev-enter-active,
.slide-prev-leave-active {
transition: transform 0.2s ease-in-out, opacity 0.2s ease-in-out;
}
.slide-prev-enter-from {
transform: translateX(-100%);
opacity: 0;
}
.slide-prev-enter-to {
transform: translateX(0);
opacity: 1;
}
.slide-prev-leave-from {
transform: translateX(0);
opacity: 1;
}
.slide-prev-leave-to {
transform: translateX(100%);
opacity: 0;
}
</style>
@@ -1,2 +1,2 @@
export { default as OnboardingDialog } from './onboarding-dialog.vue'
export { default as OnboardingScreen } from './onboarding.vue'
export { default as OnboardingScreen } from './Onboarding.vue'