feat(stage-web,stage-tamagotchi): unified onboarding, better style
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user