feat(stage-web,stage-tamagotchi): unified onboarding, better style

This commit is contained in:
Neko Ayaka
2025-07-12 23:02:23 +08:00
parent c52eb447e9
commit 25952b59ae
18 changed files with 391 additions and 1033 deletions
@@ -5,12 +5,14 @@
"windows": [
"main",
"settings",
"chat"
"chat",
"onboarding"
],
"permissions": [
"core:default",
"os:default",
"core:window:default",
"core:window:allow-close",
"core:window:allow-start-dragging",
"core:window:allow-set-focus",
"core:window:allow-set-minimizable",
@@ -1,7 +1,7 @@
use log::debug;
use tauri::Manager;
use crate::app::windows::{chat, settings};
use crate::app::windows::{chat, onboarding, settings};
#[tauri::command]
pub async fn open_settings_window(app: tauri::AppHandle) -> Result<(), tauri::Error> {
@@ -27,6 +27,18 @@ pub async fn open_chat_window(app: tauri::AppHandle) -> Result<(), tauri::Error>
Ok(())
}
#[tauri::command]
pub async fn open_onboarding_window(app: tauri::AppHandle) -> Result<(), tauri::Error> {
let window = app.get_webview_window("onboarding");
if let Some(window) = window {
let _ = window.show();
return Ok(());
}
onboarding::new_onboarding_window(&app)?;
Ok(())
}
#[tauri::command]
pub fn debug_println(msg: serde_json::Value) -> Result<(), tauri::Error> {
debug!("{msg}");
@@ -1,2 +1,3 @@
pub mod chat;
pub mod onboarding;
pub mod settings;
@@ -0,0 +1,37 @@
use std::path::Path;
use anyhow::{Ok, Result};
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
use tauri::{Runtime, WebviewUrl, WebviewWindowBuilder};
pub fn new_onboarding_window<R: Runtime>(
app: &tauri::AppHandle<R>
) -> Result<tauri::WebviewWindow<R>> {
let mut builder = WebviewWindowBuilder::new(
app,
"onboarding",
WebviewUrl::App(Path::new("#/onboarding").to_path_buf()),
)
.title("Onboarding")
.inner_size(800.0, 600.0)
.shadow(true)
.transparent(false)
.accept_first_mouse(true);
#[cfg(target_os = "macos")]
{
// macOS traffic light (red, yellow, green) position customization
//
// feat: traffic light position (#12366) · tauri-apps/tauri@30f5a15
// https://github.com/tauri-apps/tauri/commit/30f5a1553d3c0ce460c9006764200a9210915a44
builder = builder.hidden_title(true);
builder = builder.decorations(true);
builder = builder.title_bar_style(TitleBarStyle::Overlay);
builder = builder.traffic_light_position(tauri::LogicalPosition::new(14.0, 20.0));
}
let window = builder.build()?;
Ok(window)
}
@@ -175,6 +175,7 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
app::commands::open_settings_window,
app::commands::open_chat_window,
app::commands::open_onboarding_window,
app::commands::debug_println,
])
.run(tauri::generate_context!())
@@ -6,7 +6,7 @@ use tauri::{
plugin::{Builder, TauriPlugin},
};
use crate::app::windows::{chat, settings};
use crate::app::windows::{chat, onboarding, settings};
#[tauri::command]
#[specta::specta]
@@ -31,6 +31,11 @@ async fn go<R: Runtime>(
None => settings::new_settings_window(app)
.map_err(|e| format!("Failed to create settings window: {}", e))?,
},
Some("onboarding") => match app.get_webview_window("onboarding") {
Some(window) => window,
None => onboarding::new_onboarding_window(app)
.map_err(|e| format!("Failed to create onboarding window: {}", e))?,
},
Some(label) => {
return Err(format!("Unknown window label: {}", label));
},
@@ -46,7 +51,14 @@ async fn go<R: Runtime>(
current_url.set_fragment(Some(route.to_string().as_str()));
let _ = target_window.show();
let _ = target_window.navigate(current_url);
if let Ok(url) = target_window.url() {
if url == current_url {
// If the URL is already the same, we don't need to navigate again.
return Ok(());
}
let _ = target_window.navigate(current_url);
}
Ok(())
}
+8
View File
@@ -9,6 +9,7 @@ import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterView } from 'vue-router'
import { WindowRouterLink } from './commands'
import { useAppRuntime } from './composables/runtime'
import { useTauriEvent } from './composables/tauri'
import { useWindowControlStore } from './stores/window-controls'
@@ -18,6 +19,7 @@ const i18n = useI18n()
const windowControlStore = useWindowControlStore()
const mcpStore = useMcpStore()
const onboardingStore = useOnboardingStore()
const { shouldShowSetup } = storeToRefs(onboardingStore)
const { platform } = useAppRuntime()
const { listen } = useTauriEvent<AiriTamagotchiEvents>()
@@ -44,6 +46,12 @@ watch(themeColorsHueDynamic, () => {
document.documentElement.classList.toggle('dynamic-hue', themeColorsHueDynamic.value)
}, { immediate: true })
watch(shouldShowSetup, () => {
if (shouldShowSetup.value) {
WindowRouterLink.commands.go('/onboarding', 'onboarding')
}
})
onMounted(() => {
listen('mcp_plugin_destroyed', () => {
mcpStore.connected = false
@@ -1,467 +0,0 @@
import type { Monitor } from '@tauri-apps/api/window'
import type { DisplayInfo, Point, Size } from './tauri'
import { useThrottleFn, watchThrottled } from '@vueuse/core'
import { computed, readonly, ref } from 'vue'
import { useAppRuntime } from './runtime'
import { useTauriWindow } from './tauri'
import { useTauriPointAndWindowFrame } from './tauri-window-pass-through-on-hover'
import { useTauriWindowState } from './tauri-window-state'
export interface WindowPersistenceConfig {
autoSave?: boolean
autoRestore?: boolean
savePeriod?: number // milliseconds
constrainToDisplays?: boolean
centerPointConstraint?: boolean // Use center point for boundary checks
}
export interface WindowBoundaryConstraints {
minCenterX: number
maxCenterX: number
minCenterY: number
maxCenterY: number
recommendedMonitor: Monitor | null
}
/**
* Enhanced window positioning system with persistence and boundary management
* Integrates with the existing tauri-click-through system
*/
export function useWindowPersistence(config: WindowPersistenceConfig = {}) {
const {
autoSave = true,
autoRestore = true,
savePeriod = 10000,
constrainToDisplays = true,
centerPointConstraint = true,
} = config
const { platform } = useAppRuntime()
const { windowFrame } = useTauriPointAndWindowFrame()
const { saveWindowState, restoreStateCurrent } = useTauriWindowState()
const { setPosition, getAvailableMonitors, getPrimaryMonitor } = useTauriWindow()
// Debounced save function (declare early to avoid usage before definition)
const throttledSave = useThrottleFn(() => savePosition(), savePeriod)
// Reactive state
const displayInfo = ref<DisplayInfo>()
const currentWindowPosition = computed(() => windowFrame.value.origin)
const currentWindowSize = computed(() => windowFrame.value.size)
const isPositioning = ref(false)
const isRestoring = ref(false)
// State tracking (using Tauri plugins only)
const isStateRestored = ref<boolean>(false)
const currentMonitor = computed(() => {
if (!currentWindowPosition.value || !displayInfo.value)
return null
return findMonitorContainingCenterPoint(
displayInfo.value.monitors,
getWindowCenterPoint({
x: currentWindowPosition.value.x,
y: currentWindowPosition.value.y,
}, {
width: currentWindowSize.value.width,
height: currentWindowSize.value.height,
}),
)
})
const boundaryConstraints = computed((): WindowBoundaryConstraints | null => {
if (!displayInfo.value || !currentWindowPosition.value)
return null
const allMonitors = displayInfo.value.monitors
const windowSize = {
width: currentWindowSize.value.width,
height: currentWindowSize.value.height,
}
return calculateBoundaryConstraints(allMonitors, windowSize, centerPointConstraint)
})
const isWindowOutOfBounds = computed(() => {
if (!currentWindowPosition.value || !displayInfo.value)
return false
// Check if window center is within any monitor's work area
const center = getWindowCenterPoint(currentWindowPosition.value, currentWindowSize.value)
const isInAnyMonitor = displayInfo.value.monitors.some((monitor) => {
const workArea = monitor.workArea
return (
center.x >= workArea.position.x
&& center.x <= workArea.position.x + workArea.size.width
&& center.y >= workArea.position.y
&& center.y <= workArea.position.y + workArea.size.height
)
})
return !isInAnyMonitor
})
// Initialize the system
async function initialize() {
if (platform.value === 'web')
return
try {
// Get initial display information
refreshDisplayInfo()
// Set up event listeners
setupEventListeners()
// Auto-restore if enabled
if (autoRestore) {
restorePosition()
}
// Success - using console.warn to comply with linting rules
}
catch (error) {
console.error('[WindowPersistence] Failed to initialize:', error)
}
}
function setupEventListeners() {
watchThrottled(windowFrame, () => {
if (!isPositioning.value && !isRestoring.value && autoSave) {
throttledSave()
}
}, { deep: true, throttle: savePeriod })
}
// Core positioning functions
function savePosition(): boolean {
if (!currentWindowPosition.value || !displayInfo.value) {
return false
}
try {
saveWindowState()
return true
}
catch (error) {
console.error('[WindowPersistence] Failed to save position:', error)
return false
}
}
async function restorePosition(): Promise<boolean> {
if (!displayInfo.value) {
return false
}
try {
isRestoring.value = true
// Restore window state using Tauri plugin
await restoreStateCurrent()
// Ensure the restored position is within bounds
if (constrainToDisplays && currentWindowPosition.value) {
if (isWindowOutOfBounds.value) {
console.warn('[WindowPersistence] Restored position is not valid for current displays')
await moveToNearestValidPosition()
return false
}
}
isStateRestored.value = true
return true
}
catch (error) {
console.error('[WindowPersistence] Failed to restore position:', error)
return false
}
finally {
isRestoring.value = false
}
}
async function ensureWindowInBounds(): Promise<boolean> {
if (!currentWindowPosition.value || !constrainToDisplays)
return true
try {
const constrainedPosition = constrainPositionToBounds({
x: currentWindowPosition.value.x,
y: currentWindowPosition.value.y,
}, {
width: currentWindowSize.value.width,
height: currentWindowSize.value.height,
})
if (!positionsEqual({
x: currentWindowPosition.value.x,
y: currentWindowPosition.value.y,
width: currentWindowSize.value.width,
height: currentWindowSize.value.height,
}, {
x: constrainedPosition.x,
y: constrainedPosition.y,
width: currentWindowSize.value.width,
height: currentWindowSize.value.height,
})) {
await applyPosition(constrainedPosition)
return true
}
return true
}
catch (error) {
console.error('[WindowPersistence] Failed to ensure window bounds:', error)
return false
}
}
async function applyPosition(pos: Point): Promise<boolean> {
if (platform.value === 'web')
return false
try {
isPositioning.value = true
await setPosition(pos.x, pos.y)
if (autoSave) {
throttledSave()
}
return true
}
catch (error) {
console.error('[WindowPersistence] Failed to apply position:', error)
return false
}
finally {
isPositioning.value = false
}
}
async function centerOnNearestMonitor(): Promise<boolean> {
if (!displayInfo.value || !currentWindowPosition.value || !currentWindowSize.value)
return false
// Find the nearest monitor to current window center
const currentCenter = getWindowCenterPoint(currentWindowPosition.value, currentWindowSize.value)
let nearestMonitor = displayInfo.value.monitors[0]
let shortestDistance = Infinity
displayInfo.value.monitors.forEach((monitor) => {
const monitorCenter = {
x: monitor.workArea.position.x + monitor.workArea.size.width / 2,
y: monitor.workArea.position.y + monitor.workArea.size.height / 2,
}
const distance = Math.sqrt(
(currentCenter.x - monitorCenter.x) ** 2
+ (currentCenter.y - monitorCenter.y) ** 2,
)
if (distance < shortestDistance) {
shortestDistance = distance
nearestMonitor = monitor
}
})
// Center window on the nearest monitor
const centerPosition: Point = {
x: nearestMonitor.workArea.position.x + (nearestMonitor.workArea.size.width - currentWindowSize.value.width) / 2,
y: nearestMonitor.workArea.position.y + (nearestMonitor.workArea.size.height - currentWindowSize.value.height) / 2,
}
return await applyPosition(centerPosition)
}
async function moveToNearestValidPosition(): Promise<boolean> {
if (!displayInfo.value || !currentWindowPosition.value || !currentWindowSize.value)
return false
// If window is already in bounds, no need to move
if (!isWindowOutOfBounds.value)
return true
// First try to constrain to nearest valid position
const constrainedPosition = constrainPositionToBounds(
currentWindowPosition.value,
currentWindowSize.value,
)
// If constraining worked, apply the position
if (constrainedPosition.x !== currentWindowPosition.value.x
|| constrainedPosition.y !== currentWindowPosition.value.y) {
return await applyPosition(constrainedPosition)
}
// If constraining didn't work, center on nearest monitor
return await centerOnNearestMonitor()
}
// Utility functions
function getWindowCenterPoint(pos: Point, size: Size): Point {
return {
x: pos.x + size.width / 2,
y: pos.y + size.height / 2,
}
}
function findMonitorContainingCenterPoint(monitors: Monitor[], center: Point): Monitor | null {
return monitors.find((monitor) => {
const workArea = monitor.workArea
return (
center.x >= workArea.position.x
&& center.x <= workArea.position.x + workArea.size.width
&& center.y >= workArea.position.y
&& center.y <= workArea.position.y + workArea.size.height
)
}) || null
}
function calculateBoundaryConstraints(
monitors: Monitor[],
windowSize: Size,
useCenterPoint: boolean,
): WindowBoundaryConstraints {
if (monitors.length === 0) {
return {
minCenterX: 0,
maxCenterX: 0,
minCenterY: 0,
maxCenterY: 0,
recommendedMonitor: null,
}
}
// Calculate the combined bounds of all monitors
let minX = Infinity
let maxX = -Infinity
let minY = Infinity
let maxY = -Infinity
let bestMonitor = monitors[0]
monitors.forEach((monitor) => {
const workArea = monitor.workArea
minX = Math.min(minX, workArea.position.x)
maxX = Math.max(maxX, workArea.position.x + workArea.size.width)
minY = Math.min(minY, workArea.position.y)
maxY = Math.max(maxY, workArea.position.y + workArea.size.height)
// Use primary monitor from displayInfo instead of is_primary field
if (displayInfo.value && monitor.name === displayInfo.value.primaryMonitor.name) {
bestMonitor = monitor
}
})
if (useCenterPoint) {
// Calculate constraints for center point
return {
minCenterX: minX + windowSize.width / 2,
maxCenterX: maxX - windowSize.width / 2,
minCenterY: minY + windowSize.height / 2,
maxCenterY: maxY - windowSize.height / 2,
recommendedMonitor: bestMonitor,
}
}
else {
// Calculate constraints for top-left corner
return {
minCenterX: minX,
maxCenterX: maxX - windowSize.width,
minCenterY: minY,
maxCenterY: maxY - windowSize.height,
recommendedMonitor: bestMonitor,
}
}
}
function constrainPositionToBounds(pos: Point, size: Size): Point {
if (!boundaryConstraints.value)
return { x: pos.x, y: pos.y }
const constraints = boundaryConstraints.value
const center = getWindowCenterPoint(pos, size)
// Constrain center point
const constrainedCenter: Point = {
x: Math.max(constraints.minCenterX, Math.min(constraints.maxCenterX, center.x)),
y: Math.max(constraints.minCenterY, Math.min(constraints.maxCenterY, center.y)),
}
// Convert back to position
return {
x: constrainedCenter.x - size.width / 2,
y: constrainedCenter.y - size.height / 2,
}
}
function positionsEqual(a: (Point & Size), b: (Point & Size)): boolean {
if (!a || !b)
return a === b
return (
Math.abs(a.x - b.x) < 1
&& Math.abs(a.y - b.y) < 1
&& Math.abs(a.width - b.width) < 1
&& Math.abs(a.height - b.height) < 1
)
}
async function refreshDisplayInfo(): Promise<void> {
if (platform.value === 'web')
return
try {
const monitors = await getAvailableMonitors()
if (!monitors || monitors.length === 0) {
console.warn('[WindowPersistence] Failed to get available monitors')
return
}
const primaryMonitor = await getPrimaryMonitor()
if (!primaryMonitor) {
console.warn('[WindowPersistence] Failed to get primary monitor')
return
}
displayInfo.value = {
monitors,
primaryMonitor,
}
}
catch (error) {
console.error('[WindowPersistence] Failed to refresh display info:', error)
}
}
return {
// State
displayInfo: readonly(displayInfo),
// Computed properties for external use
isWindowOutOfBounds,
currentMonitor,
boundaryConstraints,
// Core functions
initialize,
// Position correction functions
moveToNearestValidPosition,
centerOnNearestMonitor,
ensureWindowInBounds,
applyPosition,
// Utilities
refreshDisplayInfo,
// Manual control functions
manualSave: () => savePosition(),
manualRestore: () => restorePosition(),
}
}
@@ -270,10 +270,34 @@ export function useTauriWindow() {
}
}
async function closeWindow(label?: string) {
try {
const imported = await _ensureImported()
if (!label) {
const window = imported.getCurrentWindow()
return await window.close()
}
else {
const windows = await imported.getAllWindows()
const targetWindow = windows.find(win => win.label === label)
if (targetWindow) {
return await targetWindow.close()
}
else {
console.warn(`No window found with label: ${label}`)
}
}
}
catch (error) {
console.error('Failed to close window:', error)
}
}
return {
getAvailableMonitors,
getCurrentMonitor,
getPrimaryMonitor,
setPosition,
closeWindow,
}
}
@@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
@@ -13,7 +13,6 @@ import ResourceStatusIsland from '../components/Widgets/ResourceStatusIsland/ind
import { useTauriCore, useTauriEvent } from '../composables/tauri'
import { useTauriGlobalShortcuts } from '../composables/tauri-global-shortcuts'
import { useTauriWindowClickThrough } from '../composables/tauri-window-pass-through-on-hover'
import { useWindowPersistence } from '../composables/tauri-window-persistence'
import { useResourcesStore } from '../stores/resources'
import { useWindowControlStore } from '../stores/window-controls'
import { WindowControlMode } from '../types/window-controls'
@@ -37,13 +36,6 @@ const { connected, serverCmd, serverArgs } = storeToRefs(mcpStore)
watch([live2dLookAtX, live2dLookAtY], ([x, y]) => live2dFocusAt.value = { x, y }, { immediate: true })
const windowPersistence = useWindowPersistence({
autoSave: true,
autoRestore: true,
constrainToDisplays: true,
centerPointConstraint: true,
})
const modeIndicatorClass = computed(() => {
switch (windowStore.controlMode) {
case WindowControlMode.MOVE:
@@ -60,7 +52,6 @@ const modeIndicatorClass = computed(() => {
onMounted(async () => {
await invoke('plugin:proj-airi-tauri-plugin-window-pass-through-on-hover|start_monitor')
await startClickThrough()
await windowPersistence.initialize()
})
onUnmounted(async () => {
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { OnboardingScreen } from '@proj-airi/stage-ui/components'
import { useOnboardingStore } from '@proj-airi/stage-ui/stores'
import { useTauriWindow } from '../composables/tauri'
const window = useTauriWindow()
const onboardingStore = useOnboardingStore()
function handleSkipped() {
window.closeWindow()
}
function handleConfigured() {
onboardingStore.markSetupCompleted()
window.closeWindow()
}
</script>
<template>
<div h-full w-full flex flex-col overflow-x-hidden overflow-y-hidden overscroll-none>
<div bg="white dark:#181818" w="100dvw" min-h="12" data-tauri-drag-region w-full flex-shrink-0 select-none />
<div w-full flex-1 overflow-y-scroll px-3>
<div h-full py-3>
<OnboardingScreen @skipped="handleSkipped" @configured="handleConfigured" />
</div>
</div>
</div>
</template>
<route lang="yaml">
meta:
layout: plain
</route>
+1
View File
@@ -123,6 +123,7 @@ words:
- micvad
- mineflayer
- mingcute
- minimizable
- mkdist
- modelcontextprotocol
- modnet
@@ -1,439 +0,0 @@
<script setup lang="ts">
import { useProvidersStore } from '@proj-airi/stage-ui/stores'
import { FieldInput } from '@proj-airi/ui'
import { useDebounceFn } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import onboardingLogo from '../../../../assets/onboarding.png'
import { Callout } from '../../../Layouts'
import { RadioCardDetail } from '../../../Menu'
import { Button } from '../../../Misc'
import { ProviderAccountIdInput } from '../../../Providers'
interface Emits {
(e: 'configured'): void
(e: 'skipped'): void
}
const emit = defineEmits<Emits>()
const step = ref(1)
const direction = ref<'next' | 'previous'>('next')
const { t } = useI18n()
const providersStore = useProvidersStore()
const { providers, allChatProvidersMetadata } = storeToRefs(providersStore)
// 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'
})
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.dialogs.onboarding.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.dialogs.onboarding.validationSuccess')
}
else {
validationMessage.value = t('settings.dialogs.onboarding.validationFailed')
}
}
catch (error) {
isValid.value = false
validationMessage.value = t('settings.dialogs.onboarding.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 })
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,
}
emit('configured')
}
function handlePreviousStep() {
if (step.value > 1) {
direction.value = 'previous'
step.value--
}
}
function handleNextStep() {
if (step.value < 3) {
direction.value = 'next'
step.value++
}
else {
handleSave()
}
}
// 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="[calc(100%-3rem)]" flex flex-col justify-center>
<div class="mb-2 text-center md:mb-8" h-full flex flex-1 flex-col justify-center>
<div
v-motion
:initial="{ opacity: 0, scale: 0 }"
:enter="{ opacity: 1, scale: 1 }"
class="mb-1 flex justify-center md:mb-4"
>
<img :src="onboardingLogo" w="50">
</div>
<h2 class="mb-0 text-3xl text-neutral-800 font-bold md:mb-2 dark:text-neutral-100">
{{ t('settings.dialogs.onboarding.title') }}
</h2>
<p class="text-sm text-neutral-600 md:text-lg dark:text-neutral-400">
{{ t('settings.dialogs.onboarding.description') }}
</p>
</div>
<Button
:label="t('settings.dialogs.onboarding.start')"
@click="handleNextStep"
/>
</div>
</template>
<!-- Provider Selection -->
<template v-else-if="step === 2">
<div h="[calc(100%-3rem)]" class="mb-2 mt-4 md:mb-8" flex flex-col gap-4>
<div flex items-center>
<button outline-none @click="handlePreviousStep">
<div i-solar:alt-arrow-left-line-duotone h-5 w-5 />
</button>
<h2 class="text-center text-xl text-neutral-800 font-semibold md:text-left md:text-2xl dark:text-neutral-100" flex-1>
{{ t('settings.dialogs.onboarding.selectProvider') }}
</h2>
<div h-5 w-5 />
</div>
<div flex-1>
<div class="grid grid-cols-1 gap-3 overflow-y-scroll sm:grid-cols-2 md:max-h-full">
<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="[calc(100%-3rem)]" class="mb-2 mt-4 md:mb-8" flex flex-col gap-4>
<div flex items-center>
<button outline-none @click="handlePreviousStep">
<div i-solar:alt-arrow-left-line-duotone h-5 w-5 />
</button>
<h2 class="text-center text-xl text-neutral-800 font-semibold md:text-left md:text-2xl dark:text-neutral-100" flex-1>
{{ t('settings.dialogs.onboarding.configureProvider', { provider: selectedProvider!.localizedName }) }}
</h2>
<div h-5 w-5 />
</div>
<div v-if="selectedProvider" h-full flex-1>
<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 -->
<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-solar:check-circle-bold-duotone'
: 'i-solar:danger-circle-bold-duotone',
]"
/>
{{ validationMessage }}
</div>
</div>
</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>
@@ -3,11 +3,12 @@ import { useProvidersStore } from '@proj-airi/stage-ui/stores'
import { FieldInput } from '@proj-airi/ui'
import { useDebounceFn } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import onboardingLogo from '../../../../assets/onboarding.png'
import { Callout } from '../../../Layouts'
import { RadioCardDetail } from '../../../Menu'
import { Button } from '../../../Misc'
import { ProviderAccountIdInput } from '../../../Providers'
@@ -19,6 +20,9 @@ interface Emits {
const emit = defineEmits<Emits>()
const step = ref(1)
const direction = ref<'next' | 'previous'>('next')
const { t } = useI18n()
const providersStore = useProvidersStore()
const { providers, allChatProvidersMetadata } = storeToRefs(providersStore)
@@ -176,11 +180,6 @@ watch([apiKey, baseUrl, accountId], () => {
}
}, { deep: true })
// Actions
function handleSkip() {
emit('skipped')
}
async function handleSave() {
if (!selectedProvider.value || !canSave.value)
return
@@ -200,9 +199,27 @@ async function handleSave() {
...config,
}
await nextTick()
emit('configured')
}
function handlePreviousStep() {
if (step.value > 1) {
direction.value = 'previous'
step.value--
}
}
function handleNextStep() {
if (step.value < 3) {
direction.value = 'next'
step.value++
}
else {
handleSave()
}
}
// Initialize with first popular provider
onMounted(() => {
if (popularProviders.value.length > 0) {
@@ -213,112 +230,230 @@ onMounted(() => {
</script>
<template>
<!-- Header -->
<div class="mb-2 text-center md:mb-8">
<div class="my-0 mb-1 flex justify-center lg:mb-8 lg:mt-8 md:mb-4">
<img :src="onboardingLogo" w="20 md:25 lg:50">
</div>
<h2 class="mb-0 text-lg text-neutral-800 font-bold md:mb-2 md:text-2xl dark:text-neutral-100">
{{ t('settings.dialogs.onboarding.title') }}
</h2>
<p class="text-sm text-neutral-600 md:text-base dark:text-neutral-400">
{{ t('settings.dialogs.onboarding.description') }}
</p>
</div>
<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 -->
<div class="mb-2 md:mb-8">
<h2 class="mb-4 text-center text-lg text-neutral-800 font-semibold md:text-left md:text-2xl dark:text-neutral-100">
{{ t('settings.dialogs.onboarding.selectProvider') }}
</h2>
<div class="grid grid-cols-1 max-h-[25dvh] gap-3 overflow-y-scroll sm:grid-cols-2 md:max-h-full">
<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>
<!-- Provider Selection -->
<template v-else-if="step === 2">
<div h-full flex flex-col gap-4>
<div bg="white dark:#181818" 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 -->
<div v-if="selectedProvider" class="mb-2 md:mb-8">
<h3 class="mb-4 text-lg text-neutral-800 font-medium dark:text-neutral-100">
{{ t('settings.dialogs.onboarding.configureProvider', { provider: selectedProvider.localizedName }) }}
</h3>
<!-- Configuration Form -->
<template v-else-if="step === 3 && selectedProvider">
<div h-full flex flex-col gap-4>
<div bg="white dark:#181818" 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>
<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>
<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>
<!-- 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>
<!-- Account ID for Cloudflare -->
<div v-if="selectedProvider.id === 'cloudflare-workers-ai'">
<ProviderAccountIdInput v-model="accountId" />
</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-solar:check-circle-bold-duotone'
: 'i-solar:danger-circle-bold-duotone',
]"
/>
{{ validationMessage }}
</div>
</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-solar:check-circle-bold-duotone'
: 'i-solar:danger-circle-bold-duotone',
]"
/>
{{ validationMessage }}
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end md:gap-3">
<Button
variant="secondary"
:label="t('settings.dialogs.onboarding.skipForNow')"
@click="handleSkip"
/>
<Button
variant="primary"
:disabled="!canSave"
:label="t('settings.dialogs.onboarding.saveAndContinue')"
@click="handleSave"
/>
<!-- 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>
@@ -3,7 +3,6 @@ import { useMediaQuery } from '@vueuse/core'
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot } from 'reka-ui'
import { DrawerContent, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot } from 'vaul-vue'
import MobileOnboarding from './MobileOnboarding.vue'
import Onboarding from './Onboarding.vue'
const emit = defineEmits<{
@@ -20,7 +19,7 @@ const isDesktop = useMediaQuery('(min-width: 768px)')
<DialogRoot v-if="isDesktop" :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-0 my-4 max-h-[calc(100%-4rem)] max-w-2xl w-[92vw] transform overflow-y-scroll rounded-lg bg-white p-4 shadow-xl outline-none backdrop-blur-md scrollbar-none md:mx-4 -translate-x-1/2 -translate-y-1/2 dark:bg-neutral-900 md:p-8">
<DialogContent class="data-[state=open]:animate-contentShow data-[state=closed]:animate-contentHide 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 dark:bg-neutral-900">
<Onboarding @configured="emit('configured')" @skipped="emit('skipped')" />
</DialogContent>
</DialogPortal>
@@ -28,11 +27,9 @@ const isDesktop = useMediaQuery('(min-width: 768px)')
<DrawerRoot v-else :open="showDialog" should-scale-background @update:open="value => showDialog = value">
<DrawerPortal>
<DrawerOverlay class="fixed inset-0" />
<DrawerContent class="fixed bottom-0 left-0 right-0 z-1000 mt-20 h-full max-h-[96%] flex flex-col rounded-t-[10px] bg-neutral-50 outline-none backdrop-blur-md dark:bg-neutral-900/95">
<div class="flex-1 rounded-t-[10px] px-4 py-1">
<DrawerHandle class="my-2" />
<MobileOnboarding @configured="emit('configured')" @skipped="emit('skipped')" />
</div>
<DrawerContent class="fixed bottom-0 left-0 right-0 z-1000 mt-20 h-full max-h-[96%] flex flex-col rounded-t-2xl bg-neutral-50 p-4 outline-none backdrop-blur-md dark:bg-neutral-900/95">
<DrawerHandle />
<Onboarding @configured="emit('configured')" @skipped="emit('skipped')" />
</DrawerContent>
</DrawerPortal>
</DrawerRoot>
@@ -1 +1,2 @@
export { default as OnboardingScreen } from './Onboarding.vue'
export { default as OnboardingDialog } from './OnboardingDialog.vue'
@@ -77,6 +77,7 @@ export const useOnboardingStore = defineStore('onboarding', () => {
shouldShowSetup,
hasEssentialProviderConfigured,
needsOnboarding,
initializeSetupCheck,
markSetupCompleted,
markSetupSkipped,