feat: added debouncing for onboarding, fixed validation if the user i… (#392)

This commit is contained in:
MisakaKumomi 御坂云见
2025-08-22 20:55:27 +08:00
committed by GitHub
parent 1b76f65232
commit fd702cc0d3
6 changed files with 116 additions and 16 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ dialogs:
baseUrlHelp: API endpoint URL (use default if unsure)
accountId: Account ID
validating: Validating configuration...
validationSuccess: Configuration is valid!
validationSuccess: Server has response, Configuration may valid.
validationFailed: Configuration validation failed
validationError: 'Validation error: {error}'
skipForNow: Skip for now
@@ -18,7 +18,6 @@ dialogs:
baseUrlHelp: URL del endpoint de la API (usa el predeterminado si no estás seguro)
accountId: ID de Cuenta
validating: Validando configuración...
validationSuccess: ¡La configuración es válida!
validationFailed: La validación de la configuración falló
validationError: 'Error de validación: {error}'
skipForNow: Omitir por ahora
@@ -16,7 +16,7 @@ dialogs:
baseUrlHelp: API 端点 URL(如果不确定请使用默认值)
accountId: 账户 ID
validating: 正在验证配置...
validationSuccess: 配置有效
validationSuccess: 初步测试服务器有响应,配置或许有效
validationFailed: 配置验证失败
validationError: 验证错误:{error}
skipForNow: 暂时跳过
@@ -1,4 +1,4 @@
<script setup lang="ts">
<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'
@@ -21,6 +21,8 @@ interface Emits {
const emit = defineEmits<Emits>()
const debounceTime = 500
const step = ref(1)
const direction = ref<'next' | 'previous'>('next')
@@ -55,8 +57,8 @@ const selectedProvider = computed(() => {
return allChatProvidersMetadata.value.find(p => p.id === selectedProviderId.value) || null
})
// Validation state
const isValidating = ref(false)
// Validation state (animation)
const isValidating = ref(0)
const isValid = ref(false)
const validationMessage = ref('')
@@ -134,8 +136,11 @@ async function validateConfiguration() {
if (!selectedProvider.value)
return
isValidating.value = true
isValidating.value++
// service startup time
const startValidationTimestamp = performance.now()
validationMessage.value = t('settings.dialogs.onboarding.validating')
let finalValidationMessage = ''
try {
// Prepare config object
@@ -155,20 +160,23 @@ async function validateConfiguration() {
isValid.value = validationResult.valid
if (isValid.value) {
validationMessage.value = t('settings.dialogs.onboarding.validationSuccess')
finalValidationMessage = t('settings.dialogs.onboarding.validationSuccess')
}
else {
validationMessage.value = validationResult.reason
finalValidationMessage = validationResult.reason
}
}
catch (error) {
isValid.value = false
validationMessage.value = t('settings.dialogs.onboarding.validationError', {
finalValidationMessage = t('settings.dialogs.onboarding.validationError', {
error: error instanceof Error ? error.message : String(error),
})
}
finally {
isValidating.value = false
setTimeout(() => {
isValidating.value--
validationMessage.value = finalValidationMessage
}, Math.max(0, debounceTime - (performance.now() - startValidationTimestamp)))
}
}
@@ -184,7 +192,7 @@ const debouncedValidateConfiguration = useDebounceFn(() => {
return
validateConfiguration()
}, 500)
}, debounceTime)
// Watch for changes and validate
watch([apiKey, baseUrl, accountId], () => {
@@ -394,7 +402,7 @@ onMounted(() => {
</div>
<!-- Validation Status -->
<Alert v-if="!isValid && validationMessage" type="error">
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
@@ -404,17 +412,17 @@ onMounted(() => {
</div>
</template>
</Alert>
<div v-if="(isValid || isValidating) && validationMessage" class="mt-4">
<div v-if="(isValid || isValidating > 0) && validationMessage" class="mt-4">
<div
class="flex items-center rounded-lg p-3" :class="[
isValidating
(isValidating > 0)
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
: 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300',
]"
>
<div
class="mr-2 text-lg" :class="[
isValidating
(isValidating > 0)
? 'i-svg-spinners:3-dots-fade'
: 'i-solar:check-circle-bold-duotone',
]"
+82
View File
@@ -48,6 +48,7 @@ import {
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { isAbsoluteUrl } from '../utils/string'
import { models as elevenLabsModels } from './providers/elevenlabs/list-models'
export interface ProviderMetadata {
@@ -159,6 +160,11 @@ export interface VoiceInfo {
export const useProvidersStore = defineStore('providers', () => {
const providerCredentials = useLocalStorage<Record<string, Record<string, unknown>>>('settings/credentials/providers', {})
const { t } = useI18n()
const notBaseUrlError = computed(() => ({
errors: [new Error('Base URL is not absolute')],
reason: 'Base URL is not absolute. Check your input.',
valid: false,
}))
// Helper function to fetch OpenRouter models manually
async function fetchOpenRouterModels(config: Record<string, unknown>): Promise<ModelInfo[]> {
@@ -217,6 +223,10 @@ export const useProvidersStore = defineStore('providers', () => {
!config.baseUrl && new Error('Base URL is required'),
].filter(Boolean)
if (!!config.baseUrl && !isAbsoluteUrl(config.baseUrl as string)) {
return notBaseUrlError.value
}
return {
errors,
reason: errors.filter(e => e).map(e => String(e)).join(', ') || '',
@@ -494,6 +504,10 @@ export const useProvidersStore = defineStore('providers', () => {
}
}
if (!isAbsoluteUrl(config.baseUrl as string)) {
return notBaseUrlError.value
}
// Check if the Ollama server is reachable
return fetch(`${(config.baseUrl as string).trim()}models`, { headers: (config.headers as HeadersInit) || undefined })
.then((response) => {
@@ -556,6 +570,10 @@ export const useProvidersStore = defineStore('providers', () => {
}
}
if (!isAbsoluteUrl(config.baseUrl as string)) {
return notBaseUrlError.value
}
// Check if the Ollama server is reachable
return fetch(`${(config.baseUrl as string).trim()}models`, { headers: (config.headers as HeadersInit) || undefined })
.then((response) => {
@@ -647,6 +665,10 @@ export const useProvidersStore = defineStore('providers', () => {
}
}
if (!isAbsoluteUrl(config.baseUrl as string)) {
return notBaseUrlError.value
}
// Check if the vLLM is reachable
return fetch(`${(config.baseUrl as string).trim()}models`, { headers: (config.headers as HeadersInit) || undefined })
.then((response) => {
@@ -778,6 +800,10 @@ export const useProvidersStore = defineStore('providers', () => {
!config.baseUrl && new Error('Base URL is required. Default to https://api.openai.com/v1/ for official OpenAI API.'),
].filter(Boolean)
if (!!config.baseUrl && !isAbsoluteUrl(config.baseUrl as string)) {
return notBaseUrlError.value
}
return {
errors,
reason: errors.filter(e => e).map(e => String(e)).join(', ') || '',
@@ -891,6 +917,10 @@ export const useProvidersStore = defineStore('providers', () => {
!config.baseUrl && new Error('Base URL is required. Default to https://api.openai.com/v1/ for official OpenAI API.'),
].filter(Boolean)
if (!!config.baseUrl && !isAbsoluteUrl(config.baseUrl as string)) {
return notBaseUrlError.value
}
return {
errors,
reason: errors.filter(e => e).map(e => String(e)).join(', ') || '',
@@ -934,6 +964,10 @@ export const useProvidersStore = defineStore('providers', () => {
!config.baseUrl && new Error('Base URL is required. Default to https://api.openai.com/v1/ for official OpenAI API.'),
].filter(Boolean)
if (!!config.baseUrl && !isAbsoluteUrl(config.baseUrl as string)) {
return notBaseUrlError.value
}
return {
errors,
reason: errors.filter(e => e).map(e => String(e)).join(', ') || '',
@@ -1065,6 +1099,10 @@ export const useProvidersStore = defineStore('providers', () => {
!config.baseUrl && new Error('Base URL is required. Default to https://api.anthropic.com/v1/ for official Claude API with OpenAI compatibility.'),
].filter(Boolean)
if (!!config.baseUrl && !isAbsoluteUrl(config.baseUrl as string)) {
return notBaseUrlError.value
}
return {
errors,
reason: errors.filter(e => e).map(e => String(e)).join(', ') || '',
@@ -1109,6 +1147,10 @@ export const useProvidersStore = defineStore('providers', () => {
!config.baseUrl && new Error('Base URL is required. Default to https://generativelanguage.googleapis.com/v1beta/openai/ for official Google Gemini API with OpenAI compatibility.'),
].filter(Boolean)
if (!!config.baseUrl && !isAbsoluteUrl(config.baseUrl as string)) {
return notBaseUrlError.value
}
return {
errors,
reason: errors.filter(e => e).map(e => String(e)).join(', ') || '',
@@ -1194,6 +1236,10 @@ export const useProvidersStore = defineStore('providers', () => {
!config.baseUrl && new Error('Base URL is required.'),
].filter(Boolean)
if (!!config.baseUrl && !isAbsoluteUrl(config.baseUrl as string)) {
return notBaseUrlError.value
}
return {
errors,
reason: errors.filter(e => e).map(e => String(e)).join(', ') || '',
@@ -1274,6 +1320,10 @@ export const useProvidersStore = defineStore('providers', () => {
!config.baseUrl && new Error('Base URL is required.'),
].filter(Boolean)
if (!!config.baseUrl && !isAbsoluteUrl(config.baseUrl as string)) {
return notBaseUrlError.value
}
return {
errors,
reason: errors.filter(e => e).map(e => String(e)).join(', ') || '',
@@ -1334,6 +1384,10 @@ export const useProvidersStore = defineStore('providers', () => {
!config.baseUrl && new Error('Base URL is required.'),
].filter(Boolean)
if (!!config.baseUrl && !isAbsoluteUrl(config.baseUrl as string)) {
return notBaseUrlError.value
}
return {
errors,
reason: errors.filter(e => e).map(e => String(e)).join(', ') || '',
@@ -1391,6 +1445,10 @@ export const useProvidersStore = defineStore('providers', () => {
!config.baseUrl && new Error('Base URL is required. Default to http://localhost:11996/tts for Index-TTS.'),
].filter(Boolean)
if (!!config.baseUrl && !isAbsoluteUrl(config.baseUrl as string)) {
return notBaseUrlError.value
}
return {
errors,
reason: errors.filter(e => e).map(e => String(e)).join(', ') || '',
@@ -1459,6 +1517,10 @@ export const useProvidersStore = defineStore('providers', () => {
!config.baseUrl && new Error('Base URL is required.'),
].filter(Boolean)
if (!!config.baseUrl && !isAbsoluteUrl(config.baseUrl as string)) {
return notBaseUrlError.value
}
return {
errors,
reason: errors.filter(e => e).map(e => String(e)).join(', ') || '',
@@ -1520,6 +1582,10 @@ export const useProvidersStore = defineStore('providers', () => {
!((config.app as any)?.appId) && new Error('App ID is required.'),
].filter(Boolean)
if (!!config.baseUrl && !isAbsoluteUrl(config.baseUrl as string)) {
return notBaseUrlError.value
}
return {
errors,
reason: errors.filter(e => e).map(e => String(e)).join(', ') || '',
@@ -1687,6 +1753,10 @@ export const useProvidersStore = defineStore('providers', () => {
!config.baseUrl && new Error('Base URL is required.'),
].filter(Boolean)
if (!!config.baseUrl && !isAbsoluteUrl(config.baseUrl as string)) {
return notBaseUrlError.value
}
return {
errors,
reason: errors.filter(e => e).map(e => String(e)).join(', ') || '',
@@ -1786,6 +1856,10 @@ export const useProvidersStore = defineStore('providers', () => {
!config.baseUrl && new Error('Base URL is required.'),
].filter(Boolean)
if (!!config.baseUrl && !isAbsoluteUrl(config.baseUrl as string)) {
return notBaseUrlError.value
}
return {
errors,
reason: errors.filter(e => e).map(e => String(e)).join(', ') || '',
@@ -1910,6 +1984,10 @@ export const useProvidersStore = defineStore('providers', () => {
}
}
if (!isAbsoluteUrl(config.baseUrl as string)) {
return notBaseUrlError.value
}
// Check if the local running Player 2 is reachable
return await fetch(`${(config.baseUrl as string).endsWith('/') ? (config.baseUrl as string).slice(0, -1) : config.baseUrl}/health`, {
method: 'GET',
@@ -2015,6 +2093,10 @@ export const useProvidersStore = defineStore('providers', () => {
}
}
if (!isAbsoluteUrl(config.baseUrl as string)) {
return notBaseUrlError.value
}
return {
errors: [],
reason: '',
+11
View File
@@ -0,0 +1,11 @@
export function isAbsoluteUrl(url: string) {
try {
// This could be the most reliable way to check so using side-effect is acceptable
// eslint-disable-next-line no-new
new URL(url)
return true
}
catch {
return false
}
}