feat(stage-web): launch setup (#259)

This commit is contained in:
melty kiss
2025-07-06 23:24:00 +08:00
committed by GitHub
parent 4ffe2e4f99
commit 15dcca498a
16 changed files with 544 additions and 7 deletions
+4
View File
@@ -56,6 +56,10 @@
], ],
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
"typescript.preferences.includePackageJsonAutoImports": "off",
"typescript.suggest.autoImports": false,
"vue.server.hybridMode": true,
"vue.server.maxFileSize": 20971520,
"cSpell.words": [ "cSpell.words": [
"appimage", "appimage",
"localforage" "localforage"
+22 -1
View File
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AiriTamagotchiEvents } from './composables/tauri' import type { AiriTamagotchiEvents } from './composables/tauri'
import { useMcpStore, useSettings } from '@proj-airi/stage-ui/stores' import { FirstTimeSetupDialog } from '@proj-airi/stage-ui/components'
import { useFirstTimeSetupStore, useMcpStore, useSettings } from '@proj-airi/stage-ui/stores'
import { Window } from '@tauri-apps/api/window' import { Window } from '@tauri-apps/api/window'
import { useEventListener } from '@vueuse/core' import { useEventListener } from '@vueuse/core'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
@@ -17,6 +18,8 @@ const { language, themeColorsHue, themeColorsHueDynamic, allowVisibleOnAllWorksp
const i18n = useI18n() const i18n = useI18n()
const windowControlStore = useWindowControlStore() const windowControlStore = useWindowControlStore()
const mcpStore = useMcpStore() const mcpStore = useMcpStore()
const firstTimeSetupStore = useFirstTimeSetupStore()
const { shouldShowSetup } = storeToRefs(firstTimeSetupStore)
const { platform } = useAppRuntime() const { platform } = useAppRuntime()
const { listen } = useTauriEvent<AiriTamagotchiEvents>() const { listen } = useTauriEvent<AiriTamagotchiEvents>()
@@ -31,6 +34,8 @@ watch(language, () => {
// FIXME: store settings to file // FIXME: store settings to file
onMounted(() => { onMounted(() => {
// Initialize first-time setup check
firstTimeSetupStore.initializeSetupCheck()
}) })
watch(themeColorsHue, () => { watch(themeColorsHue, () => {
@@ -47,6 +52,15 @@ onMounted(() => {
}) })
}) })
// Handle first-time setup events
function handleSetupConfigured() {
firstTimeSetupStore.markSetupCompleted()
}
function handleSetupSkipped() {
firstTimeSetupStore.markSetupSkipped()
}
const isMac = computed(() => platform.value === 'macos') const isMac = computed(() => platform.value === 'macos')
if (isMac.value) { if (isMac.value) {
@@ -64,6 +78,13 @@ if (isMac.value) {
<template> <template>
<RouterView /> <RouterView />
<!-- First Time Setup Dialog -->
<FirstTimeSetupDialog
v-model="shouldShowSetup"
@configured="handleSetupConfigured"
@skipped="handleSetupSkipped"
/>
</template> </template>
<style> <style>
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AiriTamagotchiEvents, Point } from '../composables/tauri' import type { AiriTamagotchiEvents, Point } from '../composables/tauri'
import { WidgetStage } from '@proj-airi/stage-ui/components/scenes' import { WidgetStage } from '@proj-airi/stage-ui/components/Scenes'
import { useMcpStore } from '@proj-airi/stage-ui/stores' import { useMcpStore } from '@proj-airi/stage-ui/stores'
import { connectServer } from '@proj-airi/tauri-plugin-mcp' import { connectServer } from '@proj-airi/tauri-plugin-mcp'
import { useWindowSize } from '@vueuse/core' import { useWindowSize } from '@vueuse/core'
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Live2DCanvas, Live2DModel } from '@proj-airi/stage-ui/components/scenes' import { Live2DCanvas, Live2DModel } from '@proj-airi/stage-ui/components/Scenes'
import { useElementBounding, useMouse } from '@vueuse/core' import { useElementBounding, useMouse } from '@vueuse/core'
import { Vibrant } from 'node-vibrant/browser' import { Vibrant } from 'node-vibrant/browser'
import { ref } from 'vue' import { ref } from 'vue'
+26 -2
View File
@@ -1,14 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { useSettings } from '@proj-airi/stage-ui/stores/settings' import { FirstTimeSetupDialog } from '@proj-airi/stage-ui/components'
import { useFirstTimeSetupStore, useSettings } from '@proj-airi/stage-ui/stores'
import { StageTransitionGroup } from '@proj-airi/ui-transitions' import { StageTransitionGroup } from '@proj-airi/ui-transitions'
import { useDark } from '@vueuse/core' import { useDark } from '@vueuse/core'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { computed, watch } from 'vue' import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
const i18n = useI18n() const i18n = useI18n()
const settings = storeToRefs(useSettings()) const settings = storeToRefs(useSettings())
const firstTimeSetupStore = useFirstTimeSetupStore()
const { shouldShowSetup } = storeToRefs(firstTimeSetupStore)
const isDark = useDark() const isDark = useDark()
const primaryColor = computed(() => { const primaryColor = computed(() => {
@@ -44,6 +47,20 @@ watch(settings.themeColorsHue, () => {
watch(settings.themeColorsHueDynamic, () => { watch(settings.themeColorsHueDynamic, () => {
document.documentElement.classList.toggle('dynamic-hue', settings.themeColorsHueDynamic.value) document.documentElement.classList.toggle('dynamic-hue', settings.themeColorsHueDynamic.value)
}, { immediate: true }) }, { immediate: true })
// Initialize first-time setup check when app mounts
onMounted(() => {
firstTimeSetupStore.initializeSetupCheck()
})
// Handle first-time setup events
function handleSetupConfigured() {
firstTimeSetupStore.markSetupCompleted()
}
function handleSetupSkipped() {
firstTimeSetupStore.markSetupSkipped()
}
</script> </script>
<template> <template>
@@ -58,6 +75,13 @@ watch(settings.themeColorsHueDynamic, () => {
> >
<RouterView /> <RouterView />
</StageTransitionGroup> </StageTransitionGroup>
<!-- First Time Setup Dialog -->
<FirstTimeSetupDialog
v-model="shouldShowSetup"
@configured="handleSetupConfigured"
@skipped="handleSetupSkipped"
/>
</template> </template>
<style> <style>
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { WidgetStage } from '@proj-airi/stage-ui/components/scenes' import { WidgetStage } from '@proj-airi/stage-ui/components/Scenes'
import { useDark, useMouse } from '@vueuse/core' import { useDark, useMouse } from '@vueuse/core'
import { ref } from 'vue' import { ref } from 'vue'
+1
View File
@@ -195,6 +195,7 @@ words:
- tamagotchi - tamagotchi
- tauri - tauri
- taze - taze
- togetherapi
- tolist - tolist
- tresjs - tresjs
- tsdown - tsdown
@@ -6,6 +6,22 @@ animations:
Some pages will have their own transitions, this will override the stage Some pages will have their own transitions, this will override the stage
transitions transitions
title: Use Page Specific Transitions title: Use Page Specific Transitions
firstTimeSetup:
title: Welcome to AIRI!
description: Let's set up your first AI provider to get started.
selectProvider: Choose an AI Provider
configureProvider: Configure {provider}
apiKey: API Key
apiKeyHelp: Get your API key from {provider} and paste it here
baseUrl: Base URL
baseUrlHelp: API endpoint URL (use default if unsure)
accountId: Account ID
validating: Validating configuration...
validationSuccess: ✓ Configuration is valid!
validationFailed: ✗ Configuration validation failed
validationError: 'Validation error: {error}'
skipForNow: Skip for now
saveAndContinue: Save and Continue
language: language:
chinese: 简体中文 chinese: 简体中文
zh-CN: 简体中文 zh-CN: 简体中文
@@ -4,6 +4,22 @@ animations:
use-page-specific-transitions: use-page-specific-transitions:
description: 某些页面会有自己的过场动画,这将覆盖舞台过场动画 description: 某些页面会有自己的过场动画,这将覆盖舞台过场动画
title: 是否使用页面特定过场动画 title: 是否使用页面特定过场动画
firstTimeSetup:
title: 欢迎来到 AIRI
description: 让我们设置您的第一个服务来源来开始使用。
selectProvider: 选择服务来源
configureProvider: 配置 {provider}
apiKey: API 密钥
apiKeyHelp: 从 {provider} 获取您的 API 密钥并粘贴到这里
baseUrl: 基础 URL
baseUrlHelp: API 端点 URL(如果不确定请使用默认值)
accountId: 账户 ID
validating: 正在验证配置...
validationSuccess: ✓ 配置有效!
validationFailed: ✗ 配置验证失败
validationError: '验证错误:{error}'
skipForNow: 暂时跳过
saveAndContinue: 保存并继续
language: language:
chinese: 简体中文 chinese: 简体中文
english: English english: English
@@ -0,0 +1 @@
export { default as Range } from './Range.vue'
@@ -0,0 +1,365 @@
<script setup lang="ts">
import { useProvidersStore } from '@proj-airi/stage-ui/stores'
import { Input } from '@proj-airi/ui'
import { useDebounceFn } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import {
DialogContent,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
} from 'radix-vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RadioCardDetail } from '../Menu'
import { Button } from '../Misc'
interface Props {
modelValue: boolean
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'configured'): void
(e: 'skipped'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { t } = useI18n()
const providersStore = useProvidersStore()
const { providers, allChatProvidersMetadata } = storeToRefs(providersStore)
// Dialog state
const showDialog = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
})
// Popular providers for first-time setup
const popularProviders = computed(() => {
const popular = ['openai', 'anthropic', 'google-generative-ai', 'openrouter-ai', 'ollama', 'deepseek']
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
const isValidating = ref(false)
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-api'
})
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
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-...',
'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 = true
validationMessage.value = t('settings.firstTimeSetup.validating')
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)
isValid.value = await metadata.validators.validateProviderConfig(config)
if (isValid.value) {
validationMessage.value = t('settings.firstTimeSetup.validationSuccess')
}
else {
validationMessage.value = t('settings.firstTimeSetup.validationFailed')
}
}
catch (error) {
isValid.value = false
validationMessage.value = t('validationError', {
error: error instanceof Error ? error.message : String(error),
})
}
finally {
isValidating.value = false
}
}
// 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()
}, 500)
// Watch for changes and validate
watch([apiKey, baseUrl, accountId], () => {
if (selectedProvider.value && (apiKey.value || baseUrl.value || accountId.value)) {
debouncedValidateConfiguration()
}
}, { deep: true })
// Actions
function handleSkip() {
showDialog.value = false
emit('skipped')
}
async function handleSave() {
if (!selectedProvider.value || !canSave.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,
}
showDialog.value = false
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>
<DialogRoot :open="showDialog" @update:open="value => showDialog = value">
<DialogPortal>
<DialogOverlay class="data-[state=open]:animate-fadeIn data-[state=closed]:animate-fadeOut fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm" />
<DialogContent class="data-[state=open]:animate-contentShow data-[state=closed]:animate-contentHide fixed left-1/2 top-1/2 z-[9999] mx-4 max-w-2xl w-[92vw] transform rounded-lg bg-white p-8 shadow-xl -translate-x-1/2 -translate-y-1/2 dark:bg-neutral-900">
<!-- Header -->
<div class="mb-8 text-center">
<div class="mb-4 flex justify-center">
<div class="rounded-full from-primary to-primary-600 bg-gradient-to-br p-4">
<div class="i-solar:ghost-smile-bold-duotone text-4xl text-white" />
</div>
</div>
<DialogTitle class="mb-2 text-3xl text-neutral-800 font-bold dark:text-neutral-100">
{{ t('settings.firstTimeSetup.title') }}
</DialogTitle>
<p class="text-lg text-neutral-600 dark:text-neutral-400">
{{ t('settings.firstTimeSetup.description') }}
</p>
</div>
<!-- Provider Selection -->
<div class="mb-6">
<h2 class="mb-4 text-xl text-neutral-800 font-semibold dark:text-neutral-100">
{{ t('settings.firstTimeSetup.selectProvider') }}
</h2>
<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>
<!-- Configuration Form -->
<div v-if="selectedProvider" class="mb-6">
<h3 class="mb-4 text-lg text-neutral-800 font-medium dark:text-neutral-100">
{{ t('settings.configureProvider', { provider: selectedProvider.localizedName }) }}
</h3>
<div class="space-y-4">
<!-- API Key Input -->
<div v-if="needsApiKey">
<label class="mb-2 block text-sm text-neutral-700 font-medium dark:text-neutral-300">
API Key
<span class="text-red-500">*</span>
</label>
<Input
v-model="apiKey"
type="password"
:placeholder="getApiKeyPlaceholder(selectedProvider.id)"
class="w-full"
:required="true"
/>
</div>
<!-- Base URL Input -->
<div v-if="needsBaseUrl">
<label class="mb-2 block text-sm text-neutral-700 font-medium dark:text-neutral-300">
Base URL
</label>
<Input
v-model="baseUrl"
type="url"
:placeholder="getBaseUrlPlaceholder(selectedProvider.id)"
class="w-full"
/>
</div>
<!-- Account ID for Cloudflare -->
<div v-if="selectedProvider.id === 'cloudflare-workers-ai'">
<label class="mb-2 block text-sm text-neutral-700 font-medium dark:text-neutral-300">
Account ID
<span class="text-red-500">*</span>
</label>
<Input
v-model="accountId"
type="text"
placeholder="Account ID"
class="w-full"
:required="true"
/>
</div>
</div>
<!-- Validation Status -->
<div v-if="validationMessage" class="mt-4">
<div
class="flex items-center rounded-lg p-3" :class="[
isValidating
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
: isValid
? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300'
: 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-300',
]"
>
<div
class="mr-2 text-lg" :class="[
isValidating
? 'i-svg-spinners:3-dots-fade'
: isValid
? 'i-mdi:check-circle'
: 'i-mdi:alert-circle',
]"
/>
{{ validationMessage }}
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Button
variant="secondary"
:label="t('settings.firstTimeSetup.skipForNow')"
@click="handleSkip"
/>
<Button
variant="primary"
:disabled="!canSave"
:label="t('settings.firstTimeSetup.saveAndContinue')"
@click="handleSave"
/>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
@@ -0,0 +1 @@
export { default as FirstTimeSetupDialog } from './OnboardingDialog.vue'
@@ -1,3 +1,5 @@
export * from './DataGui'
export * from './FirstTimeSetup'
export * from './Gadgets' export * from './Gadgets'
export * from './Graphics' export * from './Graphics'
export * from './Layouts' export * from './Layouts'
+1
View File
@@ -1,5 +1,6 @@
export * from './audio' export * from './audio'
export * from './chat' export * from './chat'
export * from './onboarding-setup'
export * from './llm' export * from './llm'
export * from './mcp' export * from './mcp'
export * from './modules' export * from './modules'
@@ -0,0 +1,84 @@
import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref, nextTick } from 'vue'
import { useProvidersStore } from './providers'
export const useFirstTimeSetupStore = defineStore('firstTimeSetup', () => {
const providersStore = useProvidersStore()
// Track if first-time setup has been completed or skipped
const hasCompletedSetup = useLocalStorage('firstTimeSetup/completed', false)
const hasSkippedSetup = useLocalStorage('firstTimeSetup/skipped', false)
// Track if we should show the setup dialog
const shouldShowSetup = ref(false)
// Check if any essential provider is configured
const hasEssentialProviderConfigured = computed(() => {
const essentialProviders = ['openai', 'anthropic', 'google-generative-ai', 'openrouter-ai', 'ollama', 'deepseek']
return essentialProviders.some(providerId => providersStore.configuredProviders[providerId])
})
// Check if first-time setup should be shown
const needsFirstTimeSetup = computed(() => {
// Don't show if already completed or skipped
if (hasCompletedSetup.value || hasSkippedSetup.value) {
return false
}
// Don't show if user already has essential providers configured
if (hasEssentialProviderConfigured.value) {
return false
}
return true
})
// Initialize setup check
async function initializeSetupCheck() {
if (needsFirstTimeSetup.value) {
// Use nextTick to ensure the app is fully rendered before showing dialog
await nextTick()
shouldShowSetup.value = true
}
}
// Mark setup as completed
function markSetupCompleted() {
hasCompletedSetup.value = true
hasSkippedSetup.value = false
shouldShowSetup.value = false
}
// Mark setup as skipped
function markSetupSkipped() {
hasSkippedSetup.value = true
shouldShowSetup.value = false
}
// Reset setup state (for testing or re-showing setup)
function resetSetupState() {
hasCompletedSetup.value = false
hasSkippedSetup.value = false
shouldShowSetup.value = false
}
// Force show setup dialog
function forceShowSetup() {
shouldShowSetup.value = true
}
return {
hasCompletedSetup,
hasSkippedSetup,
shouldShowSetup,
hasEssentialProviderConfigured,
needsFirstTimeSetup,
initializeSetupCheck,
markSetupCompleted,
markSetupSkipped,
resetSetupState,
forceShowSetup,
}
})
+2 -1
View File
@@ -28,7 +28,8 @@
"plugins": [ "plugins": [
"@vue-macros/volar/define-models", "@vue-macros/volar/define-models",
"@vue-macros/volar/define-slots" "@vue-macros/volar/define-slots"
] ],
"skipTemplateCodegen": false
}, },
"include": [ "include": [
"env.d.ts", "env.d.ts",