refactor(stage-tamagotchi-electron): init stage-tamagotchi-electron app

Co-authored-by: LemonNeko <17664845+LemonNekoGH@users.noreply.github.com>
This commit is contained in:
Neko AyakaandLemonNeko committed 2025-09-06 21:03:56 +08:00
1 parent 40ff6bda6c
commit 23eb3d3ef6
123 files changed
+14590 -154

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Main Process",
"type": "node",
"request": "launch",
"cwd": "${workspaceRoot}",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
"windows": {
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
},
"runtimeArgs": ["--sourcemap"],
"env": {
"REMOTE_DEBUGGING_PORT": "9222"
}
},
{
"name": "Debug Renderer Process",
"port": 9222,
"request": "attach",
"type": "chrome",
"webRoot": "${workspaceFolder}/src/renderer",
"timeout": 60000,
"presentation": {
"hidden": true
}
}
],
"compounds": [
{
"name": "Debug All",
"configurations": ["Debug Main Process", "Debug Renderer Process"],
"presentation": {
"order": 1
}
}
]
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

@@ -0,0 +1,3 @@
provider: generic
url: https://example.com/auto-updates
updaterCacheDirName: stage-tamagotchi-electron-updater
@@ -0,0 +1,43 @@
appId: com.electron.app
productName: stage-tamagotchi-electron
directories:
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
- resources/**
win:
executableName: stage-tamagotchi-electron
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
linux:
target:
- AppImage
- snap
- deb
maintainer: electronjs.org
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: false
publish:
provider: generic
url: https://example.com/auto-updates
@@ -0,0 +1,86 @@
import { join, resolve } from 'node:path'
import VueI18n from '@intlify/unplugin-vue-i18n/vite'
import Vue from '@vitejs/plugin-vue'
import UnoCss from 'unocss/vite'
import VueMacros from 'unplugin-vue-macros/vite'
import VueRouter from 'unplugin-vue-router/vite'
import Yaml from 'unplugin-yaml/vite'
import VitePluginVueDevTools from 'vite-plugin-vue-devtools'
import Layouts from 'vite-plugin-vue-layouts'
import { Download } from '@proj-airi/unplugin-fetch'
import { DownloadLive2DSDK } from '@proj-airi/unplugin-live2d-sdk'
import { templateCompilerOptions } from '@tresjs/core'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
},
preload: {
plugins: [externalizeDepsPlugin()],
},
renderer: {
optimizeDeps: {
exclude: [
'@proj-airi/stage-ui/*',
'@proj-airi/drizzle-duckdb-wasm',
'@proj-airi/drizzle-duckdb-wasm/*',
],
},
resolve: {
alias: {
'@proj-airi/stage-ui/components/scenarios/settings/model-settings': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'components', 'Scenarios', 'Settings', 'ModelSettings')),
'@proj-airi/stage-ui/components/scenes': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'components', 'Scenes')),
'@proj-airi/stage-ui/stores': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'stores')),
'@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')),
'@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')),
},
},
plugins: [
Yaml(),
VueMacros({
plugins: {
vue: Vue({
include: [/\.vue$/, /\.md$/],
...templateCompilerOptions,
}),
vueJsx: false,
},
betterDefine: false,
}),
VueRouter({
dts: resolve(import.meta.dirname, 'src/renderer/typed-router.d.ts'),
routesFolder: 'src/renderer/pages',
}),
VitePluginVueDevTools(),
// https://github.com/JohnCampionJr/vite-plugin-vue-layouts
Layouts(),
UnoCss(),
// https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n
VueI18n({
runtimeOnly: true,
compositionOnly: true,
fullInstall: true,
}),
DownloadLive2DSDK(),
Download('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'assets/live2d/models'),
Download('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', 'hiyori_pro_zh.zip', 'assets/live2d/models'),
Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', 'AvatarSample_A.vrm', 'assets/vrm/models/AvatarSample-A'),
Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', 'AvatarSample_B.vrm', 'assets/vrm/models/AvatarSample-B'),
],
server: {
watch: {
ignored: ['**/src-tauri/**'],
},
},
},
})
+142
View File
@@ -0,0 +1,142 @@
{
"name": "@proj-airi/stage-tamagotchi-electron",
"version": "1.0.0",
"description": "An Electron application with Vue and TypeScript",
"author": "example.com",
"homepage": "https://electron-vite.org",
"main": "./out/main/index.js",
"scripts": {
"lint": "eslint --cache .",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "pnpm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "pnpm run build && electron-builder --dir",
"build:win": "pnpm run build && electron-builder --win",
"build:mac": "pnpm run build && electron-builder --mac",
"build:linux": "pnpm run build && electron-builder --linux"
},
"dependencies": {
"@date-fns/utc": "^2.1.1",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@formkit/auto-animate": "^0.8.4",
"@huggingface/transformers": "^3.7.2",
"@moeru/std": "catalog:",
"@proj-airi/audio": "workspace:^",
"@proj-airi/ccc": "workspace:^",
"@proj-airi/drizzle-duckdb-wasm": "catalog:",
"@proj-airi/font-cjkfonts-allseto": "workspace:^",
"@proj-airi/font-xiaolai": "workspace:^",
"@proj-airi/i18n": "workspace:^",
"@proj-airi/stage-ui": "workspace:^",
"@proj-airi/ui": "workspace:^",
"@ricky0123/vad-web": "^0.0.26",
"@stdlib/string-base-kebabcase": "^0.2.2",
"@tresjs/cientos": "^4.3.1",
"@tresjs/core": "^4.3.6",
"@types/audioworklet": "^0.0.83",
"@unbird/eventa": "^0.0.5",
"@vueuse/core": "^13.9.0",
"@vueuse/shared": "^13.9.0",
"@xsai-ext/providers-cloud": "catalog:",
"@xsai-ext/providers-local": "catalog:",
"@xsai-ext/shared-providers": "catalog:",
"@xsai/generate-speech": "catalog:",
"@xsai/generate-text": "catalog:",
"@xsai/model": "catalog:",
"@xsai/shared": "catalog:",
"@xsai/shared-chat": "catalog:",
"@xsai/stream-text": "catalog:",
"@xsai/utils-chat": "catalog:",
"colorjs.io": "^0.5.2",
"culori": "^4.0.2",
"date-fns": "^4.1.0",
"drizzle-kit": "^0.31.4",
"drizzle-orm": "^0.44.5",
"jszip": "^3.10.1",
"localforage": "^1.10.0",
"mediabunny": "^1.13.0",
"node-vibrant": "^4.0.3",
"nprogress": "^0.2.0",
"ofetch": "^1.4.1",
"onnxruntime-web": "^1.22.0",
"pinia": "^3.0.3",
"popmotion": "^11.0.5",
"rehype-stringify": "^10.0.1",
"reka-ui": "^2.5.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"shiki": "^3.12.2",
"three": "^0.179.1",
"unified": "^11.0.5",
"unspeech": "^0.1.7",
"valibot": "1.0.0-beta.9",
"vaul-vue": "^0.4.1",
"vue": "^3.5.21",
"vue-demi": "^0.14.10",
"vue-i18n": "^11.1.11",
"vue-router": "^4.5.1",
"xsschema": "catalog:",
"yauzl": "^3.2.0",
"zod": "^4.1.5"
},
"devDependencies": {
"@electron-toolkit/tsconfig": "^1.0.1",
"@fontsource-variable/dm-sans": "^5.2.6",
"@fontsource-variable/jura": "^5.2.6",
"@fontsource-variable/quicksand": "^5.2.8",
"@fontsource-variable/urbanist": "^5.2.6",
"@fontsource/dm-mono": "^5.2.6",
"@fontsource/dm-serif-display": "^5.2.6",
"@fontsource/gugi": "^5.2.6",
"@fontsource/kiwi-maru": "^5.2.6",
"@fontsource/m-plus-rounded-1c": "^5.2.8",
"@fontsource/sniglet": "^5.2.6",
"@iconify-json/carbon": "^1.2.13",
"@iconify-json/eos-icons": "^1.2.4",
"@iconify-json/lucide": "^1.2.66",
"@iconify-json/mingcute": "^1.2.5",
"@iconify-json/ph": "^1.2.2",
"@iconify-json/simple-icons": "^1.2.50",
"@iconify-json/solar": "^1.2.4",
"@iconify-json/svg-spinners": "^1.2.4",
"@iconify-json/vscode-icons": "^1.2.30",
"@iconify/utils": "^3.0.1",
"@intlify/unplugin-vue-i18n": "^6.0.8",
"@proj-airi/lobe-icons": "^1.0.10",
"@proj-airi/ui-transitions": "workspace:^",
"@proj-airi/unplugin-fetch": "^0.1.6",
"@proj-airi/unplugin-live2d-sdk": "^0.1.5",
"@shikijs/markdown-it": "^3.12.2",
"@types/culori": "^4.0.1",
"@types/nprogress": "^0.2.3",
"@types/three": "^0.179.0",
"@types/yauzl": "^2.10.3",
"@unocss/reset": "^66.5.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vue-macros/volar": "^3.0.0-beta.22",
"@vueuse/motion": "^3.0.3",
"@xsai-transformers/embed": "^0.0.7",
"cac": "^6.7.14",
"csstype": "^3.1.3",
"electron": "^37.2.3",
"electron-builder": "^25.1.8",
"electron-vite": "^4.0.0",
"execa": "^9.6.0",
"less": "^4.4.1",
"unocss-preset-scrollbar": "^3.2.0",
"unplugin-info": "^1.2.4",
"unplugin-vue-macros": "^2.14.5",
"unplugin-vue-router": "^0.15.0",
"unplugin-yaml": "^3.0.5",
"vite": "catalog:rolldown-vite",
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-vue-devtools": "^8.0.1",
"vite-plugin-vue-layouts": "^0.11.0",
"vue-tsc": "^3.0.6"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

@@ -0,0 +1,80 @@
import { join } from 'node:path'
import { env, platform } from 'node:process'
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
import { app, BrowserWindow, ipcMain, shell } from 'electron'
import icon from '../../resources/icon.png?asset'
function createWindow(): void {
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 900,
height: 670,
show: false,
autoHideMenuBar: true,
...(platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
},
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(env.ELECTRON_RENDERER_URL)
}
else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
// Set app user model id for windows
electronApp.setAppUserModelId('com.electron')
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
// IPC test
// eslint-disable-next-line no-console
ipcMain.on('ping', () => console.log('pong'))
createWindow()
app.on('activate', () => {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0)
createWindow()
})
})
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
if (platform !== 'darwin') {
app.quit()
}
})
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.
+8
View File
@@ -0,0 +1,8 @@
import type { ElectronAPI } from '@electron-toolkit/preload'
declare global {
interface Window {
electron: ElectronAPI
api: unknown
}
}
@@ -0,0 +1,26 @@
import { contextIsolated } from 'node:process'
import { electronAPI } from '@electron-toolkit/preload'
import { contextBridge } from 'electron'
// Custom APIs for renderer
const api = {}
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
if (contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
}
catch (error) {
console.error(error)
}
}
else {
// @ts-expect-error (define in dts)
window.electron = electronAPI
// @ts-expect-error (define in dts)
window.api = api
}
@@ -0,0 +1,94 @@
<script setup lang="ts">
// import type { AiriTamagotchiEvents } from './composables/tauri'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
// import { useMcpStore } from '@proj-airi/stage-ui/stores/mcp'
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
// import { Window } from '@tauri-apps/api/window'
import { storeToRefs } from 'pinia'
import { onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterView } from 'vue-router'
// import { useAppRuntime } from './composables/runtime'
// import { useTauriEvent } from './composables/tauri'
import { useWindowMode } from './stores/window-controls'
useWindowMode()
const i18n = useI18n()
const displayModelsStore = useDisplayModelsStore()
const settingsStore = useSettings()
const { language, themeColorsHue, themeColorsHueDynamic } = storeToRefs(settingsStore)
const onboardingStore = useOnboardingStore()
// const mcpStore = useMcpStore()
// const { listen } = useTauriEvent<AiriTamagotchiEvents>()
// const { platform } = useAppRuntime()
watch(language, () => {
i18n.locale.value = language.value
})
// FIXME: store settings to file
onMounted(async () => {
onboardingStore.initializeSetupCheck()
await displayModelsStore.loadDisplayModelsFromIndexedDB()
await settingsStore.initializeStageModel()
})
watch(themeColorsHue, () => {
document.documentElement.style.setProperty('--chromatic-hue', themeColorsHue.value.toString())
}, { immediate: true })
watch(themeColorsHueDynamic, () => {
document.documentElement.classList.toggle('dynamic-hue', themeColorsHueDynamic.value)
}, { immediate: true })
// onMounted(() => {
// listen('mcp_plugin_destroyed', () => {
// mcpStore.connected = false
// })
// })
// const isMac = computed(() => platform.value === 'macos')
// if (isMac.value) {
// watch(allowVisibleOnAllWorkspaces, async (value) => {
// const window = await Window.getByLabel('main')
// if (!window) {
// return
// }
// window.setVisibleOnAllWorkspaces(value)
// })
// }
</script>
<template>
<RouterView />
</template>
<style>
/* We need this to properly animate the CSS variable */
@property --chromatic-hue {
syntax: '<number>';
initial-value: 0;
inherits: true;
}
@keyframes hue-anim {
from {
--chromatic-hue: 0;
}
to {
--chromatic-hue: 360;
}
}
.dynamic-hue {
animation: hue-anim 10s linear infinite;
}
</style>
@@ -0,0 +1,97 @@
// @ts-nocheck
// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually.
/** user-defined commands **/
export const commands = {
async startPassThrough() : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("plugin:window-pass-through-on-hover|start_pass_through") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async stopPassThrough() : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("plugin:window-pass-through-on-hover|stop_pass_through") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
}
}
/** user-defined events **/
/** user-defined constants **/
/** user-defined types **/
/** tauri-specta globals **/
import {
invoke as TAURI_INVOKE,
Channel as TAURI_CHANNEL,
} from "@tauri-apps/api/core";
import * as TAURI_API_EVENT from "@tauri-apps/api/event";
import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow";
type __EventObj__<T> = {
listen: (
cb: TAURI_API_EVENT.EventCallback<T>,
) => ReturnType<typeof TAURI_API_EVENT.listen<T>>;
once: (
cb: TAURI_API_EVENT.EventCallback<T>,
) => ReturnType<typeof TAURI_API_EVENT.once<T>>;
emit: null extends T
? (payload?: T) => ReturnType<typeof TAURI_API_EVENT.emit>
: (payload: T) => ReturnType<typeof TAURI_API_EVENT.emit>;
};
export type Result<T, E> =
| { status: "ok"; data: T }
| { status: "error"; error: E };
function __makeEvents__<T extends Record<string, any>>(
mappings: Record<keyof T, string>,
) {
return new Proxy(
{} as unknown as {
[K in keyof T]: __EventObj__<T[K]> & {
(handle: __WebviewWindow__): __EventObj__<T[K]>;
};
},
{
get: (_, event) => {
const name = mappings[event as keyof T];
return new Proxy((() => {}) as any, {
apply: (_, __, [window]: [__WebviewWindow__]) => ({
listen: (arg: any) => window.listen(name, arg),
once: (arg: any) => window.once(name, arg),
emit: (arg: any) => window.emit(name, arg),
}),
get: (_, command: keyof __EventObj__<any>) => {
switch (command) {
case "listen":
return (arg: any) => TAURI_API_EVENT.listen(name, arg);
case "once":
return (arg: any) => TAURI_API_EVENT.once(name, arg);
case "emit":
return (arg: any) => TAURI_API_EVENT.emit(name, arg);
}
},
});
},
},
);
}
@@ -0,0 +1,89 @@
// @ts-nocheck
// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually.
/** user-defined commands **/
export const commands = {
async go(route: string, windowLabel: string | null) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("plugin:window-router-link|go", { route, windowLabel }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
}
}
/** user-defined events **/
/** user-defined constants **/
/** user-defined types **/
/** tauri-specta globals **/
import {
invoke as TAURI_INVOKE,
Channel as TAURI_CHANNEL,
} from "@tauri-apps/api/core";
import * as TAURI_API_EVENT from "@tauri-apps/api/event";
import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow";
type __EventObj__<T> = {
listen: (
cb: TAURI_API_EVENT.EventCallback<T>,
) => ReturnType<typeof TAURI_API_EVENT.listen<T>>;
once: (
cb: TAURI_API_EVENT.EventCallback<T>,
) => ReturnType<typeof TAURI_API_EVENT.once<T>>;
emit: null extends T
? (payload?: T) => ReturnType<typeof TAURI_API_EVENT.emit>
: (payload: T) => ReturnType<typeof TAURI_API_EVENT.emit>;
};
export type Result<T, E> =
| { status: "ok"; data: T }
| { status: "error"; error: E };
function __makeEvents__<T extends Record<string, any>>(
mappings: Record<keyof T, string>,
) {
return new Proxy(
{} as unknown as {
[K in keyof T]: __EventObj__<T[K]> & {
(handle: __WebviewWindow__): __EventObj__<T[K]>;
};
},
{
get: (_, event) => {
const name = mappings[event as keyof T];
return new Proxy((() => {}) as any, {
apply: (_, __, [window]: [__WebviewWindow__]) => ({
listen: (arg: any) => window.listen(name, arg),
once: (arg: any) => window.once(name, arg),
emit: (arg: any) => window.emit(name, arg),
}),
get: (_, command: keyof __EventObj__<any>) => {
switch (command) {
case "listen":
return (arg: any) => TAURI_API_EVENT.listen(name, arg);
case "once":
return (arg: any) => TAURI_API_EVENT.once(name, arg);
case "emit":
return (arg: any) => TAURI_API_EVENT.emit(name, arg);
}
},
});
},
},
);
}
@@ -0,0 +1,143 @@
<script setup lang="ts">
import type { CommonContentPart } from '@xsai/shared-chat'
import { MarkdownRenderer } from '@proj-airi/stage-ui/components'
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
import { storeToRefs } from 'pinia'
import { nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const chatHistoryRef = ref<HTMLDivElement>()
const { t } = useI18n()
const { messages, sending, streamingMessage } = storeToRefs(useChatStore())
const { onBeforeMessageComposed, onTokenLiteral } = useChatStore()
onBeforeMessageComposed(async () => {
// Scroll down to the new sent message
nextTick().then(() => {
if (!chatHistoryRef.value)
return
chatHistoryRef.value.scrollTop = chatHistoryRef.value.scrollHeight
})
})
onTokenLiteral(async () => {
// Scroll down to the new responding message
nextTick().then(() => {
if (!chatHistoryRef.value)
return
chatHistoryRef.value.scrollTop = chatHistoryRef.value.scrollHeight
})
})
</script>
<template>
<div ref="chatHistoryRef" v-auto-animate flex="~ col" relative h-full w-full overflow-y-auto rounded-lg px="<sm:2" py="<sm:2">
<div v-for="(message, index) in messages" :key="index" mb-2>
<div v-if="message.role === 'error'" flex mr="12">
<div
flex="~ col" border="2 solid violet-200/50 dark:violet-500/50" shadow="md violet-200/50 dark:none"
min-w-20 rounded-lg px-2 py-1 h="unset <sm:fit" bg="<md:violet-500/25"
>
<div flex="~ row" gap-2>
<div flex-1>
<span text-xs text="violet-400/90 dark:violet-600/90" font-normal class="inline <sm:hidden">{{ t('stage.chat.message.character-name.core-system') }}</span>
</div>
<div i-solar:danger-triangle-bold-duotone text-violet-500 />
</div>
<div v-if="sending && index === messages.length - 1" i-eos-icons:three-dots-loading />
<MarkdownRenderer
v-else
:content="message.content as string"
class="break-words text-violet-500"
text="base <sm:xs"
/>
</div>
</div>
<div v-if="message.role === 'assistant' && message.content !== ''" flex mr="12">
<div
flex="~ col" border="2 solid primary-200/50 dark:primary-500/50" shadow="md primary-200/50 dark:none" min-w-20
rounded-lg px-2 py-1 h="unset <sm:fit" bg="<md:primary-500/25"
>
<div>
<span text-xs text="primary-400/90 dark:primary-600/90" font-normal class="inline <sm:hidden">{{ t('stage.chat.message.character-name.airi') }}</span>
</div>
<div class="break-words" text="xs primary-400">
<div v-for="(slice, sliceIndex) in message.slices" :key="sliceIndex">
<div v-if="slice.type === 'tool-call'">
<div
p="1" border="1 solid primary-200" rounded-lg m="y-1" bg="primary-100"
>
Called: <code>{{ slice.toolCall.toolName }}</code>
</div>
</div>
<div v-else-if="slice.type === 'tool-call-result'" /> <!-- this line should be unreachable -->
<MarkdownRenderer
v-else
:content="slice.text"
/>
</div>
</div>
</div>
</div>
<div v-else-if="message.role === 'user'" flex="~ row-reverse" ml="12">
<div
flex="~ col" border="2 solid cyan-200/50 dark:cyan-500/50" shadow="md cyan-200/50 dark:none" px="2"
h="unset <sm:fit" min-w-20 rounded-lg px-2 py-1 bg="<md:cyan-500/25"
>
<div>
<span text-xs text="cyan-400/90 dark:cyan-600/90" font-normal class="inline <sm:hidden">{{ t('stage.chat.message.character-name.you') }}</span>
</div>
<div v-if="Array.isArray(message.content)" class="flex flex-col gap-2">
<template v-for="(part, partIndex) in (message.content as CommonContentPart[])" :key="partIndex">
<MarkdownRenderer
v-if="part.type === 'text' && part.text"
:content="part.text"
class="break-words"
text="base <sm:xs"
/>
<img v-if="part.type === 'image_url'" :src="part.image_url.url" class="max-w-full rounded-lg">
</template>
</div>
<MarkdownRenderer
v-else-if="typeof message.content === 'string'"
:content="message.content"
class="break-words"
text="base <sm:xs"
/>
</div>
</div>
</div>
<div v-if="sending" flex mr="12">
<div
flex="~ col" border="2 solid primary-200/50 dark:primary-500/50" shadow="md primary-200/50 dark:none" min-w-20
rounded-lg px-2 py-1 h="unset <sm:fit" bg="<md:primary-500/25"
>
<div>
<span text-xs text="primary-400/90 dark:primary-600/90" font-normal class="inline <sm:hidden">{{ t('stage.chat.message.character-name.airi') }}</span>
</div>
<div v-if="streamingMessage.content" class="break-words" text="xs primary-400">
<div v-for="(slice, sliceIndex) in streamingMessage.slices" :key="sliceIndex">
<div v-if="slice.type === 'tool-call'">
<div
p="1" border="1 solid primary-200" rounded-lg m="y-1" bg="primary-100"
>
Called: <code>{{ slice.toolCall.toolName }}</code>
</div>
</div>
<div v-else-if="slice.type === 'tool-call-result'" /> <!-- this line should be unreachable -->
<MarkdownRenderer
v-else
:content="slice.text"
/>
</div>
</div>
<div v-else i-eos-icons:three-dots-loading />
</div>
</div>
</div>
</template>
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
const props = defineProps<{
icon: string
iconSize: number // avoid conflict with unocss size prop
position: string
duration: number
started: boolean
textColor: string
isReverse?: boolean
zIndex?: number
}>()
const emit = defineEmits<{
(e: 'animationEnded'): void
}>()
const isAnimating = ref(false)
const clsAndProps = computed(() => {
return {
opacity: isAnimating.value !== props.isReverse
? 1
: 0, // this equals to opacity: isAnimating.value ? props.isReverse ? 1 : 0 : props.isReverse ? 0 : 1
size: isAnimating.value !== props.isReverse
? 25
: props.iconSize,
position: isAnimating.value !== props.isReverse
? `calc(50dvw - 12.5rem), calc(50dvh - 12.5rem)`
: props.position,
textColor: isAnimating.value !== props.isReverse
? 'text-white'
: props.textColor,
}
})
const animationEndProps = ref<string[]>([])
const animationEnded = ref(false)
function handleAnimationEnded(e: TransitionEvent) {
animationEndProps.value.push(e.propertyName)
if (animationEndProps.value.includes('color')
&& animationEndProps.value.includes('width')
&& animationEndProps.value.includes('height')
&& animationEndProps.value.includes('transform')) {
animationEnded.value = true
emit('animationEnded')
}
}
watch(() => props.started, newVal =>
newVal && requestAnimationFrame(() => isAnimating.value = true))
</script>
<template>
<div
pointer-events-none fixed w="100dvw" h="100dvh"
:style="{
zIndex: animationEnded ? zIndex : undefined,
}"
>
<div
fixed inset-0 bg-primary-500 transition-opacity ease-linear :style="{
opacity: clsAndProps.opacity,
transitionDuration: `${duration}ms`,
}"
/>
<div
fixed inset-0 ease-in-out :style="{
width: `${clsAndProps.size}rem`,
height: `${clsAndProps.size}rem`,
transform: `translate(${clsAndProps.position})`,
transitionDuration: `${duration}ms`,
}" :class="[
clsAndProps.textColor,
props.icon,
{ 'transition-all': isAnimating },
]"
@transitionend="handleAnimationEnded"
/>
</div>
</template>
@@ -0,0 +1,180 @@
<script setup lang="ts">
import type { ChatProvider } from '@xsai-ext/shared-providers'
import { useMicVAD } from '@proj-airi/stage-ui/composables'
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { BasicTextarea } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import TamagotchiChatHistory from './ChatHistory.vue'
const messageInput = ref('')
const listening = ref(false)
const attachments = ref<{ type: 'image', data: string, mimeType: string, url: string }[]>([])
// const { askPermission } = useSettingsAudioDevice()
const { enabled, selectedAudioInput } = storeToRefs(useSettingsAudioDevice())
const { send, onAfterMessageComposed, discoverToolsCompatibility } = useChatStore()
const { messages } = storeToRefs(useChatStore())
const { t } = useI18n()
const providersStore = useProvidersStore()
const { activeModel, activeProvider } = storeToRefs(useConsciousnessStore())
const isComposing = ref(false)
async function handleSend() {
if (isComposing.value) {
return
}
if (!messageInput.value.trim() && !attachments.value.length) {
return
}
try {
const providerConfig = providersStore.getProviderConfig(activeProvider.value)
const attachmentsToSend = attachments.value.map(({ data, mimeType, type }) => ({ data, mimeType, type }))
await send(messageInput.value, {
model: activeModel.value,
chatProvider: await providersStore.getProviderInstance<ChatProvider>(activeProvider.value),
providerConfig,
attachments: attachmentsToSend,
})
// clear after sending
messageInput.value = ''
}
catch (error) {
messages.value.pop()
messages.value.push({
role: 'error',
content: (error as Error).message,
})
}
}
async function handleFilePaste(files: File[]) {
for (const file of files) {
if (file.type.startsWith('image/')) {
const reader = new FileReader()
reader.onload = (e) => {
const base64Data = (e.target?.result as string)?.split(',')[1]
if (base64Data) {
attachments.value.push({
type: 'image' as const,
data: base64Data,
mimeType: file.type,
url: URL.createObjectURL(file),
})
}
}
reader.readAsDataURL(file)
}
}
}
function removeAttachment(index: number) {
const attachment = attachments.value[index]
if (attachment) {
URL.revokeObjectURL(attachment.url)
attachments.value.splice(index, 1)
}
}
const { destroy, start } = useMicVAD(selectedAudioInput, {
onSpeechStart: () => {
// TODO: interrupt the playback
// TODO: interrupt any of the ongoing TTS
// TODO: interrupt any of the ongoing LLM requests
// TODO: interrupt any of the ongoing animation of Live2D or VRM
// TODO: once interrupted, we should somehow switch to listen or thinking
// emotion / expression?
listening.value = true
},
// VAD misfire means while speech end is detected but
// the frames of the segment of the audio buffer
// is not enough to be considered as a speech segment
// which controlled by the `minSpeechFrames` parameter
onVADMisfire: () => {
// TODO: do audio buffer send to whisper
listening.value = false
},
onSpeechEnd: (buffer) => {
// TODO: do audio buffer send to whisper
listening.value = false
handleTranscription(buffer)
},
auto: false,
})
function handleTranscription(_buffer: Float32Array) {
// eslint-disable-next-line no-alert
alert('Transcription is not implemented yet')
}
// async function handleAudioInputChange(event: Event) {
// const target = event.target as HTMLSelectElement
// const found = audioInputs.value.find(d => d.deviceId === target.value)
// if (!found) {
// selectedAudioDevice.value = undefined
// return
// }
// selectedAudioDevice.value = found
// }
watch(enabled, async (value) => {
if (value === false) {
destroy()
}
})
watch([activeProvider, activeModel], async () => {
if (activeProvider.value && activeModel.value) {
await discoverToolsCompatibility(activeModel.value, await providersStore.getProviderInstance<ChatProvider>(activeProvider.value), [])
}
})
onAfterMessageComposed(async () => {
messageInput.value = ''
attachments.value.forEach(att => URL.revokeObjectURL(att.url))
attachments.value = []
})
onMounted(() => {
start()
})
</script>
<template>
<div h-full w-full flex="~ col gap-1">
<div w-full flex-1 overflow-hidden>
<TamagotchiChatHistory />
</div>
<div v-if="attachments.length > 0" class="flex flex-wrap gap-2 border-t border-primary-100 p-2">
<div v-for="(attachment, index) in attachments" :key="index" class="relative">
<img :src="attachment.url" class="h-20 w-20 rounded-md object-cover">
<button class="absolute right-1 top-1 h-5 w-5 flex items-center justify-center rounded-full bg-red-500 text-xs text-white" @click="removeAttachment(index)">
&times;
</button>
</div>
</div>
<BasicTextarea
v-model="messageInput"
:placeholder="t('stage.message')"
border="solid 2 primary-100"
text="primary-400 hover:primary-600 placeholder:primary-400 placeholder:hover:primary-600"
bg="primary-50 dark:primary-100" max-h="[10lh]" min-h="[1lh]"
w-full shrink-0 resize-none overflow-y-scroll rounded-xl p-2 font-medium outline-none
transition="all duration-250 ease-in-out placeholder:all placeholder:duration-250 placeholder:ease-in-out"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
@keydown.enter.exact.prevent="handleSend"
@paste-file="handleFilePaste"
/>
</div>
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
defineProps<{
text: string
iconOn: string
iconOff: string
description?: string
}>()
const model = defineModel<boolean>()
</script>
<template>
<label
class="w-full flex cursor-pointer items-center justify-between rounded-lg px-4 py-3 text-sm outline-none transition-all duration-250 ease-in-out"
bg="neutral-50 dark:neutral-800"
hover="bg-neutral-200 dark:bg-neutral-700"
>
<input v-model="model" :aria-checked="model" type="checkbox" hidden>
<div>
{{ $t(text) }}
<div v-if="description" text="sm neutral-500">
{{ $t(description) }}
</div>
</div>
<Transition name="slide-away" mode="out-in">
<div v-if="model" :class="iconOn" transition="all ease-in-out duration-250" />
<div v-else :class="iconOff" transition="all ease-in-out duration-250" />
</Transition>
</label>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
// import { commands } from '../../bindings/tauri-plugins/window-router-link'
// const props = defineProps<{
// to: string
// label?: string
// }>()
defineProps<{
to: string
label?: string
}>()
function handleClick() {
// commands.go(props.to, props.label ?? null)
}
</script>
<template>
<a cursor-pointer @click="handleClick">
<slot />
</a>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { Component } from '../../../stores/resources'
import { Progress } from '@proj-airi/stage-ui/components'
const props = defineProps<{
component: Component
}>()
</script>
<template>
<div flex flex-col gap-2>
<div
v-for="file in Array.from(props.component.files.values())"
:key="file.filename"
max-w-full flex flex-col gap="1 sm:2"
>
<div grid="~ cols-[85%_15%]" justify-between text="xs sm:sm neutral-600 dark:neutral-400">
<div flex items-center gap-1>
<div v-if="file.progress < 100" i-svg-spinners:pulse-ring />
<div v-else i-solar:check-circle-bold-duotone text="green-600 dark:green-400" />
<div inline-block flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-nowrap font-mono>
{{ file.filename }}
</div>
</div>
<div text-right>
{{ file.progress.toFixed(2) }}%
</div>
</div>
<Progress
:progress="file.progress"
bar-class="bg-primary-300 dark:bg-primary-300/50"
/>
</div>
</div>
</template>
@@ -0,0 +1,3 @@
<template>
<slot />
</template>
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import WindowRouterLink from '../../Tauri/WindowRouterLink.vue'
import LoadingComponent from './LoadingComponent.vue'
import { useResourcesStore } from '../../../stores/resources'
const { t } = useI18n()
const { resources, pendingResources, atLeastOneLoading } = storeToRefs(useResourcesStore())
const totalProgress = computed(() => {
return Array
.from(resources.value.values())
.reduce((acc, module) => {
return acc + Array
.from(module.value.components.values())
.reduce((acc, component) => {
return acc + Array
.from(component.files.values())
.reduce((acc, file) => acc + file.progress, 0)
}, 0)
}, 0)
})
</script>
<template>
<div flex flex-col gap-3>
<div>
<div v-if="atLeastOneLoading" flex items-center gap-2 text="sm orange-600 dark:orange-400">
<div i-solar:danger-triangle-bold-duotone />
<div text-nowrap>
Some modules haven't been available yet...
</div>
</div>
<div v-else flex items-center gap-2 text="sm green-600 dark:green-400">
<div i-solar:check-circle-bold-duotone />
<div text-nowrap>
All modules are ready!
</div>
</div>
<ul ml-4 mt-3>
<li v-for="[moduleName, module] in resources" :key="moduleName">
<WindowRouterLink :to="`/settings/modules/${moduleName}`" label="settings">
<div flex items-center gap-1>
<div flex items-center gap-1>
<div i-solar:microphone-3-bold-duotone text="neutral-500 dark:neutral-400" />
<span decoration-underline decoration-dashed>{{ t(`settings.pages.modules.${moduleName}.title`) }}</span>
</div>
<div v-if="module.value.reason" text="neutral-500 dark:neutral-400 nowrap">
{{ module.value.reason }}
</div>
</div>
</WindowRouterLink>
</li>
</ul>
</div>
<div flex items-center gap-2 text-sm>
<div v-if="totalProgress < 100" i-svg-spinners:pulse-ring />
<div v-else i-solar:check-circle-bold-duotone text="neutral-500 dark:neutral-400" />
<div v-if="Array(pendingResources.values()).length > 0">
Preparing resources...
</div>
</div>
<div v-if="Array(pendingResources.values()).length > 0" max-w="100%" max-h="[calc(50dvh)]" w-full flex flex-col gap-2 overflow-x-hidden overflow-y-scroll scrollbar="none">
<div v-for="[moduleName, module] in pendingResources" :key="moduleName" flex flex-col gap-2>
<div v-for="[componentName, component] in module.value.components" :key="componentName" flex flex-col gap-2>
<LoadingComponent :component="component" />
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { TransitionVertical } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { TooltipContent, TooltipPortal, TooltipProvider, TooltipRoot, TooltipTrigger } from 'reka-ui'
import { ref, watch } from 'vue'
import LoadingModules from './LoadingModules.vue'
import { useResourcesStore } from '../../../stores/resources'
import { useWindowStore } from '../../../stores/window'
const { shouldHideView } = storeToRefs(useWindowStore())
const { atLeastOneLoading, atLeastOneLoadingDelay5s, atLeastOneLoadingDelay10s } = storeToRefs(useResourcesStore())
const loadingProgressOpen = ref(false)
watch(atLeastOneLoading, (newVal) => {
loadingProgressOpen.value = newVal
}, { immediate: true })
function handleClick() {
loadingProgressOpen.value = !loadingProgressOpen.value
}
</script>
<template>
<div fixed left-0 top-3 w-full flex flex-col items-center>
<TooltipProvider v-if="atLeastOneLoadingDelay10s" :delay-duration="150">
<TooltipRoot :open="loadingProgressOpen && !shouldHideView" disable-closing-trigger @update:open="(state) => loadingProgressOpen = state">
<TooltipTrigger>
<Transition name="fade">
<div
v-if="atLeastOneLoadingDelay5s"
w="fit"
bg="white/80 dark:neutral-900/80"
mb-1 flex cursor-pointer items-center gap-2 rounded-full px-2 py-1 text-sm shadow-md backdrop-blur-md
@click="handleClick"
>
<div v-if="atLeastOneLoading" i-svg-spinners:pulse-ring pointer-events-none />
<div v-else i-solar:check-circle-bold-duotone pointer-events-none text="green-600 dark:green-400" />
<div v-if="atLeastOneLoading" pointer-events-none select-none pr-2>
Resources loading...
</div>
<div v-else pointer-events-none select-none pr-2>
Ready!
</div>
</div>
</Transition>
</TooltipTrigger>
<TooltipPortal>
<TransitionVertical>
<TooltipContent class="resource-status-island-tooltip" w-fit flex justify-center>
<LoadingModules
w="[calc(100dvw-1.5rem)] sm:[calc(75dvw-1.5rem)] md:sm:[calc(50dvw-1.5rem)]"
bg="white/80 dark:neutral-900/80"
rounded-xl p-3 shadow-md backdrop-blur-md will-change-transform
/>
</TooltipContent>
</TransitionVertical>
</TooltipPortal>
</TooltipRoot>
</TooltipProvider>
</div>
</template>
<style>
[data-reka-popper-content-wrapper=""]:has(.resource-status-island-tooltip) {
z-index: 1000 !important;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.fade-enter-to,
.fade-leave-from {
opacity: 1;
}
</style>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { useAppRuntime } from '../../composables/runtime'
defineProps<{
title: string
icon: string
}>()
const { platform } = useAppRuntime()
</script>
<template>
<div
bg="neutral-100 dark:neutral-900" w="100dvw"
top="0"
data-tauri-drag-region fixed z-100 w-full select-none py-2 pr-4
:class="[
platform === 'macos' ? 'pl-20' : 'pl-4',
]"
>
<div data-tauri-drag-region flex>
<div
bg="hover:neutral-200 hover:dark:neutral-800"
transition="all duration-200 ease-in-out"
flex cursor-pointer select-none items-center gap-2 rounded-md px-1.5 py-0.5
>
<div :class="icon" select-none text="neutral-400 dark:neutral-500" whitespace-nowrap />
<div><span select-none whitespace-nowrap text-sm>{{ title }}</span></div>
</div>
<div data-tauri-drag-region w-full />
<div
bg="hover:neutral-200 hover:dark:neutral-800"
transition="all duration-200 ease-in-out"
flex cursor-pointer items-center gap-2 rounded-md px-1.5 py-0.5
>
<div i-solar:info-circle-bold text="neutral-400 dark:neutral-500" whitespace-nowrap />
</div>
</div>
</div>
</template>
@@ -0,0 +1,29 @@
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { computed, onMounted, onUnmounted, ref } from 'vue'
export function useIconAnimation(icon: string) {
const iconAnimationStarted = ref(false)
const showAnimationComponent = ref(false)
const animationIcon = ref(icon)
const settingsStore = useSettings()
const showIconAnimation = computed(() => showAnimationComponent.value && !settingsStore.disableTransitions && settingsStore.usePageSpecificTransitions)
onMounted(() => {
showAnimationComponent.value = true
requestAnimationFrame(() => {
iconAnimationStarted.value = true
})
})
onUnmounted(() => {
iconAnimationStarted.value = false
showAnimationComponent.value = false
})
return {
iconAnimationStarted,
showIconAnimation,
animationIcon,
}
}
@@ -0,0 +1,25 @@
import { computedAsync } from '@vueuse/core'
import { computed, ref } from 'vue'
export function useAppRuntime() {
const isInitialized = ref(false)
const platform = computedAsync(async () => {
const res = 'electron'
if (!isInitialized.value) {
isInitialized.value = true
}
return res
}, 'web')
const isTauri = computed(() => {
return platform.value !== 'web'
})
return {
platform,
isInitialized,
isTauri,
}
}
@@ -0,0 +1,34 @@
// import { useMagicKeys } from '@vueuse/core'
// import { until, whenever } from '@vueuse/shared'
// import { storeToRefs } from 'pinia'
// import { watch } from 'vue'
// import { useShortcutsStore } from '../stores/shortcuts'
// import { useAppRuntime } from './runtime'
// import { useTauriRdevEventTarget } from './tauri-rdev'
// export function useTauriGlobalShortcuts() {
// const { shortcuts } = storeToRefs(useShortcutsStore())
// const { platform, isInitialized } = useAppRuntime()
// const eventTarget = useTauriRdevEventTarget()
// const keys = useMagicKeys({ target: eventTarget })
// watch(shortcuts, async () => {
// await until(isInitialized).toBeTruthy()
// if (platform.value === 'web') {
// return
// }
// for (const handler of shortcuts.value) {
// if (!handler.shortcut) {
// return
// }
// whenever(keys[handler.shortcut], async () => {
// handler.handle().catch((error) => {
// console.error('Error handling shortcut', error)
// })
// })
// }
// }, { immediate: true })
// }
@@ -0,0 +1,42 @@
// import type { Position } from '@tauri-apps/plugin-positioner'
// import { computedAsync, until } from '@vueuse/core'
// import { useAppRuntime } from './runtime'
// import { untilImported } from './tauri'
// export function useTauriPositioner() {
// const { platform, isInitialized } = useAppRuntime()
// const tauriPositionerApi = computedAsync(async () => {
// await until(isInitialized).toBeTruthy()
// if (platform.value !== 'web') {
// return untilImported(() => import('@tauri-apps/plugin-positioner'), console.warn)
// }
// })
// async function ensureImported() {
// await until(isInitialized).toBeTruthy()
// if (platform.value === 'web') {
// console.warn('Attempted to use Tauri positioner in web platform')
// return
// }
// await until(tauriPositionerApi).toBeTruthy()
// const imported = await tauriPositionerApi.value
// if (!imported) {
// throw new Error('Tauri positioner API not available')
// }
// }
// async function moveWindow(to: Position) {
// await ensureImported()
// return tauriPositionerApi.value?.moveWindow(to)
// }
// return {
// moveWindow,
// }
// }
@@ -0,0 +1,117 @@
// import type { AiriTamagotchiEvents } from './tauri'
// import { onMounted, onUnmounted } from 'vue'
// import { KeyCode, mapKeyCode, mapKeyKey } from '../tauri/rdev'
// import { useTauriEvent } from './tauri'
// export function useTauriRdevEventTarget(): EventTarget {
// const unListenFuncs: (() => void)[] = []
// const eventTarget = new EventTarget()
// const { listen } = useTauriEvent<AiriTamagotchiEvents>()
// async function setup() {
// window.addEventListener('keyup', (event) => {
// event.preventDefault()
// event.stopPropagation()
// const e = new KeyboardEvent('keyup', {
// key: event.key,
// code: event.code,
// metaKey: event.metaKey,
// ctrlKey: event.ctrlKey,
// altKey: event.altKey,
// shiftKey: event.shiftKey,
// })
// eventTarget.dispatchEvent(e)
// })
// window.addEventListener('keydown', (event) => {
// event.preventDefault()
// event.stopPropagation()
// const e = new KeyboardEvent('keydown', {
// key: event.key,
// code: event.code,
// metaKey: event.metaKey,
// ctrlKey: event.ctrlKey,
// altKey: event.altKey,
// shiftKey: event.shiftKey,
// })
// eventTarget.dispatchEvent(e)
// })
// unListenFuncs.push(await listen('tauri-plugins:tauri-plugin-rdev:mousemove', (event) => {
// if (event.payload.event_type.MouseMove) {
// const { x, y } = event.payload.event_type.MouseMove
// const e = new MouseEvent('mousemove', {
// clientX: x,
// clientY: y,
// })
// eventTarget.dispatchEvent(e)
// }
// }))
// unListenFuncs.push(await listen('tauri-plugins:tauri-plugin-rdev:keyup', (event) => {
// if (typeof event.payload.event_type.KeyRelease === 'object' && 'Unknown' in event.payload.event_type.KeyRelease) {
// if (event.payload.event_type.KeyRelease.Unknown === 62) {
// event.payload.event_type = { KeyRelease: KeyCode.ControlLeft }
// }
// }
// if (typeof event.payload.event_type.KeyRelease !== 'string') {
// console.warn('unknown key release event:', event.payload.event_type.KeyRelease)
// return
// }
// const e = new KeyboardEvent('keyup', {
// key: mapKeyKey[event.payload.event_type.KeyRelease],
// code: mapKeyCode[event.payload.event_type.KeyRelease],
// metaKey: mapKeyKey[event.payload.event_type.KeyRelease] === 'Meta',
// ctrlKey: mapKeyKey[event.payload.event_type.KeyRelease] === 'Control',
// altKey: mapKeyKey[event.payload.event_type.KeyRelease] === 'Alt',
// shiftKey: mapKeyKey[event.payload.event_type.KeyRelease] === 'Shift',
// })
// eventTarget.dispatchEvent(e)
// }))
// unListenFuncs.push(await listen('tauri-plugins:tauri-plugin-rdev:keydown', (event) => {
// if (typeof event.payload.event_type.KeyPress === 'object' && 'Unknown' in event.payload.event_type.KeyPress) {
// if (event.payload.event_type.KeyPress.Unknown === 62) {
// event.payload.event_type = { KeyPress: KeyCode.ControlLeft }
// }
// }
// if (typeof event.payload.event_type.KeyPress !== 'string') {
// console.warn('unknown key release event:', event.payload.event_type.KeyPress)
// return
// }
// const e = new KeyboardEvent('keydown', {
// key: mapKeyKey[event.payload.event_type.KeyPress],
// code: mapKeyCode[event.payload.event_type.KeyPress],
// metaKey: mapKeyKey[event.payload.event_type.KeyPress] === 'Meta',
// ctrlKey: mapKeyKey[event.payload.event_type.KeyPress] === 'Control',
// altKey: mapKeyKey[event.payload.event_type.KeyPress] === 'Alt',
// shiftKey: mapKeyKey[event.payload.event_type.KeyPress] === 'Shift',
// })
// eventTarget.dispatchEvent(e)
// }))
// }
// function cleanup() {
// unListenFuncs.forEach(unListen => unListen())
// unListenFuncs.length = 0
// }
// onMounted(() => setup())
// onUnmounted(() => cleanup())
// if (import.meta.hot) { // For better DX
// import.meta.hot.on('vite:beforeUpdate', () => cleanup())
// import.meta.hot.on('vite:afterUpdate', async () => await setup())
// }
// return eventTarget
// }
@@ -0,0 +1,134 @@
// import type { MaybeRefOrGetter } from 'vue'
// import type { AiriTamagotchiEvents, Point, WindowFrame } from './tauri'
// import { onMounted, onUnmounted, ref, toValue, watch } from 'vue'
// import { startClickThrough, stopClickThrough } from '../utils/windows'
// import { useAppRuntime } from './runtime'
// import { useTauriCore, useTauriEvent } from './tauri'
// export function useTauriPointAndWindowFrame() {
// const { listen } = useTauriEvent<AiriTamagotchiEvents>()
// const { invoke } = useTauriCore()
// const unListenFuncs = ref<(() => void)[]>([])
// const mousePos = ref<Point>({ x: 0, y: 0 })
// const windowFrame = ref<WindowFrame>({
// origin: { x: 0, y: 0 },
// size: { width: 0, height: 0 },
// })
// function _onCursorPosition(event: { payload: Point }) {
// mousePos.value = event.payload
// }
// function _onWindowFrame(event: { payload: WindowFrame }) {
// windowFrame.value = event.payload
// }
// function addListeners() {
// listen('tauri-plugins:tauri-plugin-window-pass-through-on-hover:cursor-position', (event) => {
// _onCursorPosition(event)
// }).then((fn) => {
// unListenFuncs.value.push(fn)
// })
// listen('tauri-plugins:tauri-plugin-window-pass-through-on-hover:window-frame', (event) => {
// _onWindowFrame(event)
// }).then((fn) => {
// unListenFuncs.value.push(fn)
// })
// }
// onMounted(() => {
// addListeners()
// invoke('plugin:window-pass-through-on-hover|start_tracing_cursor')
// })
// onUnmounted(() => {
// unListenFuncs.value.forEach(fn => fn?.())
// unListenFuncs.value.length = 0
// invoke('plugin:window-pass-through-on-hover|stop_tracing_cursor')
// })
// if (import.meta.hot) { // For better DX
// import.meta.hot.on('vite:beforeUpdate', () => {
// unListenFuncs.value.forEach(fn => fn?.())
// unListenFuncs.value.length = 0
// invoke('plugin:window-pass-through-on-hover|stop_tracing_cursor')
// })
// import.meta.hot.on('vite:afterUpdate', () => {
// addListeners()
// invoke('plugin:window-pass-through-on-hover|start_tracing_cursor')
// })
// }
// return {
// mousePos,
// windowFrame,
// }
// }
// export function useTauriWindowClickThrough(live2DLookAtDefault: MaybeRefOrGetter<{ x: number, y: number }>) {
// const { platform, isInitialized } = useAppRuntime()
// const { mousePos, windowFrame } = useTauriPointAndWindowFrame()
// const live2dLookAtX = ref(toValue(live2DLookAtDefault).x)
// const live2dLookAtY = ref(toValue(live2DLookAtDefault).y)
// const isCursorInside = ref(false)
// function updateLive2DLookAt() {
// if (platform.value === 'macos') {
// live2dLookAtX.value = mousePos.value.x - windowFrame.value.origin.x
// live2dLookAtY.value = windowFrame.value.size.height - mousePos.value.y + windowFrame.value.origin.y
// return
// }
// live2dLookAtX.value = mousePos.value.x - windowFrame.value.origin.x
// live2dLookAtY.value = mousePos.value.y - windowFrame.value.origin.y
// }
// watch(mousePos, () => {
// updateLive2DLookAt()
// })
// function updateIsCursorInside() {
// isCursorInside.value
// = mousePos.value.x >= windowFrame.value.origin.x
// && mousePos.value.x <= windowFrame.value.origin.x + windowFrame.value.size.width
// && mousePos.value.y >= windowFrame.value.origin.y
// && mousePos.value.y <= windowFrame.value.origin.y + windowFrame.value.size.height
// }
// watch([mousePos, windowFrame], () => {
// updateIsCursorInside()
// })
// watch(isInitialized, async (initialized) => {
// if (initialized) {
// updateLive2DLookAt()
// updateIsCursorInside()
// }
// })
// onMounted(async () => {
// await startClickThrough()
// })
// onUnmounted(async () => {
// await stopClickThrough()
// })
// return {
// isCursorInside,
// live2dLookAtX,
// live2dLookAtY,
// }
// }
@@ -0,0 +1,77 @@
// import { computedAsync, until } from '@vueuse/core'
// import { useAppRuntime } from './runtime'
// import { untilImported } from './tauri'
// export enum StateFlags {
// SIZE = 1,
// POSITION = 2,
// MAXIMIZED = 4,
// VISIBLE = 8,
// DECORATIONS = 16,
// FULLSCREEN = 32,
// ALL = 63,
// }
// export function useTauriWindowState() {
// const { platform, isInitialized } = useAppRuntime()
// const tauriWindowStateApi = computedAsync(async () => {
// await until(isInitialized).toBeTruthy()
// if (platform.value !== 'web') {
// return untilImported(() => import('@tauri-apps/plugin-window-state'), console.warn)
// }
// })
// async function ensureImported() {
// await until(isInitialized).toBeTruthy()
// if (platform.value === 'web') {
// console.warn('Attempted to save window state in web platform')
// return
// }
// await until(tauriWindowStateApi).toBeTruthy()
// const imported = await tauriWindowStateApi.value
// if (!imported) {
// throw new Error('Tauri window state API not available')
// }
// }
// async function saveWindowState(stateFlag?: StateFlags) {
// await ensureImported()
// if (stateFlag != null) {
// return tauriWindowStateApi.value?.saveWindowState(stateFlag)
// }
// else {
// return tauriWindowStateApi.value?.saveWindowState(tauriWindowStateApi.value.StateFlags.ALL)
// }
// }
// async function restoreState(stateFlag?: StateFlags, windowLabel = 'main') {
// await ensureImported()
// if (stateFlag != null) {
// return tauriWindowStateApi.value?.restoreState(windowLabel, stateFlag)
// }
// else {
// return tauriWindowStateApi.value?.restoreState(windowLabel, tauriWindowStateApi.value.StateFlags.ALL)
// }
// }
// async function restoreStateCurrent(stateFlag?: StateFlags) {
// await ensureImported()
// if (stateFlag != null) {
// return tauriWindowStateApi.value?.restoreStateCurrent(stateFlag)
// }
// else {
// return tauriWindowStateApi.value?.restoreStateCurrent(tauriWindowStateApi.value.StateFlags.ALL)
// }
// }
// return {
// saveWindowState,
// restoreState,
// restoreStateCurrent,
// }
// }
@@ -0,0 +1,340 @@
// import type { InvokeArgs, InvokeOptions } from '@tauri-apps/api/core'
// import type { EventCallback, EventName, UnlistenFn } from '@tauri-apps/api/event'
// import type { Monitor } from '@tauri-apps/api/window'
// import type { InvokeMethods, InvokeMethodShape } from '../tauri/invoke'
// import type { KeyCode } from '../tauri/rdev'
// import { withRetry } from '@moeru/std'
// import { computedAsync, until } from '@vueuse/core'
// import { useAppRuntime } from './runtime'
// export async function untilNoError<T>(fn: () => Promise<T>, onError?: (err?: unknown | null) => void): Promise<T> {
// const fnRetry = withRetry(fn, { retryDelay: 5000, retry: 5, onError })
// return await fnRetry()
// }
// export async function untilImported<T>(fn: () => Promise<T>, onError?: (err?: unknown | null) => void): Promise<T> {
// return await untilNoError(fn, onError)
// }
// export interface Point {
// x: number
// y: number
// }
// export interface Size {
// width: number
// height: number
// }
// export interface WindowFrame {
// origin: Point
// size: Size
// }
// interface Events {
// 'tauri://resize': unknown
// 'tauri://move': { x: number, y: number }
// 'tauri://close-requested': unknown
// 'tauri://destroyed': unknown
// 'tauri://focus': unknown
// 'tauri://blur': unknown
// 'tauri://scale-change': unknown
// 'tauri://theme-changed': unknown
// 'tauri://window-created': unknown
// 'tauri://webview-created': unknown
// 'tauri://drag-enter': unknown
// 'tauri://drag-over': unknown
// 'tauri://drag-drop': unknown
// 'tauri://drag-leave': unknown
// }
// export interface AiriTamagotchiEvents extends Events {
// // from main
// 'tauri-main:main:window-mode:fade-on-hover': true
// 'tauri-main:main:window-mode:move': true
// 'tauri-main:main:window-mode:resize': true
// // from tauri-plugin-window-pass-through-on-hover
// 'tauri-plugins:tauri-plugin-window-pass-through-on-hover:cursor-position': Point
// 'tauri-plugins:tauri-plugin-window-pass-through-on-hover:window-frame': WindowFrame
// 'tauri-plugins:tauri-plugin-window-pass-through-on-hover:pass-through-enabled': boolean
// // from tauri-plugin-ipc-audio-transcription-ort
// 'tauri-plugins:tauri-plugin-ipc-audio-vad-ort:load-model-silero-vad-progress': [boolean, string, number, number, number]
// // from tauri-plugin-ipc-audio-vad-ort
// 'tauri-plugins:tauri-plugin-ipc-audio-transcription-ort:load-model-whisper-progress': [boolean, string, number, number, number]
// // from tauri-plugin-rdev
// 'tauri-plugins:tauri-plugin-rdev:keydown': { time: { secs_since_epoch: number, nanos_since_epoch: number }, name: string, event_type: { KeyPress: KeyCode | { Unknown: number } } } // similar to 'keydown' events from DOM elements
// 'tauri-plugins:tauri-plugin-rdev:keyup': { time: { secs_since_epoch: number, nanos_since_epoch: number }, name: string, event_type: { KeyRelease: KeyCode | { Unknown: number } } } // similar to 'keyup' events from DOM elements
// 'tauri-plugins:tauri-plugin-rdev:mousedown': { time: { secs_since_epoch: number, nanos_since_epoch: number }, name: string, event_type: { ButtonPress: string } } // similar to 'mousedown' events from DOM elements
// 'tauri-plugins:tauri-plugin-rdev:mouseup': { time: { secs_since_epoch: number, nanos_since_epoch: number }, name: string, event_type: { ButtonRelease: string } } // similar to 'mouseup' events from DOM elements
// 'tauri-plugins:tauri-plugin-rdev:mousemove': { time: { secs_since_epoch: number, nanos_since_epoch: number }, name: string, event_type: { MouseMove: { x: number, y: number } } } // similar to 'mousemove' events from DOM elements
// // MCP
// 'mcp_plugin_destroyed': undefined
// }
// export interface DisplayInfo {
// monitors: Monitor[]
// primaryMonitor: Monitor
// }
// export enum PlacementStrategy {
// Center = 'Center',
// TopLeft = 'TopLeft',
// TopRight = 'TopRight',
// BottomLeft = 'BottomLeft',
// BottomRight = 'BottomRight',
// NearCursor = 'NearCursor',
// RestorePrevious = 'RestorePrevious',
// AvoidOverlap = 'AvoidOverlap',
// }
// export interface WindowPlacementRequest {
// window_size: Size
// preferred_position?: Point
// placement_strategy: PlacementStrategy
// }
// export interface WindowPlacementResult {
// position: Point
// target_monitor_id: number
// is_constrained: boolean
// }
// export function useTauriEvent<ES = Events>() {
// const { platform, isInitialized } = useAppRuntime()
// const tauriEventApi = computedAsync(async () => {
// await until(isInitialized).toBeTruthy()
// if (platform.value !== 'web') {
// return untilImported(() => import('@tauri-apps/api/event'), console.warn)
// }
// })
// async function _listen<E extends keyof ES>(event: E, callback: EventCallback<ES[E]>) {
// await until(isInitialized).toBeTruthy()
// if (platform.value === 'web') {
// return () => {}
// }
// await until(tauriEventApi).toBeTruthy()
// const imported = await tauriEventApi.value
// if (!imported) {
// throw new Error('Tauri event API not available')
// }
// return await imported.listen(event as EventName, callback)
// }
// async function listen<E extends keyof ES>(event: E, callback: EventCallback<ES[E]>) {
// let cleanupListener: UnlistenFn | undefined
// _listen(event, callback).then((listener) => {
// cleanupListener = listener
// })
// return () => {
// cleanupListener?.()
// }
// }
// return {
// listen,
// }
// }
// export function useTauriCore<IM extends Record<keyof IM, InvokeMethodShape> = InvokeMethods>() {
// const { platform, isInitialized } = useAppRuntime()
// const tauriCoreApi = computedAsync(async () => {
// await until(isInitialized).toBeTruthy()
// if (platform.value !== 'web') {
// return untilImported(() => import('@tauri-apps/api/core'), console.warn)
// }
// })
// const tauriCoreApiInvoke = computedAsync(async () => {
// await until(isInitialized).toBeTruthy()
// if (platform.value !== 'web') {
// return untilImported(() => import('../tauri/invoke'), console.warn)
// }
// })
// async function invoke<C extends keyof IM>(
// command: C,
// args?: IM[C]['args'],
// options?: IM[C]['options'],
// ): Promise<IM[C]['returns'] | undefined> {
// await until(isInitialized).toBeTruthy()
// if (platform.value === 'web') {
// console.warn(`Attempted to invoke Tauri command "${String(command)}" in web platform`)
// return
// }
// await until(tauriCoreApiInvoke).toBeTruthy()
// const imported = await tauriCoreApiInvoke.value
// if (!imported) {
// throw new Error('Tauri core API not available')
// }
// return await imported.invoke<C, IM>(command, args as InvokeArgs | undefined, options as InvokeOptions | undefined)
// }
// return {
// invoke,
// core: tauriCoreApi,
// }
// }
// export function useTauriDpi() {
// const { platform, isInitialized } = useAppRuntime()
// const tauriDpiApi = computedAsync(async () => {
// await until(isInitialized).toBeTruthy()
// if (platform.value !== 'web') {
// return untilImported(() => import('@tauri-apps/api/window'), console.warn)
// }
// })
// async function createLogicalPosition(x: number, y: number) {
// const imported = await tauriDpiApi.value
// if (!imported) {
// throw new Error('Tauri DPI API not available')
// }
// return new imported.LogicalPosition(x, y)
// }
// return {
// createLogicalPosition,
// }
// }
// export function useTauriWindow() {
// const { platform, isInitialized } = useAppRuntime()
// const { createLogicalPosition } = useTauriDpi()
// const tauriWindowApi = computedAsync(async () => {
// await until(isInitialized).toBeTruthy()
// if (platform.value !== 'web') {
// return untilImported(() => import('@tauri-apps/api/window'), console.warn)
// }
// })
// async function _ensureImported() {
// await until(isInitialized).toBeTruthy()
// if (platform.value === 'web') {
// throw new Error('Tauri Window API is not available in web platform')
// }
// await until(tauriWindowApi).toBeTruthy()
// const imported = await tauriWindowApi.value
// if (!imported) {
// throw new Error('Tauri Window API not available')
// }
// return imported
// }
// async function getCurrentMonitor() {
// try {
// return await _ensureImported().then(imported => imported.currentMonitor())
// }
// catch (error) {
// console.error('Failed to get current monitor:', error)
// return undefined
// }
// }
// async function getAvailableMonitors() {
// try {
// return await _ensureImported().then(imported => imported.availableMonitors())
// }
// catch (error) {
// console.error('Failed to get available monitors:', error)
// return []
// }
// }
// async function getPrimaryMonitor() {
// try {
// return await _ensureImported().then(imported => imported.primaryMonitor())
// }
// catch (error) {
// console.error('Failed to get primary monitor:', error)
// return undefined
// }
// }
// async function setPosition(x: number, y: number) {
// try {
// const imported = await _ensureImported()
// const window = imported.getCurrentWindow()
// return await window.setPosition(await createLogicalPosition(x, y))
// }
// catch (error) {
// console.error('Failed to set window position:', error)
// }
// }
// async function getPosition() {
// try {
// const imported = await _ensureImported()
// const window = imported.getCurrentWindow()
// return await window.innerPosition()
// }
// catch (error) {
// console.error('Failed to get window position:', error)
// return undefined
// }
// }
// 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,
// getPosition,
// closeWindow,
// }
// }
// export function createTauriEventTarget(): EventTarget {
// const eventTarget = new EventTarget()
// return eventTarget
// }
@@ -0,0 +1,46 @@
import Color from 'colorjs.io'
import { withRetry } from '@moeru/std'
import { useDark } from '@vueuse/core'
export function themeColorFromPropertyOf(colorFromClass: string, property: string): () => Promise<string> {
return async () => {
const fetchUntilWidgetMounted = withRetry(() => {
const widgets = document.querySelector(colorFromClass) as HTMLDivElement | undefined
if (!widgets)
throw new Error('Widgets element not found')
return widgets
}, { retry: 10, retryDelay: 1000 })
const widgets = await fetchUntilWidgetMounted()
return window.getComputedStyle(widgets).getPropertyValue(property)
}
}
export function themeColorFromValue(value: string | { light: string, dark: string }): () => Promise<string> {
return async () => {
if (typeof value === 'string') {
return value
}
else {
const dark = useDark()
return dark.value ? value.dark : value.light
}
}
}
export function useThemeColor(colorFrom: () => string | Promise<string>) {
async function updateThemeColor() {
if (!('document' in globalThis && globalThis.document != null))
return
if (!('window' in globalThis && globalThis.window != null))
return
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', new Color(await colorFrom()).to('srgb').toString({ format: 'hex' }))
}
return {
updateThemeColor,
}
}
@@ -0,0 +1,30 @@
// import type { AiriTamagotchiEvents } from './tauri'
// import { createSharedComposable } from '@vueuse/core'
// import { ref } from 'vue'
// import { useTauriEvent } from './tauri'
// export const useRdevMouse = createSharedComposable(() => {
// const mouseX = ref(0)
// const mouseY = ref(0)
// const { listen } = useTauriEvent<AiriTamagotchiEvents>()
// async function setup() {
// await listen('tauri-plugins:tauri-plugin-rdev:mousemove', (event) => {
// if (event.payload.event_type.MouseMove) {
// const { x, y } = event.payload.event_type.MouseMove
// mouseX.value = x
// mouseY.value = y
// }
// })
// }
// setup()
// return {
// mouseX,
// mouseY,
// }
// })
@@ -0,0 +1,7 @@
declare global {
interface Window {
electron: import('@electron-toolkit/preload').ElectronAPI
}
}
export {}
@@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>AIRI</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=0" />
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<!-- <meta
http-equiv="Content-Security-Policy"
content="
default-src 'self';
script-src-elem 'self' 'unsafe-eval' https://cdn.jsdelivr.net/;
script-src 'self' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
font-src 'self' https://fonts.gstatic.com/;
connect-src 'self' https://cdn.jsdelivr.net/ blob: https://openrouter.ai/"
/> -->
<script src="/assets/js/CubismSdkForWeb-5-r.3/Core/live2dcubismcore.min.js"></script>
</head>
<body class="font-sans">
<div id="app"></div>
<script type="module" src="/main.ts"></script>
</body>
</html>
@@ -0,0 +1,9 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<div h-full w-full p-4 flex="~ col gap-4">
<RouterView />
</div>
</template>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
@@ -0,0 +1,164 @@
<script setup lang="ts">
import { PageHeader } from '@proj-airi/stage-ui/components'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { useDark } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterView, useRoute } from 'vue-router'
import WindowTitleBar from '../components/Window/TitleBar.vue'
import { themeColorFromValue, useThemeColor } from '../composables/theme-color'
const route = useRoute()
const dark = useDark()
const { t } = useI18n()
const providersStore = useProvidersStore()
const { allProvidersMetadata } = storeToRefs(providersStore)
const routeHeaderMetadataMap = computed(() => {
const map: Record<string, { subtitle?: string, title: string }> = {
'/settings/airi-card': {
subtitle: t('settings.title'),
title: t('settings.pages.card.title'),
},
'/settings/system': {
subtitle: t('settings.title'),
title: t('settings.pages.system.title'),
},
'/settings/system/general': {
subtitle: t('settings.title'),
title: t('settings.pages.system.general.title'),
},
'/settings/system/color-scheme': {
subtitle: t('settings.title'),
title: t('settings.pages.system.color-scheme.title'),
},
'/settings/system/window-shortcuts': {
subtitle: t('settings.title'),
title: t('tamagotchi.settings.pages.system.window-shortcuts.title'),
},
'/settings/system/developer': {
subtitle: t('settings.title'),
title: t('settings.pages.system.developer.title'),
},
'/settings/memory': {
subtitle: t('settings.title'),
title: t('settings.pages.memory.title'),
},
'/settings/models': {
subtitle: t('settings.title'),
title: t('settings.pages.models.title'),
},
'/settings/modules': {
subtitle: t('settings.title'),
title: t('settings.pages.modules.title'),
},
'/settings/modules/consciousness': {
subtitle: t('settings.title'),
title: t('settings.pages.modules.consciousness.title'),
},
'/settings/modules/speech': {
subtitle: t('settings.title'),
title: t('settings.pages.modules.speech.title'),
},
'/settings/modules/hearing': {
subtitle: t('settings.title'),
title: t('settings.pages.modules.hearing.title'),
},
'/settings/modules/memory-short-term': {
subtitle: t('settings.title'),
title: t('settings.pages.modules.memory-short-term.title'),
},
'/settings/modules/memory-long-term': {
subtitle: t('settings.title'),
title: t('settings.pages.modules.memory-long-term.title'),
},
'/settings/modules/messaging-discord': {
subtitle: t('settings.title'),
title: t('settings.pages.modules.messaging-discord.title'),
},
'/settings/modules/x': {
subtitle: t('settings.title'),
title: t('settings.pages.modules.x.title'),
},
'/settings/modules/gaming-minecraft': {
subtitle: t('settings.title'),
title: t('settings.pages.modules.gaming-minecraft.title'),
},
'/settings/modules/gaming-factorio': {
subtitle: t('settings.title'),
title: t('settings.pages.modules.gaming-factorio.title'),
},
'/settings/providers': {
subtitle: t('settings.title'),
title: t('settings.pages.providers.title'),
},
'/settings/scene': {
subtitle: t('settings.title'),
title: t('settings.pages.scene.title'),
},
'/settings': {
title: t('settings.title'),
},
// Tamagotchi specific
'/settings/modules/mcp': {
subtitle: t('settings.title'),
title: t('settings.pages.modules.mcp-server.title'),
},
}
for (const metadata of allProvidersMetadata.value) {
map[`/settings/providers/${metadata.id}`] = {
subtitle: t('settings.title'),
title: t(metadata.nameKey),
}
}
return map
})
// const activeSettingsTutorial = ref('default')
const routeHeaderMetadata = computed(() => {
return routeHeaderMetadataMap.value[route.path] || routeHeaderMetadataMap.value[`${route.path}/`]
})
const { updateThemeColor } = useThemeColor(themeColorFromValue({ light: 'rgb(255 255 255)', dark: 'rgb(18 18 18)' }))
watch(dark, () => updateThemeColor(), { immediate: true })
watch(route, () => updateThemeColor(), { immediate: true })
onMounted(() => updateThemeColor())
</script>
<template>
<div h-full w-full overflow-y-scroll scrollbar-none bg="$bg-color">
<WindowTitleBar
:title="routeHeaderMetadata?.title"
icon="i-solar:settings-bold"
/>
<div
:style="{
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
paddingTop: 'env(safe-area-inset-top, 0px)',
paddingRight: 'env(safe-area-inset-right, 0px)',
paddingLeft: 'env(safe-area-inset-left, 0px)',
}"
>
<div relative h-full w-full pb-4 top="44px">
<!-- Content -->
<div flex="~ col" mx-auto max-w-screen-xl h="[calc(100dvh-44px-16px)]">
<PageHeader
:title="routeHeaderMetadata?.title"
:subtitle="routeHeaderMetadata?.subtitle"
:disable-back-button="route.path === '/settings'"
top="44px!" px-4
/>
<div h-full px-4>
<RouterView />
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
@@ -0,0 +1,49 @@
import type { Plugin } from 'vue'
import Tres from '@tresjs/core'
import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
import { setupLayouts } from 'virtual:generated-layouts'
import { createApp } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import { routes } from 'vue-router/auto-routes'
import App from './App.vue'
import { i18n } from './modules/i18n'
import '@unocss/reset/tailwind.css'
import 'uno.css'
import './styles/main.css'
// Fonts
import '@proj-airi/font-cjkfonts-allseto/index.css'
import '@proj-airi/font-xiaolai/index.css'
import '@fontsource-variable/dm-sans'
import '@fontsource-variable/jura'
import '@fontsource-variable/quicksand'
import '@fontsource-variable/urbanist'
import '@fontsource/dm-mono'
import '@fontsource/dm-serif-display'
import '@fontsource/gugi'
import '@fontsource/kiwi-maru'
import '@fontsource/m-plus-rounded-1c'
import '@fontsource/sniglet'
const pinia = createPinia()
const router = createRouter({
history: createWebHashHistory(),
routes: setupLayouts(routes),
})
createApp(App)
.use(MotionPlugin)
// TODO: Fix autoAnimatePlugin type error
.use(autoAnimatePlugin as unknown as Plugin)
.use(router)
.use(pinia)
.use(i18n)
.use(Tres)
.mount('#app')
@@ -0,0 +1,42 @@
import messages from '@proj-airi/i18n/locales'
import { createI18n } from 'vue-i18n'
const languageRemap: Record<string, string> = {
'zh-CN': 'zh-Hans',
'zh-TW': 'zh-Hans', // TODO: remove this when zh-Hant is supported
'zh-HK': 'zh-Hans', // TODO: remove this when zh-Hant is supported
'zh-Hant': 'zh-Hans', // TODO: remove this when zh-Hant is supported
'en-US': 'en',
'en-GB': 'en',
'en-AU': 'en',
'en': 'en',
'es-ES': 'es',
'es-MX': 'es',
'es-AR': 'es',
'es': 'es',
'vi': 'vi',
'vi-VN': 'vi',
'ru': 'ru',
'ru-RU': 'ru',
}
function getLocale() {
let language = localStorage.getItem('settings/language')
const languages = Object.keys(messages!)
if (languageRemap[language || 'en'] != null) {
language = languageRemap[language || 'en']
}
if (language && languages.includes(language))
return language
return 'en'
}
export const i18n = createI18n({
legacy: false,
locale: getLocale(),
fallbackLocale: 'en',
messages,
})
@@ -0,0 +1,22 @@
<script setup lang="ts">
import InteractiveArea from '../components/InteractiveArea.vue'
import WindowTitleBar from '../components/Window/TitleBar.vue'
</script>
<template>
<div h-full w-full pt="44px" overflow-y-scroll>
<WindowTitleBar
title="Chat"
icon="i-solar:chat-line-bold"
/>
<InteractiveArea
class="interaction-area block"
h-full w-full p-4 transition="opacity duration-250"
/>
</div>
</template>
<route lang="yaml">
meta:
layout: stage
</route>
@@ -0,0 +1,139 @@
<!-- <script setup lang="ts">
import { useMagicKeys } from '@vueuse/core'
import { computed, defineComponent, h } from 'vue'
import { useTauriRdevEventTarget } from '../../composables/tauri-rdev'
const eventTarget = useTauriRdevEventTarget()
const {
shift,
control,
escape,
tab,
v,
u,
e,
s,
v_u_e,
u_s_e,
i_Shift_Alt,
a_Shift_Alt,
n_Shift_Alt,
current,
} = useMagicKeys({ target: eventTarget })
const keys = computed(() => Array.from(current))
const Key = defineComponent({
props: {
value: {
type: Boolean,
required: true,
},
},
render() {
return h('div', {
class: [
'font-mono px-4 py-2 rounded',
this.value
? 'opacity-100 text-primary bg-primary bg-opacity-15'
: 'opacity-50 bg-gray-600 bg-opacity-10 dark:(bg-gray-400 bg-opacity-10)',
],
}, this.$slots.default?.())
},
})
</script>
<template>
<div class="flex flex-col md:flex-row">
<img
class="m-auto h-38 transform py-8 transition duration-500"
:class="{ 'opacity-0': !v_u_e, 'rotate-180': shift }"
>
<div>
<div class="mb-5 mt-0 text-center">
Press the following keys to test out
</div>
<div class="flex justify-center gap-3">
<Key :value="v">
V
</Key>
<Key :value="u">
u
</Key>
<Key :value="e">
e
</Key>
<div class="mx-1" />
<Key :value="u">
U
</Key>
<Key :value="s">
s
</Key>
<Key :value="e">
e
</Key>
</div>
<div class="mt-3 flex justify-center gap-3">
<Key :value="escape">
Escape
</Key>
<Key :value="shift">
Shift
</Key>
<Key :value="control">
Control
</Key>
<Key :value="tab">
Tab
</Key>
</div>
<div class="mt-3 flex justify-center gap-3">
<Key :value="v_u_e">
Vue
</Key>
<Key :value="u_s_e">
Use
</Key>
</div>
<div class="mt-3 flex flex-col items-center gap-3">
<Key :value="i_Shift_Alt">
Shift + Alt + I
</Key>
<Key :value="a_Shift_Alt">
Shift + Alt + A
</Key>
<Key :value="n_Shift_Alt">
Shift + Alt + N
</Key>
</div>
<div class="mt-4 text-center">
<div>Keys Pressed</div>
<div class="mt-2 min-h-1.5em flex justify-center space-x-1">
<code
v-for="key in keys"
:key="key"
class="font-mono"
>
{{ key }}
</code>
</div>
</div>
</div>
<img
class="m-auto h-38 transform py-8 transition duration-500"
:class="{ 'opacity-0': !u_s_e, 'rotate-180': shift }"
>
</div>
</template>
<route lang="yaml">
meta:
layout: settings
</route> -->
@@ -0,0 +1,386 @@
<script setup lang="ts">
// import type { AiriTamagotchiEvents, Point } from '../composables/tauri'
import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
import { useLive2d } from '@proj-airi/stage-ui/stores/live2d'
import { useMcpStore } from '@proj-airi/stage-ui/stores/mcp'
// import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
// import { connectServer } from '@proj-airi/tauri-plugin-mcp'
// import { watchThrottled } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import ResourceStatusIsland from '../components/Widgets/ResourceStatusIsland/index.vue'
// import { commands as passThroughCommands } from '../bindings/tauri-plugins/window-pass-through-on-hover'
// import { commands } from '../bindings/tauri-plugins/window-router-link'
// import { useTauriCore, useTauriEvent, useTauriWindow } from '../composables/tauri'
// import { useTauriGlobalShortcuts } from '../composables/tauri-global-shortcuts'
// import { useRdevMouse } from '../composables/use-rdev-mouse'
// import { useResourcesStore } from '../stores/resources'
import { useWindowStore } from '../stores/window'
import { useWindowControlStore } from '../stores/window-controls'
import { WindowControlMode } from '../types/window-controls'
export interface Point {
x: number
y: number
}
// const onboardingStore = useOnboardingStore()
// const { shouldShowSetup } = storeToRefs(onboardingStore)
// useTauriGlobalShortcuts()
const windowControlStore = useWindowControlStore()
// const resourcesStore = useResourcesStore()
const mcpStore = useMcpStore()
// const { getPosition } = useTauriWindow()
// const { mouseX, mouseY } = useRdevMouse()
// const { listen } = useTauriEvent<AiriTamagotchiEvents>()
// const { invoke } = useTauriCore()
const { connected, serverCmd, serverArgs } = storeToRefs(mcpStore)
const { scale, positionInPercentageString } = storeToRefs(useLive2d())
const { centerPos, live2dLookAtX, live2dLookAtY } = storeToRefs(useWindowStore())
const live2dFocusAt = ref<Point>(centerPos.value)
const widgetStageRef = ref<{ canvasElement: () => HTMLCanvasElement }>()
const resourceStatusIslandRef = ref<InstanceType<typeof ResourceStatusIsland>>()
// const buttonsContainerRef = ref<HTMLDivElement>()
// const windowX = ref(0)
// const windowY = ref(0)
const isClickThrough = ref(false)
// const isPassingThrough = ref(false)
// const isOverUI = ref(false)
const isFirstTime = ref(true)
// watch(shouldShowSetup, () => {
// if (shouldShowSetup.value) {
// commands.go('/onboarding', 'onboarding')
// }
// }, { immediate: true })
// watchThrottled([mouseX, mouseY], async ([x, y]) => {
// const canvas = widgetStageRef.value?.canvasElement()
// if (!canvas) {
// return
// }
// isFirstTime.value = false
// if (windowControlStore.controlMode === WindowControlMode.RESIZE || windowControlStore.controlMode === WindowControlMode.MOVE) {
// if (isPassingThrough.value) {
// passThroughCommands.stopPassThrough()
// isPassingThrough.value = false
// }
// return
// }
// const relativeX = x - windowX.value
// const relativeY = y - windowY.value
// const islandEl = resourceStatusIslandRef.value?.$el as HTMLElement
// const buttonsEl = buttonsContainerRef.value
// let isOverUIElements = false
// if (!windowControlStore.isIgnoringMouseEvent) {
// if (islandEl) {
// const rect = islandEl.getBoundingClientRect()
// if (relativeX >= rect.left && relativeX <= rect.right && relativeY >= rect.top && relativeY <= rect.bottom) {
// isOverUIElements = true
// }
// }
// if (!isOverUIElements && buttonsEl) {
// const rect = buttonsEl.getBoundingClientRect()
// if (relativeX >= rect.left && relativeX <= rect.right && relativeY >= rect.top && relativeY <= rect.bottom) {
// isOverUIElements = true
// }
// }
// }
// isOverUI.value = isOverUIElements
// if (isOverUI.value) {
// isClickThrough.value = false
// if (isPassingThrough.value) {
// passThroughCommands.stopPassThrough()
// isPassingThrough.value = false
// }
// return
// }
// let isTransparent = false
// if (
// relativeX >= 0
// && relativeX < canvas.clientWidth
// && relativeY >= 0
// && relativeY < canvas.clientHeight
// ) {
// const gl = canvas.getContext('webgl2') || canvas.getContext('webgl')
// if (gl) {
// const pixelX = relativeX * (gl.drawingBufferWidth / canvas.clientWidth)
// const pixelY
// = gl.drawingBufferHeight
// - relativeY * (gl.drawingBufferHeight / canvas.clientHeight)
// const data = new Uint8Array(4)
// gl.readPixels(
// Math.floor(pixelX),
// Math.floor(pixelY),
// 1,
// 1,
// gl.RGBA,
// gl.UNSIGNED_BYTE,
// data,
// )
// isTransparent = data[3] < 100 // Use a small threshold for anti-aliasing
// }
// }
// else {
// isTransparent = true
// }
// isClickThrough.value = isTransparent
// if (windowControlStore.isIgnoringMouseEvent) {
// if (!isPassingThrough.value) {
// passThroughCommands.startPassThrough()
// isPassingThrough.value = true
// }
// return
// }
// if (isTransparent && !isPassingThrough.value) {
// passThroughCommands.startPassThrough()
// isPassingThrough.value = true
// }
// else if (!isTransparent && isPassingThrough.value) {
// passThroughCommands.stopPassThrough()
// isPassingThrough.value = false
// }
// }, { throttle: 33 })
watch([live2dLookAtX, live2dLookAtY], ([x, y]) => live2dFocusAt.value = { x, y }, { immediate: true })
const modeIndicatorClass = computed(() => {
switch (windowControlStore.controlMode) {
case WindowControlMode.MOVE:
return 'cursor-move'
case WindowControlMode.RESIZE:
return 'cursor-se-resize'
case WindowControlMode.DEBUG:
return 'debug-mode'
default:
return ''
}
})
const unListenFuncs: (() => void)[] = []
// function openSettings() {
// invoke('open_settings_window')
// }
// function openChat() {
// invoke('open_chat_window')
// }
// async function setupVADModelLoadingProgressListener() {
// // VAD
// unListenFuncs.push(await listen('tauri-plugins:tauri-plugin-ipc-audio-vad-ort:load-model-silero-vad-progress', (event) => {
// const [_, filename, progress, totalSize, currentSize] = event.payload
// resourcesStore.updateResourceProgress('hearing', 'vad', { filename, progress, totalSize, currentSize })
// }))
// }
// async function setupVADModel() {
// await setupVADModelLoadingProgressListener()
// invoke('plugin:ipc-audio-vad-ort|load_ort_model_silero_vad')
// }
// async function setupWhisperModelLoadingProgressListener() {
// // Whisper
// unListenFuncs.push(await listen('tauri-plugins:tauri-plugin-ipc-audio-transcription-ort:load-model-whisper-progress', (event) => {
// const [_, filename, progress, totalSize, currentSize] = event.payload
// resourcesStore.updateResourceProgress('hearing', 'whisper', { filename, progress, totalSize, currentSize })
// }))
// }
// async function setupWhisperModel() {
// await setupWhisperModelLoadingProgressListener()
// invoke('plugin:ipc-audio-transcription-ort|load_ort_model_whisper', { modelType: 'medium' })
// }
onMounted(async () => {
// const pos = await getPosition()
// if (pos) {
// windowX.value = pos.x
// windowY.value = pos.y
// }
// unListenFuncs.push(await listen('tauri://move', (event) => {
// windowX.value = event.payload.x
// windowY.value = event.payload.y
// }))
// await setupVADModel()
// await setupWhisperModel()
if (connected.value)
return
if (!serverCmd.value || !serverArgs.value)
return
try {
// await connectServer(serverCmd.value, serverArgs.value.split(' '))
connected.value = true
}
catch (error) {
console.error(error)
}
})
onUnmounted(() => {
unListenFuncs.forEach(fn => fn?.())
unListenFuncs.length = 0
})
if (import.meta.hot) { // For better DX
import.meta.hot.on('vite:beforeUpdate', () => {
unListenFuncs.forEach(fn => fn?.())
unListenFuncs.length = 0
})
import.meta.hot.on('vite:afterUpdate', async () => {
// await setupVADModelLoadingProgressListener()
// await setupWhisperModelLoadingProgressListener()
})
}
</script>
<template>
<div
:class="[modeIndicatorClass, {
'op-0': windowControlStore.isIgnoringMouseEvent && !isClickThrough && !isFirstTime,
}]"
max-h="[100vh]"
max-w="[100vw]"
flex="~ col"
relative z-2 h-full overflow-hidden rounded-xl
transition="opacity duration-500 ease-in-out"
>
<div relative h-full w-full items-end gap-2 class="view">
<WidgetStage
ref="widgetStageRef"
h-full w-full flex-1
:focus-at="live2dFocusAt" :scale="scale"
:x-offset="positionInPercentageString.x"
:y-offset="positionInPercentageString.y" mb="<md:18"
/>
<ResourceStatusIsland ref="resourceStatusIslandRef" />
<!-- <div
ref="buttonsContainerRef"
absolute bottom-4 left-4 flex gap-1 op-0 transition="opacity duration-500"
:class="{
'pointer-events-none': isClickThrough && !isOverUI,
'show-on-hover': !windowControlStore.isIgnoringMouseEvent && (!isClickThrough || isOverUI),
}"
>
<div
border="solid 2 primary-100 "
text="lg primary-400 hover:primary-600 placeholder:primary-400 placeholder:hover:primary-600"
bg="primary-50 dark:primary-50" max-h="[10lh]" min-h="[1lh]"
flex cursor-pointer items-center justify-center rounded-l-xl p-4 transition-colors
@click="openChat"
>
<div i-solar:chat-line-bold-duotone />
</div>
<div
border="solid 2 primary-100 "
text="lg primary-400 hover:primary-600 placeholder:primary-400 placeholder:hover:primary-600"
bg="primary-50 dark:primary-50" max-h="[10lh]" min-h="[1lh]"
flex cursor-pointer items-center justify-center rounded-r-xl p-4 transition-colors
@click="openSettings"
>
<div i-solar:settings-bold-duotone />
</div>
</div> -->
</div>
<div v-if="windowControlStore.controlMode === WindowControlMode.DEBUG" class="debug-controls" />
</div>
<Transition
enter-active-class="transition-opacity duration-250"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-250"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="windowControlStore.controlMode === WindowControlMode.MOVE"
data-tauri-drag-region
class="absolute left-0 top-0 z-999 h-full w-full flex cursor-grab items-center justify-center overflow-hidden"
>
<div
class="absolute h-32 w-full flex items-center justify-center overflow-hidden rounded-xl"
bg="white/80 dark:neutral-950/80" backdrop-blur="md"
>
<div class="wall absolute top-0 h-8" />
<div data-tauri-drag-region class="absolute left-0 top-0 h-full w-full flex animate-flash animate-duration-5s animate-count-infinite select-none items-center justify-center text-1.5rem text-primary-400 font-normal">
DRAG HERE TO MOVE
</div>
<div data-tauri-drag-region class="wall absolute bottom-0 h-8" />
</div>
</div>
</Transition>
<Transition
enter-active-class="transition-opacity duration-250 ease-in-out"
enter-from-class="opacity-50"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-250 ease-in-out"
leave-from-class="opacity-100"
leave-to-class="opacity-50"
>
<div
v-if="windowControlStore.controlMode === WindowControlMode.RESIZE"
class="absolute left-0 top-0 z-999 h-full w-full"
>
<div h-full w-full animate-flash animate-duration-2.5s animate-count-infinite b-4 b-primary rounded-2xl />
</div>
</Transition>
</template>
<style scoped>
.view {
transition: opacity 0.5s ease-in-out;
.show-on-hover {
opacity: 1;
}
}
@keyframes wall-move {
0% {
transform: translateX(calc(var(--wall-width) * -2));
}
100% {
transform: translateX(calc(var(--wall-width) * 1));
}
}
.wall {
--at-apply: text-primary-300;
--wall-width: 8px;
animation: wall-move 1s linear infinite;
background-image: repeating-linear-gradient(
45deg,
currentColor,
currentColor var(--wall-width),
#ff00 var(--wall-width),
#ff00 calc(var(--wall-width) * 2)
);
width: calc(100% + 4 * var(--wall-width));
}
</style>
<route lang="yaml">
meta:
layout: stage
</route>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { OnboardingScreen } from '@proj-airi/stage-ui/components'
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
// 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>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<!-- Style from ui/InputFile component, may be centralized later -->
<div
relative
class="min-h-[120px] flex flex-col cursor-pointer items-center justify-center border-neutral-200 rounded-xl bg-white/60 p-6 dark:border-neutral-700 hover:border-primary-300 dark:bg-black/30 hover:bg-white/80 dark:hover:border-primary-700 dark:hover:bg-black/40"
border="solid 2"
transition="all duration-300"
cursor-pointer opacity-95
hover="scale-100 opacity-100 shadow-md dark:shadow-lg"
>
<div i-solar:add-square-line-duotone mb-4 text-5xl text="neutral-400 dark:neutral-500" />
<p font-medium text="neutral-600 dark:neutral-300">
{{ t('settings.pages.card.create_card') }}
</p>
</div>
</template>
@@ -0,0 +1,281 @@
<script setup lang="ts">
import type { Card } from '@proj-airi/ccc'
import kebabcase from '@stdlib/string-base-kebabcase'
import { Button } from '@proj-airi/stage-ui/components'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
import { FieldInput, FieldValues } from '@proj-airi/ui'
import {
DialogContent,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
} from 'reka-ui'
import { computed, ref, toRaw } from 'vue'
import { useI18n } from 'vue-i18n'
interface Props {
modelValue: boolean
}
defineProps<Props>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const modelValue = defineModel<boolean>()
const { t } = useI18n()
const cardStore = useAiriCardStore()
// Tab type definition
interface Tab {
id: string
label: string
icon: string
}
// Active tab ID state
const activeTabId = ref('')
// Tabs for card details
const tabs: Tab[] = [
{ id: 'identity', label: t('settings.pages.card.creation.identity'), icon: 'i-solar:emoji-funny-square-bold-duotone' },
{ id: 'behavior', label: t('settings.pages.card.creation.behavior'), icon: 'i-solar:chat-round-line-bold-duotone' },
{ id: 'settings', label: t('settings.pages.card.creation.settings'), icon: 'i-solar:settings-bold-duotone' },
]
// Active tab state - set to first available tab by default
const activeTab = computed({
get: () => {
// If current active tab is not in available tabs, reset to first tab
if (!tabs.find(tab => tab.id === activeTabId.value))
return tabs[0]?.id || ''
return activeTabId.value
},
set: (value: string) => {
activeTabId.value = value
},
})
// Check for errors, and save built Cards :
const showError = ref<boolean>(false)
const errorMessage = ref<string>('')
function saveCard(card: Card): boolean {
// Before saving, let's validate what the user entered :
const rawCard: Card = toRaw(card)
if (!(rawCard.name!.length > 0)) { // ! is used, since a default value is provided, and computed values passed to v-model should never be undefined
// No name
showError.value = true
errorMessage.value = t('settings.pages.card.creation.errors.name')
return false
}
else if (!/^(?:\d+\.)+\d+$/.test(rawCard.version)) {
// Invalid version
showError.value = true
errorMessage.value = t('settings.pages.card.creation.errors.version')
return false
}
else if (!(rawCard.description!.length > 0)) {
// No description
showError.value = true
errorMessage.value = t('settings.pages.card.creation.errors.description')
return false
}
else if (!(rawCard.personality!.length > 0)) {
// No personality
showError.value = true
errorMessage.value = t('settings.pages.card.creation.errors.personality')
return false
}
else if (!(rawCard.scenario!.length > 0)) {
// No Scenario
showError.value = true
errorMessage.value = t('settings.pages.card.creation.errors.scenario')
return false
}
else if (!(rawCard.systemPrompt!.length > 0)) {
// No sys prompt
showError.value = true
errorMessage.value = t('settings.pages.card.creation.errors.systemprompt')
return false
}
else if (!(rawCard.postHistoryInstructions!.length > 0)) {
// No post history prompt
showError.value = true
errorMessage.value = t('settings.pages.card.creation.errors.posthistoryinstructions')
return false
}
showError.value = false
cardStore.addCard(rawCard)
modelValue.value = false // Close this
return true
}
// Cards data holders :
const card = ref<Card>({
name: t('settings.pages.card.creation.defaults.name'),
nickname: undefined,
version: '1.0',
description: '',
notes: undefined,
personality: t('settings.pages.card.creation.defaults.personality'),
scenario: t('settings.pages.card.creation.defaults.scenario'),
systemPrompt: t('settings.pages.card.creation.defaults.systemprompt'),
postHistoryInstructions: t('settings.pages.card.creation.defaults.posthistoryinstructions'),
greetings: [],
messageExample: [],
})
function makeComputed<T extends keyof Card>(
/*
Function used to generate Computed values, with an optional sanitize function
*/
key: T,
transform?: (input: string) => string,
) {
return computed({
get: () => {
return card.value[key] ?? ''
},
set: (val: string) => { // Set,
const input = val.trim() // We first trim the value
card.value[key] = (input.length > 0
? (transform ? transform(input) : input) // then potentially transform it
: '') as Card[T]// or default to empty string value if nothing was given
},
})
}
const cardName = makeComputed('name', input => kebabcase(input))
const cardNickname = makeComputed('nickname')
const cardDescription = makeComputed('description')
const cardNotes = makeComputed('notes')
const cardPersonality = makeComputed('personality')
const cardScenario = makeComputed('scenario')
const cardGreetings = computed({
get: () => card.value.greetings ?? [],
set: (val: string[]) => {
card.value.greetings = val || []
},
})
const cardVersion = makeComputed('version')
const cardSystemPrompt = makeComputed('systemPrompt')
const cardPostHistoryInstructions = makeComputed('postHistoryInstructions')
</script>
<template>
<DialogRoot :open="modelValue" @update:open="emit('update:modelValue', $event)">
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-100 bg-black/50 backdrop-blur-sm data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn" />
<DialogContent class="fixed left-1/2 top-1/2 z-100 m-0 max-h-[90vh] max-w-6xl w-[92vw] flex flex-col overflow-auto border border-neutral-200 rounded-xl bg-white p-5 shadow-xl 2xl:w-[60vw] lg:w-[80vw] md:w-[85vw] xl:w-[70vw] -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow dark:border-neutral-700 dark:bg-neutral-800 sm:p-6">
<div class="w-full flex flex-col gap-5">
<DialogTitle text-2xl font-normal class="from-primary-500 to-primary-400 bg-gradient-to-r bg-clip-text text-transparent">
{{ t("settings.pages.card.create_card") }}
</DialogTitle>
<!-- Dialog tabs -->
<div class="mt-4">
<div class="border-b border-neutral-200 dark:border-neutral-700">
<div class="flex justify-center -mb-px sm:justify-start space-x-1">
<button
v-for="tab in tabs"
:key="tab.id"
class="px-4 py-2 text-sm font-medium"
:class="[
activeTab === tab.id
? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-500 dark:border-primary-400'
: 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300',
]"
@click="activeTab = tab.id"
>
<div class="flex items-center gap-1">
<div :class="tab.icon" />
{{ tab.label }}
</div>
</button>
</div>
</div>
</div>
<!-- Error div -->
<div v-if="showError" class="w-full rounded-xl bg-red900">
<p class="w-full p-4">
{{ errorMessage }}
</p>
</div>
<!-- Actual content -->
<!-- Identity details -->
<div v-if="activeTab === 'identity'" class="tab-content ml-auto mr-auto w-95%">
<p class="mb-3">
{{ t('settings.pages.card.creation.fields_info.subtitle') }}
</p>
<div class="input-list ml-auto mr-auto w-90% flex flex-row flex-wrap justify-center gap-8">
<FieldInput v-model="cardName" :label="t('settings.pages.card.creation.name')" :description="t('settings.pages.card.creation.fields_info.name')" :required="true" />
<FieldInput v-model="cardNickname" :label="t('settings.pages.card.creation.nickname')" :description="t('settings.pages.card.creation.fields_info.nickname')" />
<FieldInput v-model="cardDescription" :label="t('settings.pages.card.creation.description')" :single-line="false" :required="true" :description="t('settings.pages.card.creation.fields_info.description')" />
<FieldInput v-model="cardNotes" :label="t('settings.pages.card.creator_notes')" :single-line="false" :description="t('settings.pages.card.creation.fields_info.notes')" />
</div>
</div>
<!-- Behavior -->
<div v-else-if="activeTab === 'behavior'" class="tab-content ml-auto mr-auto w-95%">
<div class="input-list ml-auto mr-auto w-90% flex flex-row flex-wrap justify-center gap-8">
<FieldInput v-model="cardPersonality" :label="t('settings.pages.card.personality')" :single-line="false" :required="true" :description="t('settings.pages.card.creation.fields_info.personality')" />
<FieldInput v-model="cardScenario" :label="t('settings.pages.card.scenario')" :single-line="false" :required="true" :description="t('settings.pages.card.creation.fields_info.scenario')" />
<FieldValues v-model="cardGreetings" :label="t('settings.pages.card.creation.greetings')" :description="t('settings.pages.card.creation.fields_info.greetings')" />
</div>
</div>
<!-- Settings -->
<div v-else-if="activeTab === 'settings'" class="tab-content ml-auto mr-auto w-95%">
<div class="input-list ml-auto mr-auto w-90% flex flex-row flex-wrap justify-center gap-8">
<FieldInput v-model="cardSystemPrompt" :label="t('settings.pages.card.systemprompt')" :single-line="false" :required="true" :description="t('settings.pages.card.creation.fields_info.systemprompt')" />
<FieldInput v-model="cardPostHistoryInstructions" :label="t('settings.pages.card.posthistoryinstructions')" :single-line="false" :required="true" :description="t('settings.pages.card.creation.fields_info.posthistoryinstructions')" />
<FieldInput v-model="cardVersion" :label="t('settings.pages.card.creation.version')" :required="true" :description="t('settings.pages.card.creation.fields_info.version')" />
</div>
</div>
<div class="ml-auto mr-1 flex flex-row gap-2">
<Button
variant="secondary"
icon="i-solar:undo-left-bold-duotone"
:label="t('settings.pages.card.cancel')"
:disabled="false"
@click="modelValue = false"
/>
<Button
variant="primary"
icon="i-solar:check-circle-bold-duotone"
:label="t('settings.pages.card.creation.create')"
:disabled="false"
@click="saveCard(card)"
/>
</div>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
<style scoped>
.input-list > * {
min-width: 45%;
}
@media (max-width: 641px) {
.input-list * {
min-width: unset;
width: 100%;
}
}
</style>
@@ -0,0 +1,352 @@
<script setup lang="ts">
import type { AiriCard } from '@proj-airi/stage-ui/stores/modules/airi-card'
import { Button } from '@proj-airi/stage-ui/components'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
import { storeToRefs } from 'pinia'
import {
DialogContent,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
} from 'reka-ui'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import DeleteCardDialog from './DeleteCardDialog.vue'
interface Props {
modelValue: boolean
cardId: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const { t } = useI18n()
const cardStore = useAiriCardStore()
const { removeCard } = cardStore
const { activeCardId } = storeToRefs(cardStore)
// Get selected card data
const selectedCard = computed<AiriCard | undefined>(() => {
if (!props.cardId)
return undefined
return cardStore.getCard(props.cardId)
})
// Get module settings
const moduleSettings = computed(() => {
if (!selectedCard.value || !selectedCard.value.extensions?.airi?.modules) {
return {
consciousness: '',
speech: '',
voice: '',
}
}
const airiExt = selectedCard.value.extensions.airi.modules
return {
consciousness: airiExt.consciousness?.model || '',
speech: airiExt.speech?.model || '',
voice: airiExt.speech?.voice_id || '',
}
})
// Get character settings
const characterSettings = computed(() => {
if (!selectedCard.value)
return {}
return {
personality: selectedCard.value.personality,
scenario: selectedCard.value.scenario,
systemPrompt: selectedCard.value.systemPrompt,
postHistoryInstructions: selectedCard.value.postHistoryInstructions,
}
})
// Check if card is active
const isActive = computed(() => props.cardId === activeCardId.value)
// Animation control for card activation
const isActivating = ref(false)
function handleActivate() {
isActivating.value = true
setTimeout(() => {
activeCardId.value = props.cardId
isActivating.value = false
}, 300)
}
function hightlightTagToHtml(text: string) {
return text?.replace(/\{\{(.*?)\}\}/g, '<span class="bg-primary-500/20 inline-block">{{ $1 }}</span>').trim()
}
// Delete confirmation
const showDeleteConfirm = ref(false)
function handleDeleteConfirm() {
if (selectedCard.value) {
removeCard(props.cardId)
emit('update:modelValue', false)
}
showDeleteConfirm.value = false
}
// Tab type definition
interface Tab {
id: string
label: string
icon: string
}
// Active tab ID state
const activeTabId = ref('')
// Tabs for card details
const tabs = computed<Tab[]>(() => {
const availableTabs: Tab[] = []
// Description tab - always show if there's description
if (selectedCard.value?.description) {
availableTabs.push({
id: 'description',
label: t('settings.pages.card.description_label'),
icon: 'i-solar:document-text-linear',
})
}
// Notes tab - only show if there are creator notes
if (selectedCard.value?.notes) {
availableTabs.push({
id: 'notes',
label: t('settings.pages.card.creator_notes'),
icon: 'i-solar:notes-linear',
})
}
// Character tab - only show if there are character settings
if (Object.values(characterSettings.value).some(value => !!value)) {
availableTabs.push({
id: 'character',
label: t('settings.pages.card.character'),
icon: 'i-solar:user-rounded-linear',
})
}
// Modules tab - always show
availableTabs.push({
id: 'modules',
label: t('settings.pages.card.modules'),
icon: 'i-solar:tuning-square-linear',
})
return availableTabs
})
// Active tab state - set to first available tab by default
const activeTab = computed({
get: () => {
// If current active tab is not in available tabs, reset to first tab
if (!tabs.value.find(tab => tab.id === activeTabId.value))
return tabs.value[0]?.id || ''
return activeTabId.value
},
set: (value: string) => {
activeTabId.value = value
},
})
</script>
<template>
<DialogRoot :open="modelValue" @update:open="emit('update:modelValue', $event)">
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-100 bg-black/50 backdrop-blur-sm data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn" />
<DialogContent class="fixed left-1/2 top-1/2 z-100 m-0 max-h-[90vh] max-w-6xl w-[92vw] flex flex-col overflow-auto border border-neutral-200 rounded-xl bg-white p-5 shadow-xl 2xl:w-[60vw] lg:w-[80vw] md:w-[85vw] xl:w-[70vw] -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow dark:border-neutral-700 dark:bg-neutral-800 sm:p-6">
<div v-if="selectedCard" class="w-full flex flex-col gap-5">
<!-- Header with status indicator -->
<div flex="~ col" gap-3>
<div flex="~ row" items-center justify-between>
<div>
<div flex="~ row" items-center gap-2>
<DialogTitle text-2xl font-normal class="from-primary-500 to-primary-400 bg-gradient-to-r bg-clip-text text-transparent">
{{ selectedCard.name }}
</DialogTitle>
<div v-if="isActive" class="flex items-center gap-1 rounded-full bg-primary-100 px-2 py-0.5 text-xs text-primary-600 font-medium dark:bg-primary-900/40 dark:text-primary-400">
<div i-solar:check-circle-bold-duotone text-xs />
{{ t('settings.pages.card.active_badge') }}
</div>
</div>
<div mt-1 text-sm text-neutral-500 dark:text-neutral-400>
v{{ selectedCard.version }}
<template v-if="selectedCard.creator">
· {{ t('settings.pages.card.created_by') }} <span font-medium>{{ selectedCard.creator }}</span>
</template>
</div>
</div>
<!-- Action buttons -->
<div flex="~ row" gap-2>
<!-- Activation button -->
<Button
variant="primary"
:icon="isActive ? 'i-solar:check-circle-bold-duotone' : 'i-solar:play-circle-broken'"
:label="isActive ? t('settings.pages.card.active') : t('settings.pages.card.activate')"
:disabled="isActive"
:class="{ 'animate-pulse': isActivating }"
@click="handleActivate"
/>
</div>
</div>
<!-- Card content tabs -->
<div class="mt-4">
<div class="border-b border-neutral-200 dark:border-neutral-700">
<div class="flex justify-center -mb-px sm:justify-start space-x-1">
<button
v-for="tab in tabs"
:key="tab.id"
class="px-4 py-2 text-sm font-medium"
:class="[
activeTab === tab.id
? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-500 dark:border-primary-400'
: 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300',
]"
@click="activeTab = tab.id"
>
<div class="flex items-center gap-1">
<div :class="tab.icon" />
{{ tab.label }}
</div>
</button>
</div>
</div>
</div>
<!-- Creator notes -->
<div v-if="activeTab === 'notes' && selectedCard.notes">
<div
bg="white/60 dark:black/30"
border="~ neutral-200/50 dark:neutral-700/30"
max-h-60 overflow-auto whitespace-pre-line rounded-lg p-4 text-neutral-700 sm:max-h-80 dark:text-neutral-300 transition="all duration-200"
hover="bg-white/80 dark:bg-black/40"
v-html="hightlightTagToHtml(selectedCard.notes)"
/>
</div>
<!-- Description section -->
<div v-if="activeTab === 'description' && selectedCard.description">
<div
bg="white/60 dark:black/30"
max-h-60 overflow-auto whitespace-pre-line rounded-lg p-4 sm:max-h-80
text="neutral-600 dark:neutral-300"
border="~ neutral-200/50 dark:neutral-700/30"
v-html="hightlightTagToHtml(selectedCard.description)"
/>
</div>
<!-- Character -->
<div v-if="activeTab === 'character' && Object.values(characterSettings).some(value => !!value)">
<div flex="~ col" max-h-60 gap-4 overflow-auto pr-1 sm:max-h-80>
<template v-for="(value, key) in characterSettings" :key="key">
<div v-if="value" flex="~ col" gap-2>
<h2 text-lg text-neutral-500 font-medium dark:text-neutral-400>
{{ t(`settings.pages.card.${key.toLowerCase()}`) }}
</h2>
<div
bg="white/60 dark:black/30"
border="~ neutral-200/50 dark:neutral-700/30"
transition="all duration-200"
hover="bg-white/80 dark:bg-black/40"
max-h-none overflow-auto whitespace-pre-line rounded-lg p-3 text-neutral-700 dark:text-neutral-300
v-html="hightlightTagToHtml(value)"
/>
</div>
</template>
</div>
</div>
<!-- Modules -->
<div v-if="activeTab === 'modules'">
<div grid="~ cols-1 sm:cols-3" gap-4>
<div
flex="~ col"
bg="white/60 dark:black/30"
gap-1 rounded-lg p-3
border="~ neutral-200/50 dark:neutral-700/30"
transition="all duration-200"
hover="bg-white/80 dark:bg-black/40"
>
<span flex="~ row" items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400>
<div i-lucide:ghost />
{{ t('settings.pages.card.consciousness.model') }}
</span>
<div truncate font-medium>
{{ moduleSettings.consciousness ?? 'default' }}
</div>
</div>
<div
flex="~ col"
bg="white/60 dark:black/30"
gap-2 rounded-lg p-3
border="~ neutral-200/50 dark:neutral-700/30"
transition="all duration-200"
hover="bg-white/80 dark:bg-black/40"
>
<span flex="~ row" items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400>
<div i-lucide:mic />
{{ t('settings.pages.card.speech.model') }}
</span>
<div truncate font-medium>
{{ moduleSettings.speech ?? 'default' }}
</div>
</div>
<div
flex="~ col"
bg="white/60 dark:black/30"
gap-2 rounded-lg p-3
border="~ neutral-200/50 dark:neutral-700/30"
transition="all duration-200"
hover="bg-white/80 dark:bg-black/40"
>
<span flex="~ row" items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400>
<div i-lucide:music />
{{ t('settings.pages.card.speech.voice') }}
</span>
<div truncate font-medium>
{{ moduleSettings.voice ?? 'default' }}
</div>
</div>
</div>
</div>
</div>
</div>
<div
v-else
bg="neutral-50/50 dark:neutral-900/50"
rounded-xl p-8 text-center
border="~ neutral-200/50 dark:neutral-700/30"
shadow="sm"
>
<div i-solar:card-search-broken mx-auto mb-3 text-6xl text-neutral-400 />
{{ t('settings.pages.card.card_not_found') }}
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
<!-- Delete confirmation dialog -->
<DeleteCardDialog
v-model="showDeleteConfirm"
:card-name="selectedCard?.name"
@confirm="handleDeleteConfirm"
@cancel="showDeleteConfirm = false"
/>
</template>
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { CursorFloating } from '@proj-airi/stage-ui/components'
interface Props {
id: string
name: string
description?: string
isActive: boolean
isSelected: boolean
version: string
consciousnessModel: string
voiceModel: string
}
defineProps<Props>()
const emit = defineEmits<{
(e: 'select'): void
(e: 'activate'): void
(e: 'delete'): void
}>()
</script>
<template>
<CursorFloating
relative min-h-120px flex="~ col" cursor-pointer overflow-hidden rounded-xl
:class="[
isSelected
? 'border-2 border-primary-400 dark:border-primary-600'
: 'border-2 border-neutral-100 dark:border-neutral-800/25',
]"
bg="neutral-200/50 dark:neutral-800/50"
drop-shadow="none hover:[0px_4px_4px_rgba(220,220,220,0.4)] active:[0px_0px_0px_rgba(220,220,220,0.25)] dark:hover:none"
transition="all ease-in-out duration-400"
before="content-empty absolute inset-0 z-0 w-25% h-full transition-all duration-400 ease-in-out bg-gradient-to-r from-primary-500/0 to-primary-500/0 dark:from-primary-400/0 dark:to-primary-400/0 mask-image-[linear-gradient(120deg,white_100%)] opacity-0"
hover="before:(opacity-100 bg-gradient-to-r from-primary-500/20 via-primary-500/10 to-transparent dark:from-primary-400/20 dark:via-primary-400/10 dark:to-transparent)"
@click="emit('select')"
>
<!-- Card content -->
<div
relative flex="~ col 1" justify-between gap-3 overflow-hidden rounded-lg bg="white dark:neutral-900" p-5
transition="all ease-in-out duration-400"
after="content-empty absolute inset-0 z--2 w-full h-full bg-dotted-[neutral-200/80] bg-size-10px mask-image-[linear-gradient(165deg,white_30%,transparent_50%)] transition-all duration-400 ease-in-out"
hover="after:bg-dotted-[primary-300/50] dark:after:bg-dotted-[primary-200/20] text-primary-600/80 dark:text-primary-300/80"
>
<!-- Card header (name and badge) -->
<div z-1 flex items-start justify-between gap-2>
<h3 flex-1 truncate text-lg font-normal>
{{ name }}
</h3>
<div v-if="isActive" shrink-0 rounded-md p-1 bg="primary-100 dark:primary-900/40" text="primary-600 dark:primary-400">
<div i-solar:check-circle-bold-duotone text-sm />
</div>
</div>
<!-- Card description -->
<p v-if="description" line-clamp-3 min-h-40px flex-1 text-sm text="neutral-500 dark:neutral-400">
{{ description }}
</p>
<!-- Card stats -->
<div z-1 flex items-center justify-between text-xs text="neutral-500 dark:neutral-400">
<div>v{{ version }}</div>
<div flex items-center gap-1.5>
<div flex items-center gap-0.5>
<div i-lucide:ghost text-xs />
<span>{{ consciousnessModel }}</span>
</div>
<div flex items-center gap-0.5>
<div i-lucide:mic text-xs />
<span>{{ voiceModel }}</span>
</div>
</div>
</div>
</div>
<!-- Card actions -->
<div flex items-center justify-end px-2 py-1.5>
<button
rounded-lg p-1.5 transition-colors hover="bg-neutral-200 dark:bg-neutral-700/50"
:disabled="isActive"
@click.stop="emit('activate')"
>
<div
:class="[
isActive
? 'i-solar:check-circle-bold-duotone text-primary-500 dark:text-primary-400'
: 'i-solar:play-circle-broken text-neutral-500 dark:text-neutral-400',
]"
/>
</button>
<button
v-if="id !== 'default'"
rounded-lg p-1.5 transition-colors hover="bg-neutral-200 dark:bg-neutral-700/50"
@click.stop="emit('delete')"
>
<div i-solar:trash-bin-trash-linear text="neutral-500 dark:neutral-400" />
</button>
</div>
</CursorFloating>
</template>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { Button } from '@proj-airi/stage-ui/components'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
interface Props {
modelValue: boolean
cardName?: string
}
defineProps<Props>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
(e: 'cancel'): void
}>()
const { t } = useI18n()
function handleCancel() {
emit('update:modelValue', false)
emit('cancel')
}
function handleConfirm() {
emit('update:modelValue', false)
emit('confirm')
}
</script>
<template>
<AlertDialogRoot :open="modelValue" @update:open="emit('update:modelValue', $event)">
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-100 bg-black/50 data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn" />
<AlertDialogContent
class="fixed left-1/2 top-1/2 z-100 max-w-md w-full border border-neutral-200 rounded-xl bg-white p-6 shadow-xl -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow dark:border-neutral-700 dark:bg-neutral-800"
>
<AlertDialogTitle class="mb-4 text-xl font-normal">
{{ t('settings.pages.card.delete_card') }}
</AlertDialogTitle>
<AlertDialogDescription class="mb-6">
{{ t('settings.pages.card.delete_confirmation') }} <b>"{{ cardName || '' }}"</b>
</AlertDialogDescription>
<div class="flex flex-row justify-end gap-3">
<AlertDialogCancel as-child>
<Button
variant="secondary"
:label="t('settings.pages.card.cancel')"
@click="handleCancel"
/>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button
variant="danger"
:label="t('settings.pages.card.delete')"
@click="handleConfirm"
/>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</template>
@@ -0,0 +1,313 @@
<script setup lang="ts">
import type { ccv3 } from '@proj-airi/ccc'
import { Alert } from '@proj-airi/stage-ui/components'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
import { InputFile } from '@proj-airi/ui'
import { Select } from '@proj-airi/ui/components/form'
import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import CardCreate from './components/CardCreate.vue'
import CardCreationDialog from './components/CardCreationDialog.vue'
import CardDetailDialog from './components/CardDetailDialog.vue'
import CardListItem from './components/CardListItem.vue'
import DeleteCardDialog from './components/DeleteCardDialog.vue'
const { t } = useI18n()
const cardStore = useAiriCardStore()
const { addCard, removeCard } = cardStore
const { cards, activeCardId } = storeToRefs(cardStore)
// Currently selected card ID (different from active card ID)
const selectedCardId = ref<string>('')
// Dialog state
const isCardDialogOpen = ref(false)
const isCardCreationDialogOpen = ref(false)
// Search query
const searchQuery = ref('')
// Sort option
const sortOption = ref('nameAsc')
const inputFiles = ref<File[]>([])
// Card list data structure
interface CardItem {
id: string
name: string
description?: string
deprecated?: boolean
customizable?: boolean
}
watch(inputFiles, async (newFiles) => {
const file = newFiles[0]
if (!file)
return
try {
const content = await file.text()
const cardJSON = JSON.parse(content) as ccv3.CharacterCardV3
// Add card and select it
selectedCardId.value = addCard(cardJSON)
isCardDialogOpen.value = true
}
catch (error) {
console.error('Error processing card file:', error)
}
})
// Transform cards Map to array for display
const cardsArray = computed<CardItem[]>(() =>
Array.from(cards.value.entries()).map(([id, card]) => ({
id,
name: card.name,
description: card.description,
})),
)
// Filtered cards based on search query
const filteredCards = computed<CardItem[]>(() => {
if (!searchQuery.value)
return cardsArray.value
const query = searchQuery.value.toLowerCase()
return cardsArray.value.filter(item =>
item.name.toLowerCase().includes(query)
|| (item.description && item.description.toLowerCase().includes(query)),
)
})
// Sorted filtered cards based on sort option
const sortedFilteredCards = computed<CardItem[]>(() => {
// Create a new array to avoid mutating the source
const sorted = [...filteredCards.value]
if (sortOption.value === 'nameAsc')
return sorted.sort((a, b) => a.name.localeCompare(b.name))
else if (sortOption.value === 'nameDesc')
return sorted.sort((a, b) => b.name.localeCompare(a.name))
else if (sortOption.value === 'recent')
return sorted.sort((a, b) => b.id.localeCompare(a.id))
else
return sorted
})
// Delete confirmation
const showDeleteConfirm = ref(false)
const cardToDelete = ref<string | null>(null)
function handleDeleteConfirm() {
if (cardToDelete.value) {
removeCard(cardToDelete.value)
cardToDelete.value = null
showDeleteConfirm.value = false
}
}
// Card deletion confirmation
function confirmDelete(id: string) {
cardToDelete.value = id
showDeleteConfirm.value = true
}
function handleSelectCard(cardId: string) {
selectedCardId.value = cardId
isCardDialogOpen.value = true
}
function handleCardCreationDialog() {
isCardCreationDialogOpen.value = true
}
// Card activation
function activateCard(id: string) {
activeCardId.value = id
}
// Card version number
function getVersionNumber(id: string) {
const card = cards.value.get(id)
return card?.version || '1.0.0'
}
// Card module short name
function getModuleShortName(id: string, module: 'consciousness' | 'voice') {
const card = cards.value.get(id)
if (!card || !card.extensions?.airi?.modules)
return 'default'
const airiExt = card.extensions.airi.modules
if (module === 'consciousness') {
return airiExt.consciousness?.model ? airiExt.consciousness.model.split('-').pop() || 'default' : 'default'
}
else if (module === 'voice') {
return airiExt.speech?.voice_id || 'default'
}
return 'default'
}
</script>
<template>
<div rounded-xl p-4 flex="~ col gap-4">
<!-- Toolbar with search and filters -->
<div flex="~ row" flex-wrap items-center justify-between gap-4>
<!-- Search bar -->
<div class="relative min-w-[200px] flex-1" inline-flex="~" w-full items-center>
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<div i-solar:magnifer-line-duotone class="text-neutral-500 dark:text-neutral-400" />
</div>
<input
v-model="searchQuery"
type="search"
class="w-full rounded-xl p-2.5 pl-10 text-sm outline-none"
border="focus:primary-100 dark:focus:primary-400/50 2 solid neutral-200 dark:neutral-800"
transition="all duration-200 ease-in-out"
bg="white dark:neutral-900"
:placeholder="t('settings.pages.card.search')"
>
</div>
<!-- Sort options -->
<div class="relative flex flex-row justify-start gap-2 lg:flex-col">
<div class="top-[-32px] whitespace-nowrap text-sm text-neutral-500 leading-10 lg:absolute dark:text-neutral-400">
{{ t('settings.pages.card.sort_by') }}:
</div>
<Select
v-model="sortOption"
:options="[
{ value: 'nameAsc', label: t('settings.pages.card.name_asc') },
{ value: 'nameDesc', label: t('settings.pages.card.name_desc') },
{ value: 'recent', label: t('settings.pages.card.recent') },
]"
placeholder="Select sort option"
class="min-w-[150px]"
/>
</div>
</div>
<!-- Masonry card layout -->
<div
class="mt-4"
:class="{ 'grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 grid-auto-rows-[minmax(min-content,max-content)] grid-auto-flow-dense sm:grid-cols-[repeat(auto-fill,minmax(240px,1fr))] sm:gap-5 md:grid-cols-[repeat(auto-fill,minmax(220px,1fr))] lg:grid-cols-[repeat(auto-fill,minmax(250px,1fr))]': cards.size > 0 }"
>
<!-- Upload card -->
<InputFile v-model="inputFiles" accept="*.json">
<template #default="{ isDragging }">
<template v-if="!isDragging">
<div flex flex-col items-center>
<div i-solar:upload-square-line-duotone mb-4 text-5xl text="neutral-400 dark:neutral-500" />
<p font-medium text="neutral-600 dark:neutral-300">
{{ t('settings.pages.card.upload') }}
</p>
<p text="neutral-500 dark:neutral-400" mt-2 text-sm>
{{ t('settings.pages.card.upload_desc') }}
</p>
</div>
</template>
<template v-else>
<div flex flex-col items-center>
<div i-solar:upload-minimalistic-bold class="mb-2 text-5xl text-primary-500 dark:text-primary-400" />
<p font-medium text="primary-600 dark:primary-300">
{{ t('settings.pages.card.drop_here') }}
</p>
</div>
</template>
</template>
</InputFile>
<!-- Create card -->
<CardCreate @click="handleCardCreationDialog" />
<!-- Card Items -->
<template v-if="cards.size > 0">
<CardListItem
v-for="item in sortedFilteredCards"
:id="item.id"
:key="item.id"
:name="item.name"
:description="item.description"
:is-active="item.id === activeCardId"
:is-selected="item.id === selectedCardId && isCardDialogOpen"
:version="getVersionNumber(item.id)"
:consciousness-model="getModuleShortName(item.id, 'consciousness')"
:voice-model="getModuleShortName(item.id, 'voice')"
@select="handleSelectCard(item.id)"
@activate="activateCard(item.id)"
@delete="confirmDelete(item.id)"
/>
</template>
<!-- No cards message -->
<div
v-if="cards.size === 0"
class="col-span-full rounded-xl p-8 text-center"
border="~ neutral-200/50 dark:neutral-700/30"
bg="neutral-50/50 dark:neutral-900/50"
>
<div i-solar:card-search-broken mx-auto mb-3 text-6xl text-neutral-400 />
<p>{{ t('settings.pages.card.no_cards') }}</p>
</div>
<!-- No search results -->
<Alert
v-if="searchQuery && sortedFilteredCards.length === 0"
type="warning"
class="col-span-full"
>
<template #title>
{{ t('settings.pages.card.no_results') }}
</template>
<template #content>
{{ t('settings.pages.card.try_different_search') }}
</template>
</Alert>
</div>
</div>
<!-- Delete confirmation dialog -->
<DeleteCardDialog
v-model="showDeleteConfirm"
:card-name="cardToDelete ? cardStore.getCard(cardToDelete)?.name : ''"
@confirm="handleDeleteConfirm"
@cancel="cardToDelete = null"
/>
<!-- Card detail dialog -->
<CardDetailDialog
v-model="isCardDialogOpen"
:card-id="selectedCardId"
/>
<!-- Card detail dialog -->
<CardCreationDialog
v-model="isCardCreationDialogOpen"
/>
<!-- Background decoration -->
<div
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-15rem)]" bottom-0 right--5 z--1
:initial="{ scale: 0.9, opacity: 0, x: 20 }"
:enter="{ scale: 1, opacity: 1, x: 0 }"
:duration="500"
size-60
flex items-center justify-center
>
<div text="60" i-solar:emoji-funny-square-bold-duotone />
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,161 @@
<script setup lang="ts">
import { IconItem } from '@proj-airi/stage-ui/components'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import IconAnimation from '../../components/IconAnimation.vue'
const router = useRouter()
const iconAnimationStarted = ref(false)
const iconAnimation = ref<InstanceType<typeof IconAnimation>>()
const resolveAnimation = ref<() => void>()
const { t } = useI18n()
const animationIcon = ref('')
const animationPosition = ref('')
const showAnimationComponent = ref(false)
const settingsStore = useSettings()
function handleAnimationEnded() {
resolveAnimation.value?.()
}
async function handleIconItemClick(event: MouseEvent, setting: typeof settings.value[0]) {
const target = event.currentTarget as HTMLElement
const iconElement = target.querySelector('.menu-icon-item-icon') as HTMLElement
if (!iconElement)
return
// get the position of the icon element
const rect = iconElement.getBoundingClientRect()
const position = `${rect.left}px, ${rect.top}px`
// set the icon and position
animationIcon.value = setting.icon
animationPosition.value = position
// show the animation component
showAnimationComponent.value = true
// wait for the DOM to update
await nextTick()
// start the animation
iconAnimationStarted.value = true
}
const removeBeforeEach = router.beforeEach(async (_, __, next) => {
if (!settingsStore.usePageSpecificTransitions || settingsStore.disableTransitions) {
next()
return
}
await new Promise<void>((resolve) => {
resolveAnimation.value = resolve
})
removeBeforeEach()
next()
})
const settings = computed(() => [
{
title: t('settings.pages.card.title'),
description: t('settings.pages.card.description'),
icon: 'i-solar:emoji-funny-square-bold-duotone',
to: '/settings/airi-card',
},
{
title: t('settings.pages.modules.title'),
description: t('settings.pages.modules.description'),
icon: 'i-solar:layers-bold-duotone',
to: '/settings/modules',
},
{
title: t('settings.pages.scene.title'),
description: t('settings.pages.scene.description'),
icon: 'i-solar:armchair-2-bold-duotone',
to: '/settings/scene',
},
{
title: t('settings.pages.models.title'),
description: t('settings.pages.models.description'),
icon: 'i-solar:people-nearby-bold-duotone',
to: '/settings/models',
},
{
title: t('settings.pages.memory.title'),
description: t('settings.pages.memory.description'),
icon: 'i-solar:leaf-bold-duotone',
to: '/settings/memory',
},
{
title: t('settings.pages.providers.title'),
description: t('settings.pages.providers.description'),
icon: 'i-solar:box-minimalistic-bold-duotone',
to: '/settings/providers',
},
{
title: t('settings.pages.system.title'),
description: t('settings.pages.system.description'),
icon: 'i-solar:filters-bold-duotone',
to: '/settings/system',
},
])
</script>
<template>
<div flex="~ col gap-4" font-normal>
<div />
<div flex="~ col gap-4" pb-12>
<IconItem
v-for="(setting, index) in settings"
:key="setting.to"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250"
:style="{
transitionDelay: `${index * 50}ms`, // delay between each item, unocss doesn't support dynamic generation of classes now
}"
:title="setting.title"
:description="setting.description"
:icon="setting.icon"
:to="setting.to"
@click="(e: MouseEvent) => handleIconItemClick(e, setting)"
/>
</div>
<IconAnimation
v-if="showAnimationComponent && !settingsStore.disableTransitions && settingsStore.usePageSpecificTransitions"
ref="iconAnimation"
:icon="animationIcon"
:icon-size="6 * 1.2"
:position="animationPosition"
:duration="1000"
text-color="text-neutral-400/50 dark:text-neutral-600/20"
:started="iconAnimationStarted"
@animation-ended.once="handleAnimationEnded"
/>
<div
v-else
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-12rem)]" bottom-0 right--10 z--1
:initial="{ scale: 0.9, opacity: 0, rotate: 180 }"
:enter="{ scale: 1, opacity: 1, rotate: 0 }"
:duration="500"
size-60
flex items-center justify-center
>
<div v-motion text="60" i-solar:settings-bold-duotone />
</div>
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { Callout } from '@proj-airi/stage-ui/components'
// import { useServerStore } from '@proj-airi/stage-ui/stores/server'
// import { storeToRefs } from 'pinia'
// import { onMounted } from 'vue'
// const { server } = storeToRefs(useServerStore())
// onMounted(() => {
// server.value?.connect()
// })
</script>
<template>
<div>
<Callout
label="In development, needs your help!"
theme="orange"
>
<div>
This functionality is still under development. If you have any suggestions or would like to contribute, please reach out to us on our <a underline decoration-dotted href="https://github.com/moeru-ai/airi/issues">GitHub issues page</a>.
The source code of this page is located at <a underline decoration-dotted href="https://github.com/moeru-ai/airi/tree/main/apps/stage-tamagotchi/src/pages/settings/memory/index.vue">here</a>.
</div>
</Callout>
</div>
<div
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-15rem)]" bottom-0 right--5 z--1
:initial="{ scale: 0.9, opacity: 0, y: 15 }"
:enter="{ scale: 1, opacity: 1, y: 0 }"
:duration="500"
size-60
flex items-center justify-center
>
<div text="60" i-solar:leaf-bold-duotone />
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,89 @@
<script setup lang="ts">
import type { Live2DCanvas } from '@proj-airi/stage-ui/components/scenes'
import { ModelSettings } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings'
import { Vibrant } from 'node-vibrant/browser'
import { ref } from 'vue'
import IconAnimation from '../../../components/IconAnimation.vue'
import { useIconAnimation } from '../../../composables/icon-animation'
const live2dCanvasRef = ref<InstanceType<typeof Live2DCanvas>>()
const palette = ref<string[]>([])
async function extractColorsFromModel() {
if (!live2dCanvasRef.value)
return
const frame = await live2dCanvasRef.value.captureFrame()
if (!frame) {
console.error('No frame captured')
return
}
const frameUrl = URL.createObjectURL(frame)
try {
const vibrant = new Vibrant(frameUrl)
const paletteFromVibrant = await vibrant.getPalette()
palette.value = Object.values(paletteFromVibrant).map(color => color?.hex).filter(it => typeof it === 'string')
}
finally {
URL.revokeObjectURL(frameUrl)
}
}
const {
iconAnimationStarted,
showIconAnimation,
animationIcon,
} = useIconAnimation('i-solar:people-nearby-bold-duotone')
</script>
<template>
<div flex class="relative h-full flex-col-reverse md:flex-row">
<ModelSettings
settings-class="w-100% md:w-40% lg:w-40% xl:w-25% 2xl:w-30% h-fit sm:max-h-80dvh overflow-y-scroll relative"
live-2d-scene-class="absolute max-h-[calc(100dvh-100px-56px)] w-full h-full"
vrm-scene-class="absolute max-h-[calc(100dvh-100px-56px)] w-full h-full"
:palette="palette" @extract-colors-from-model="extractColorsFromModel"
/>
</div>
<IconAnimation
v-if="showIconAnimation"
:z-index="-1"
:icon="animationIcon"
:icon-size="12"
:duration="1000"
:started="iconAnimationStarted"
:is-reverse="true"
position="calc(100dvw - 9.5rem), calc(100dvh - 9.5rem)"
text-color="text-neutral-200/50 dark:text-neutral-600/20"
/>
<div
v-else
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-15rem)]" bottom-0 right--5 z--1
:initial="{ scale: 0.9, opacity: 0, y: 15 }"
:enter="{ scale: 1, opacity: 1, y: 0 }"
:duration="500"
size-60
flex items-center justify-center
>
<div text="60" i-solar:people-nearby-bold-duotone />
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
pageSpecificAvailable: true
</route>
@@ -0,0 +1,216 @@
<script setup lang="ts">
import { Alert, ErrorContainer, RadioCardManySelect, RadioCardSimple } from '@proj-airi/stage-ui/components'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
const providersStore = useProvidersStore()
const consciousnessStore = useConsciousnessStore()
const { configuredChatProvidersMetadata } = storeToRefs(providersStore)
const {
activeProvider,
activeModel,
customModelName,
modelSearchQuery,
supportsModelListing,
providerModels,
isLoadingActiveProviderModels,
activeProviderModelError,
} = storeToRefs(consciousnessStore)
const { t } = useI18n()
async function loadModelsForProvider() {
if (activeProvider.value) {
await consciousnessStore.loadModelsForProvider(activeProvider.value)
}
}
onMounted(loadModelsForProvider)
watch(activeProvider, loadModelsForProvider)
function updateCustomModelName(value: string) {
customModelName.value = value
}
</script>
<template>
<div bg="neutral-50 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-4">
<div>
<div flex="~ col gap-4">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-500">
{{ t('settings.pages.providers.title') }}
</h2>
<div text="neutral-400 dark:neutral-400">
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.description') }}</span>
</div>
</div>
<div max-w-full>
<!--
fieldset has min-width set to --webkit-min-container, in order to use over flow scroll,
we need to set the min-width to 0.
See also: https://stackoverflow.com/a/33737340
-->
<fieldset
v-if="configuredChatProvidersMetadata.length > 0"
flex="~ row gap-4"
:style="{ 'scrollbar-width': 'none' }"
min-w-0 of-x-scroll scroll-smooth
role="radiogroup"
>
<RadioCardSimple
v-for="metadata in configuredChatProvidersMetadata"
:id="metadata.id"
:key="metadata.id"
v-model="activeProvider"
name="provider"
:value="metadata.id"
:title="metadata.localizedName || 'Unknown'"
:description="metadata.localizedDescription"
/>
</fieldset>
<div v-else>
<RouterLink
class="flex items-center gap-3 rounded-lg p-4"
border="2 dashed neutral-200 dark:neutral-800"
bg="neutral-50 dark:neutral-800"
transition="colors duration-200 ease-in-out"
to="/settings/providers"
>
<div i-solar:warning-circle-line-duotone class="text-2xl text-amber-500 dark:text-amber-400" />
<div class="flex flex-col">
<span class="font-medium">No Providers Configured</span>
<span class="text-sm text-neutral-400 dark:text-neutral-500">Click here to set up your LLM
providers</span>
</div>
<div i-solar:arrow-right-line-duotone class="ml-auto text-xl text-neutral-400 dark:text-neutral-500" />
</RouterLink>
</div>
</div>
</div>
</div>
<!-- Model selection section -->
<div v-if="activeProvider && supportsModelListing">
<div flex="~ col gap-4">
<div>
<h2 class="text-lg md:text-2xl">
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.title') }}
</h2>
<div text="neutral-400 dark:neutral-400">
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.subtitle') }}</span>
</div>
</div>
<!-- Loading state -->
<div v-if="isLoadingActiveProviderModels" class="flex items-center justify-center py-4">
<div class="mr-2 animate-spin">
<div i-solar:spinner-line-duotone text-xl />
</div>
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.loading') }}</span>
</div>
<!-- Error state -->
<ErrorContainer
v-else-if="activeProviderModelError"
:title="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.error')"
:error="activeProviderModelError"
/>
<!-- No models available -->
<Alert
v-else-if="providerModels.length === 0 && !isLoadingActiveProviderModels"
type="warning"
>
<template #title>
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_models') }}
</template>
<template #content>
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_models_description') }}
</template>
</Alert>
<!-- Using the new RadioCardManySelect component -->
<template v-else-if="providerModels.length > 0">
<RadioCardManySelect
v-model="activeModel"
v-model:search-query="modelSearchQuery"
:items="providerModels.sort((a, b) => a.id === activeModel ? -1 : b.id === activeModel ? 1 : 0)"
:searchable="true"
:search-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_placeholder')"
:search-no-results-title="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results')"
:search-no-results-description="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results_description', { query: modelSearchQuery })"
:search-results-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_results', { count: '{count}', total: '{total}' })"
:custom-input-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.custom_model_placeholder')"
:expand-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.expand')"
:collapse-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.collapse')"
@update:custom-value="updateCustomModelName"
/>
</template>
</div>
</div>
<!-- Provider doesn't support model listing -->
<div v-else-if="activeProvider && !supportsModelListing">
<div flex="~ col gap-4">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.title') }}
</h2>
<div text="neutral-400 dark:neutral-500">
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.subtitle') }}</span>
</div>
</div>
<div
class="flex items-center gap-3 border border-primary-200 rounded-lg bg-primary-50 p-4 dark:border-primary-800 dark:bg-primary-900/20"
>
<div i-solar:info-circle-line-duotone class="text-2xl text-primary-500 dark:text-primary-400" />
<div class="flex flex-col">
<span class="font-medium">{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.not_supported')
}}</span>
<span class="text-sm text-primary-600 dark:text-primary-400">{{
t('settings.pages.modules.consciousness.sections.section.provider-model-selection.not_supported_description') }}</span>
</div>
</div>
<!-- Manual model input for providers without model listing -->
<div class="mt-2">
<label class="mb-1 block text-sm font-medium">
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.manual_model_name') }}
</label>
<input
v-model="activeModel" type="text"
class="w-full border border-neutral-300 rounded bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900"
:placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.manual_model_placeholder')"
>
</div>
</div>
</div>
</div>
<div
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-15rem)]" bottom-0 right--5 z--1
:initial="{ scale: 0.9, opacity: 0, x: 20 }"
:enter="{ scale: 1, opacity: 1, x: 0 }"
:duration="500"
size-60
flex items-center justify-center
>
<div text="60" i-solar:ghost-bold-duotone />
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,650 @@
<script setup lang="ts">
// import type { TranscriptionProvider } from '@xsai-ext/shared-providers'
import { Alert, Button, ErrorContainer, LevelMeter, RadioCardManySelect, RadioCardSimple, TestDummyMarker, ThresholdMeter, TimeSeriesChart } from '@proj-airi/stage-ui/components'
import { useAudioAnalyzer } from '@proj-airi/stage-ui/composables'
// import { useAudioAnalyzer, useAudioRecorder } from '@proj-airi/stage-ui/composables'
import { useAudioContext } from '@proj-airi/stage-ui/stores/audio'
import { useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { FieldCheckbox, FieldRange, FieldSelect } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
// import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
// import workletUrl from '../../../tauri/vad/process.worklet?worker&url'
// import { createVAD, createVADStates } from '../../../tauri/vad'
const { t } = useI18n()
const hearingStore = useHearingStore()
const {
activeTranscriptionProvider,
activeTranscriptionModel,
providerModels,
activeProviderModelError,
isLoadingActiveProviderModels,
supportsModelListing,
transcriptionModelSearchQuery,
activeCustomModelName,
} = storeToRefs(hearingStore)
const providersStore = useProvidersStore()
const { configuredTranscriptionProvidersMetadata } = storeToRefs(providersStore)
const { stopStream, startStream } = useSettingsAudioDevice()
const { audioInputs, selectedAudioInput, stream } = storeToRefs(useSettingsAudioDevice())
// const { startRecord, stopRecord, onStopRecord } = useAudioRecorder(stream)
const { startAnalyzer, stopAnalyzer, onAnalyzerUpdate, volumeLevel } = useAudioAnalyzer()
const { audioContext } = storeToRefs(useAudioContext())
const error = ref<string>('')
const vadModelError = ref('')
const isMonitoring = ref(false)
const enablePlayback = ref(false)
// Audio processing state
const gainNode = ref<GainNode>()
const animationFrame = ref<number>()
// Audio levels and indicators
const isSpeaking = ref(false)
const speakingThreshold = ref(25) // 0-100 (for volume-based fallback)
const monitorVolume = ref(50) // 0-100
// VAD integration
// const vadManager = ref<ReturnType<typeof createVADStates>>()
const isVADModelLoaded = ref(false)
const isLoadingVADModel = ref(false)
const useVADModel = ref(true) // Toggle between VAD and volume-based detection
const vadProbability = ref(0) // Raw VAD probability
const vadThreshold = ref(0.5) // VAD probability threshold for speech detection
// VAD visualization
const vadHistory = ref<number[]>([]) // History for chart visualization
// const maxVadHistory = 50 // Keep 50 samples (~1.6 seconds at 32ms intervals)
const audios = ref<Blob[]>([])
const audioCleanups = ref<(() => void)[]>([])
const audioURLs = computed(() => {
return audios.value.map((blob) => {
const url = URL.createObjectURL(blob)
audioCleanups.value.push(() => URL.revokeObjectURL(url))
return url
})
})
const transcriptions = ref<string[]>([])
// // VAD functions
// async function loadVADModel() {
// if (isVADModelLoaded.value || isLoadingVADModel.value)
// return
// isLoadingVADModel.value = true
// vadModelError.value = ''
// try {
// // // Create and initialize the VAD
// // const vad = await createVAD({
// // sampleRate: 16000,
// // speechThreshold: vadThreshold.value,
// // exitThreshold: vadThreshold.value * 0.3,
// // minSilenceDurationMs: 400,
// // })
// // // Set up event handlers
// // vad.on('speech-start', () => {
// // isSpeaking.value = true
// // startRecord() // Start recording when speech is detected
// // })
// // vad.on('speech-end', () => {
// // isSpeaking.value = false
// // stopRecord() // Stop recording when speech ends
// // })
// // vad.on('debug', ({ data }) => {
// // if (data?.probability !== undefined) {
// // vadProbability.value = data.probability
// // // Update VAD history for visualization
// // vadHistory.value.push(data.probability)
// // if (vadHistory.value.length > maxVadHistory) {
// // vadHistory.value.shift()
// // }
// // }
// // })
// // vad.on('status', ({ type, message }) => {
// // if (type === 'error') {
// // vadModelError.value = message
// // }
// // })
// // // Create and initialize audio manager
// // const manager = createVADStates(vad, workletUrl, {
// // minChunkSize: 512,
// // // NOTICE: VAD will have it's own audio context since
// // // it needs special sample rate and latency settings
// // audioContextOptions: {
// // sampleRate: 16000,
// // latencyHint: 'interactive',
// // },
// // })
// // await manager.initialize()
// // vadManager.value = manager
// // isVADModelLoaded.value = true
// }
// catch (error) {
// vadModelError.value = error instanceof Error ? error.message : String(error)
// console.error('Failed to load VAD model:', error)
// }
// finally {
// isLoadingVADModel.value = false
// }
// }
// onStopRecord(async (recording) => {
// if (!recording)
// return
// try {
// if (recording && recording.size > 0) {
// audios.value.push(recording)
// const provider = await providersStore.getProviderInstance<TranscriptionProvider<string>>(activeTranscriptionProvider.value)
// if (!provider) {
// throw new Error('Failed to initialize speech provider')
// }
// // Get model from configuration or use default
// const model = activeTranscriptionModel.value
// const res = await hearingStore.transcription(provider, model, new File([recording], 'recording.wav'))
// transcriptions.value.push(res.text)
// }
// }
// catch (err) {
// error.value = err instanceof Error ? err.message : String(err)
// console.error('Error generating transcription:', error.value)
// }
// })
// Audio monitoring
async function setupAudioMonitoring() {
try {
if (!selectedAudioInput.value) {
console.warn('No audio input device selected')
return
}
// Clean up existing connections
await stopAudioMonitoring()
await startStream()
if (!stream.value) {
console.warn('No audio stream available')
return
}
const source = audioContext.value.createMediaStreamSource(stream.value)
const analyzer = startAnalyzer(audioContext.value)
onAnalyzerUpdate((volumeLevel) => {
// Fallback speaking detection (when VAD model is not used)
if (!useVADModel.value || !isVADModelLoaded.value) {
isSpeaking.value = volumeLevel > speakingThreshold.value
}
})
// Create gain node for playback volume control
gainNode.value = audioContext.value.createGain()
gainNode.value.gain.value = enablePlayback.value ? (monitorVolume.value / 100) : 0
// Connect audio graph
if (analyzer)
source.connect(analyzer)
if (enablePlayback.value) {
source.connect(gainNode.value)
gainNode.value.connect(audioContext.value.destination)
}
// // Load VAD model and start VAD processing if enabled
// if (useVADModel.value) {
// await loadVADModel()
// if (vadManager.value) {
// await vadManager.value.start(stream.value)
// }
// }
}
catch (error) {
console.error('Error setting up audio monitoring:', error)
vadModelError.value = error instanceof Error ? error.message : String(error)
}
}
async function stopAudioMonitoring() {
if (animationFrame.value) { // Stop animation frame
cancelAnimationFrame(animationFrame.value)
animationFrame.value = undefined
}
// if (vadManager.value) { // Stop VAD manager
// await vadManager.value.stop()
// }
if (stream.value) { // Stop media stream
stopStream()
}
stopAnalyzer()
gainNode.value = undefined
isSpeaking.value = false
vadProbability.value = 0
vadHistory.value = []
}
// Update playback routing when playback setting changes
async function updatePlayback() {
if (!audioContext.value || !gainNode.value)
return
if (enablePlayback.value) {
gainNode.value.gain.value = monitorVolume.value / 100
gainNode.value.connect(audioContext.value.destination)
}
else {
gainNode.value.gain.value = 0
gainNode.value.disconnect()
}
}
// Watchers
watch(selectedAudioInput, async () => {
if (isMonitoring.value) {
await setupAudioMonitoring()
}
})
watch(enablePlayback, updatePlayback)
watch(monitorVolume, () => {
if (gainNode.value && enablePlayback.value) {
gainNode.value.gain.value = monitorVolume.value / 100
}
})
// watch(vadThreshold, () => {
// // Update VAD threshold if model is loaded
// if (vadManager.value && isVADModelLoaded.value) {
// // TODO: We would need to add an updateConfig method to VADAudioManager
// }
// })
// Monitoring toggle
async function toggleMonitoring() {
if (!isMonitoring.value) {
await setupAudioMonitoring()
isMonitoring.value = true
}
else {
await stopAudioMonitoring()
isMonitoring.value = false
}
}
// Speaking indicator with enhanced VAD visualization
const speakingIndicatorClass = computed(() => {
if (!useVADModel.value || !isVADModelLoaded.value) {
// Volume-based: simple green/white
return isSpeaking.value
? 'bg-green-500 shadow-lg shadow-green-500/50'
: 'bg-white dark:bg-neutral-900 border-2 border-neutral-300 dark:border-neutral-600'
}
// VAD-based: color intensity based on probability
const prob = vadProbability.value
const threshold = vadThreshold.value
if (prob > threshold) {
// Speaking: green (could add intensity in future)
return `bg-green-500 shadow-lg shadow-green-500/50`
}
else if (prob > threshold * 0.5) {
// Close to threshold: yellow
return 'bg-yellow-500 shadow-lg shadow-yellow-500/30'
}
else {
// Low probability: neutral
return 'bg-white dark:bg-neutral-900 border-2 border-neutral-300 dark:border-neutral-600'
}
})
function updateCustomModelName(value: string) {
activeCustomModelName.value = value
}
onMounted(async () => {
await hearingStore.loadModelsForProvider(activeTranscriptionProvider.value)
})
// onUnmounted(() => {
// stopAudioMonitoring()
// if (vadManager.value) {
// vadManager.value.dispose()
// }
// audioCleanups.value.forEach(cleanup => cleanup())
// })
</script>
<template>
<div flex="~ col md:row gap-6">
<div bg="neutral-100 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-4" class="h-fit w-full md:w-[40%]">
<div flex="~ col gap-4">
<!-- Audio Input Selection -->
<div>
<FieldSelect
v-model="selectedAudioInput"
label="Audio Input Device"
description="Select the audio input device for your hearing module."
:options="audioInputs.map(input => ({
label: input.label || input.deviceId,
value: input.deviceId,
}))"
placeholder="Select an audio input device"
layout="vertical"
/>
</div>
<div flex="~ col gap-4">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-500">
{{ t('settings.pages.providers.title') }}
</h2>
<div text="neutral-400 dark:neutral-400">
<span>{{ t('settings.pages.modules.hearing.sections.section.provider-model-selection.description') }}</span>
</div>
</div>
<div max-w-full>
<!--
fieldset has min-width set to --webkit-min-container, in order to use over flow scroll,
we need to set the min-width to 0.
See also: https://stackoverflow.com/a/33737340
-->
<fieldset
v-if="configuredTranscriptionProvidersMetadata.length > 0"
flex="~ row gap-4"
:style="{ 'scrollbar-width': 'none' }"
min-w-0 of-x-scroll scroll-smooth
role="radiogroup"
>
<RadioCardSimple
v-for="metadata in configuredTranscriptionProvidersMetadata"
:id="metadata.id"
:key="metadata.id"
v-model="activeTranscriptionProvider"
name="provider"
:value="metadata.id"
:title="metadata.localizedName || 'Unknown'"
:description="metadata.localizedDescription"
/>
</fieldset>
<div v-else>
<RouterLink
class="flex items-center gap-3 rounded-lg p-4"
border="2 dashed neutral-200 dark:neutral-800"
bg="neutral-50 dark:neutral-800"
transition="colors duration-200 ease-in-out"
to="/settings/providers"
>
<div i-solar:warning-circle-line-duotone class="text-2xl text-amber-500 dark:text-amber-400" />
<div class="flex flex-col">
<span class="font-medium">No Providers Configured</span>
<span class="text-sm text-neutral-400 dark:text-neutral-500">Click here to set up your Transcription providers</span>
</div>
<div i-solar:arrow-right-line-duotone class="ml-auto text-xl text-neutral-400 dark:text-neutral-500" />
</RouterLink>
</div>
</div>
</div>
<!-- Model selection section -->
<div v-if="activeTranscriptionProvider && supportsModelListing">
<div flex="~ col gap-4">
<div>
<h2 class="text-lg md:text-2xl">
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.title') }}
</h2>
<div text="neutral-400 dark:neutral-400">
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.subtitle') }}</span>
</div>
</div>
<!-- Loading state -->
<div v-if="isLoadingActiveProviderModels" class="flex items-center justify-center py-4">
<div class="mr-2 animate-spin">
<div i-solar:spinner-line-duotone text-xl />
</div>
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.loading') }}</span>
</div>
<!-- Error state -->
<ErrorContainer
v-else-if="activeProviderModelError"
:title="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.error')"
:error="activeProviderModelError"
/>
<!-- No models available -->
<Alert
v-else-if="providerModels.length === 0 && !isLoadingActiveProviderModels"
type="warning"
>
<template #title>
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_models') }}
</template>
<template #content>
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_models_description') }}
</template>
</Alert>
<!-- Using the new RadioCardManySelect component -->
<template v-else-if="providerModels.length > 0">
<RadioCardManySelect
v-model="activeTranscriptionModel"
v-model:search-query="transcriptionModelSearchQuery"
:items="providerModels.sort((a, b) => a.id === activeTranscriptionModel ? -1 : b.id === activeTranscriptionModel ? 1 : 0)"
:searchable="true"
:search-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_placeholder')"
:search-no-results-title="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results')"
:search-no-results-description="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results_description', { query: transcriptionModelSearchQuery })"
:search-results-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_results', { count: '{count}', total: '{total}' })"
:custom-input-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.custom_model_placeholder')"
:expand-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.expand')"
:collapse-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.collapse')"
@update:custom-value="updateCustomModelName"
/>
</template>
</div>
</div>
</div>
</div>
<div flex="~ col gap-6" class="w-full md:w-[60%]">
<div w-full rounded-xl>
<h2 class="mb-4 text-lg text-neutral-500 md:text-2xl dark:text-neutral-400" w-full>
<div class="inline-flex items-center gap-4">
<TestDummyMarker />
<div>
{{ t('settings.pages.providers.provider.elevenlabs.playground.title') }}
</div>
</div>
</h2>
<ErrorContainer v-if="error" title="Error occurred" :error="error" mb-4 />
<Button class="mb-4" w-full @click="toggleMonitoring">
{{ isMonitoring ? 'Stop Monitoring' : 'Start Monitoring' }}
</Button>
<div>
<div v-for="(audio, index) in audioURLs" :key="index" class="mb-2">
<audio :src="audio" controls class="w-full" />
<div v-if="transcriptions[index]" class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
{{ transcriptions[index] }}
</div>
</div>
</div>
<div flex="~ col gap-4">
<div class="space-y-4">
<!-- Audio Level Visualization -->
<div class="space-y-3">
<!-- Volume Meter -->
<LevelMeter :level="volumeLevel" label="Input Level" />
<!-- VAD Probability Meter (when VAD model is active) -->
<ThresholdMeter
v-if="useVADModel && isVADModelLoaded"
:value="vadProbability"
:threshold="vadThreshold"
label="Probability of Speech"
below-label="Silence"
above-label="Speech"
threshold-label="Detection threshold"
/>
<!-- Threshold Controls -->
<div v-if="useVADModel && isVADModelLoaded" class="space-y-3">
<FieldRange
v-model="vadThreshold"
label="Sensitivity"
description="Adjust the threshold for speech detection"
:min="0.1"
:max="0.9"
:step="0.05"
:format-value="value => `${(value * 100).toFixed(0)}%`"
/>
</div>
<div v-else class="space-y-3">
<FieldRange
v-model="speakingThreshold"
label="Sensitivity"
description="Adjust the threshold for speech detection"
:min="1"
:max="80"
:step="1"
:format-value="value => `${value}%`"
/>
</div>
<!-- Speaking Indicator -->
<div class="flex items-center gap-3">
<div
class="h-4 w-4 rounded-full transition-all duration-200"
:class="speakingIndicatorClass"
/>
<span class="text-sm font-medium">
{{ isSpeaking ? 'Speaking Detected' : 'Silence' }}
</span>
<span class="ml-auto text-xs text-neutral-500">
{{ useVADModel && isVADModelLoaded ? 'Model Based' : 'Volume Based' }}
</span>
</div>
<!-- VAD Method Selection -->
<div class="border-t border-neutral-200 pt-3 dark:border-neutral-700">
<FieldCheckbox
v-model="useVADModel"
label="Model Based"
description="Use AI models for more accurate speech detection"
/>
<!-- VAD Model Status -->
<div v-if="useVADModel" class="mt-3 space-y-2">
<div v-if="isLoadingVADModel" class="flex items-center gap-2 text-primary-600 dark:text-primary-400">
<div class="animate-spin text-sm" i-solar:spinner-line-duotone />
<span class="text-sm">Loading...</span>
</div>
<ErrorContainer
v-else-if="vadModelError"
title="Inference error"
:error="vadModelError"
/>
<div v-else-if="isVADModelLoaded" class="flex items-center gap-2 text-green-600 dark:text-green-400">
<div class="text-sm" i-solar:check-circle-bold-duotone />
<span class="text-sm">Activated</span>
<span class="ml-auto text-xs text-neutral-500">
Probability: {{ (vadProbability * 100).toFixed(1) }}%
</span>
</div>
</div>
</div>
<!-- Voice Activity Visualization (when VAD model is active) -->
<TimeSeriesChart
v-if="useVADModel && isVADModelLoaded"
:history="vadHistory"
:current-value="vadProbability"
:threshold="vadThreshold"
:is-active="isSpeaking"
title="Voice Activity"
subtitle="Last 2 seconds"
active-label="Speaking"
active-legend-label="Voice detected"
inactive-legend-label="Silence"
threshold-label="Speech threshold"
/>
</div>
<!-- Audio Playback (Monitor) -->
<div v-if="isMonitoring" class="border-t border-neutral-200 pt-4 dark:border-neutral-700">
<FieldCheckbox
v-model="enablePlayback"
label="Monitor Audio (Listen)"
description="Enable audio playback monitoring (like OBS). Be careful of feedback!"
/>
<div v-if="enablePlayback" class="mt-3">
<FieldRange
v-model="monitorVolume"
label="Monitor Volume"
description="Control the volume of audio monitoring playback"
:min="0"
:max="100"
:step="5"
:format-value="value => `${value}%`"
/>
</div>
</div>
<!-- Warning for playback -->
<div v-if="enablePlayback" class="border border-amber-200 rounded-lg bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-900/20">
<div class="flex items-center gap-2 text-amber-700 dark:text-amber-300">
<div class="text-sm" i-solar:warning-circle-bold-duotone />
<span class="text-sm font-medium">Audio feedback warning</span>
</div>
<div class="mt-1 text-xs text-amber-600 dark:text-amber-400">
Use headphones to prevent audio feedback. Lower the monitor volume if you hear echoing.
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,175 @@
<script setup lang="ts">
import { IconStatusItem } from '@proj-airi/stage-ui/components'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import IconAnimation from '../../../components/IconAnimation.vue'
import { useIconAnimation } from '../../../composables/icon-animation'
const { t } = useI18n()
interface Module {
id: string
name: string
description: string
icon?: string
iconColor?: string
iconImage?: string
to: string
configured: boolean
}
// TODO: categorize modules, such as essential, messaging, gaming, etc.
const modulesList = computed<Module[]>(() => [
{
id: 'consciousness',
name: t('settings.pages.modules.consciousness.title'),
description: t('settings.pages.modules.consciousness.description'),
icon: 'i-solar:ghost-bold-duotone',
to: '/settings/modules/consciousness',
configured: useConsciousnessStore().configured,
},
{
id: 'speech',
name: t('settings.pages.modules.speech.title'),
description: t('settings.pages.modules.speech.description'),
icon: 'i-solar:user-speak-rounded-bold-duotone',
to: '/settings/modules/speech',
configured: useSpeechStore().configured,
},
{
id: 'hearing',
name: t('settings.pages.modules.hearing.title'),
description: t('settings.pages.modules.hearing.description'),
icon: 'i-solar:microphone-3-bold-duotone',
to: '/settings/modules/hearing',
configured: false,
},
{
id: 'vision',
name: t('settings.pages.modules.vision.title'),
description: t('settings.pages.modules.vision.description'),
icon: 'i-solar:eye-closed-bold-duotone',
to: '',
configured: false,
},
{
id: 'memory-short-term',
name: t('settings.pages.modules.memory-short-term.title'),
description: t('settings.pages.modules.memory-short-term.description'),
icon: 'i-solar:bookmark-bold-duotone',
to: '/settings/modules/memory-short-term',
configured: false,
},
{
id: 'memory-long-term',
name: t('settings.pages.modules.memory-long-term.title'),
description: t('settings.pages.modules.memory-long-term.description'),
icon: 'i-solar:book-bookmark-bold-duotone',
to: '/settings/modules/memory-long-term',
configured: false,
},
{
id: 'messaging-discord',
name: t('settings.pages.modules.messaging-discord.title'),
description: t('settings.pages.modules.messaging-discord.description'),
icon: 'i-simple-icons:discord',
to: '',
configured: false,
},
{
id: 'x',
name: t('settings.pages.modules.x.title'),
description: t('settings.pages.modules.x.description'),
icon: 'i-simple-icons:x',
to: '',
configured: false,
},
{
id: 'game-minecraft',
name: t('settings.pages.modules.gaming-minecraft.title'),
description: t('settings.pages.modules.gaming-minecraft.description'),
iconColor: 'i-vscode-icons:file-type-minecraft',
to: '',
configured: false,
},
{
id: 'game-factorio',
name: t('settings.pages.modules.gaming-factorio.title'),
description: t('settings.pages.modules.gaming-factorio.description'),
iconImage: '',
to: '',
configured: false,
},
{
id: 'mcp-server',
name: t('settings.pages.modules.mcp-server.title'),
description: t('settings.pages.modules.mcp-server.description'),
icon: 'i-solar:server-bold-duotone',
to: '/settings/modules/mcp',
configured: false,
},
])
const {
iconAnimationStarted,
showIconAnimation,
animationIcon,
} = useIconAnimation('i-solar:layers-bold-duotone')
</script>
<template>
<div grid="~ cols-1 sm:cols-2 gap-4">
<IconStatusItem
v-for="(module, index) of modulesList"
:key="module.id"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + index * 10"
:delay="index * 50"
:title="module.name"
:description="module.description"
:icon="module.icon"
:icon-color="module.iconColor"
:icon-image="module.iconImage"
:to="module.to"
:configured="module.configured"
/>
</div>
<IconAnimation
v-if="showIconAnimation"
:icon="animationIcon"
:icon-size="12"
:duration="1000"
:started="iconAnimationStarted"
:is-reverse="true"
:z-index="-1"
text-color="text-neutral-200/50 dark:text-neutral-600/20"
position="calc(100dvw - 9.5rem), calc(100dvh - 9.5rem)"
/>
<div
v-else
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-15rem)]" bottom-0 right--5 z--1
:initial="{ scale: 0.9, opacity: 0, y: 20 }"
:enter="{ scale: 1, opacity: 1, y: 0 }"
:duration="500"
size-60
flex items-center justify-center
>
<div text="60" i-solar:layers-bold-duotone />
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
pageSpecificAvailable: true
</route>
@@ -0,0 +1,152 @@
<script setup lang="ts">
// import type { Tool } from '@proj-airi/tauri-plugin-mcp'
import { useMcpStore } from '@proj-airi/stage-ui/stores/mcp'
// import { connectServer, disconnectServer, listTools } from '@proj-airi/tauri-plugin-mcp'
import {
FieldInput,
} from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
const mcpStore = useMcpStore()
const connecting = ref(false)
const {
serverCmd,
serverArgs,
connected,
} = storeToRefs(mcpStore)
interface Tool {
name: string
description: string
}
const tools = ref<Tool[]>([])
async function connect() {
connecting.value = true
try {
// await connectServer(serverCmd.value, serverArgs.value.split(' '))
connected.value = true
}
catch (e) {
const error = e as string
console.error(error)
if (error.includes('already connected')) {
connected.value = true
}
}
finally {
connecting.value = false
}
}
async function disconnect() {
// await disconnectServer()
connected.value = false
tools.value = []
}
async function getTools() {
// tools.value = await listTools()
}
</script>
<template>
<div flex="~ col md:row gap-6">
<div flex="~ col gap-6" class="w-full md:w-[60%]">
<div w-full rounded-xl>
<div flex="~ col gap-4">
<FieldInput
v-model="serverCmd"
type="text"
label="Server Command"
description="Enter the server command to run"
placeholder="docker"
:disabled="connecting || connected"
/>
<FieldInput
v-model="serverArgs"
type="text"
label="Server Arguments"
description="Enter the server command arguments"
placeholder="run -i --rm -e ADB_HOST=host.docker.internal ghcr.io/lemonnekogh/airi-android:v0.1.0"
:disabled="connecting || connected"
/>
<div flex="~ row" gap-4>
<button
v-if="!connected"
border="neutral-800 dark:neutral-200 solid 2" transition="border duration-250 ease-in-out"
rounded-lg px-4 text="neutral-100 dark:neutral-900" py-2 text-sm
:disabled="connecting || !serverCmd || !serverArgs"
:class="{ 'opacity-50 cursor-not-allowed': connecting || !serverCmd || !serverArgs }"
bg="neutral-700 dark:neutral-300" @click="connect"
>
<div flex="~ row" items-center gap-2>
<div i-solar:play-circle-bold-duotone />
<span>Connect</span>
</div>
</button>
<button
v-else border="primary-300 dark:primary-800 solid 2"
transition="border duration-250 ease-in-out" rounded-lg px-4 py-2 text-sm @click="disconnect"
>
<div flex="~ row" items-center gap-2>
<div i-solar:stop-circle-bold-duotone />
<span>Disconnect</span>
</div>
</button>
<button
v-if="connected"
border="neutral-800 dark:neutral-200 solid 2" transition="border duration-250 ease-in-out"
rounded-lg px-4 text="neutral-100 dark:neutral-900" py-2 text-sm
:disabled="connecting"
:class="{ 'opacity-50 cursor-not-allowed': connecting }"
bg="neutral-700 dark:neutral-300" @click="getTools"
>
<div flex="~ row" items-center gap-2>
<div solar:list-arrow-down-line-duotone />
<span>List Tools</span>
</div>
</button>
</div>
<div v-if="tools.length > 0" flex="~ col gap-4">
<div v-for="tool in tools" :key="tool.name" border="neutral-200 dark:neutral-800 solid 2" rounded-lg p-4>
<div text="neutral-900 dark:neutral-100" text-sm>
{{ tool.name }}
</div>
<div text="neutral-500 dark:neutral-400" text-xs>
{{ tool.description }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-15rem)]" bottom-0 right--5 z--1
:initial="{ scale: 0.9, opacity: 0, x: 20 }"
:enter="{ scale: 1, opacity: 1, x: 0 }"
:duration="500"
size-60
flex items-center justify-center
>
<div text="60" i-solar:user-speak-rounded-bold-duotone />
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,16 @@
<script setup lang="ts">
// import { useServerStore } from '@proj-airi/stage-ui/stores/server'
// const serverStore = useServerStore()
</script>
<template>
<div />
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,16 @@
<script setup lang="ts">
// import { useServerStore } from '@proj-airi/stage-ui/stores/server'
// const serverStore = useServerStore()
</script>
<template>
<div />
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,502 @@
<script setup lang="ts">
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
import {
Alert,
ErrorContainer,
RadioCardManySelect,
RadioCardSimple,
Skeleton,
TestDummyMarker,
VoiceCardManySelect,
} from '@proj-airi/stage-ui/components'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import {
FieldCheckbox,
FieldInput,
FieldRange,
Textarea,
} from '@proj-airi/ui'
import { watchDebounced } from '@vueuse/core'
import { generateSpeech } from '@xsai/generate-speech'
import { storeToRefs } from 'pinia'
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
const { t } = useI18n()
const providersStore = useProvidersStore()
const speechStore = useSpeechStore()
const { configuredSpeechProvidersMetadata } = storeToRefs(providersStore)
const {
activeSpeechProvider,
activeSpeechModel,
activeSpeechVoice,
activeSpeechVoiceId,
pitch,
isLoadingSpeechProviderVoices,
supportsModelListing,
providerModels,
isLoadingActiveProviderModels,
activeProviderModelError,
modelSearchQuery,
speechProviderError,
ssmlEnabled,
availableVoices,
} = storeToRefs(speechStore)
const voiceSearchQuery = ref('')
const useSSML = ref(false)
const testText = ref('Hello, my name is AI Assistant')
const ssmlText = ref('')
const isGenerating = ref(false)
const audioUrl = ref('')
const audioPlayer = ref<HTMLAudioElement | null>(null)
const errorMessage = ref('')
onMounted(async () => {
await providersStore.loadModelsForConfiguredProviders()
await speechStore.loadVoicesForProvider(activeSpeechProvider.value)
})
watchDebounced(activeSpeechProvider, async () => {
await providersStore.loadModelsForConfiguredProviders()
await speechStore.loadVoicesForProvider(activeSpeechProvider.value)
}, { debounce: 100 })
// Function to generate speech
async function generateTestSpeech() {
if (!testText.value.trim() && !useSSML.value)
return
if (useSSML.value && !ssmlText.value.trim())
return
if (!activeSpeechModel.value) {
console.error('No model selected')
return
}
if (!activeSpeechVoice.value) {
console.error('No voice selected')
return
}
const provider = await providersStore.getProviderInstance(activeSpeechProvider.value) as SpeechProviderWithExtraOptions<string, any>
if (!provider) {
console.error('Failed to initialize speech provider')
return
}
const providerConfig = providersStore.getProviderConfig(activeSpeechProvider.value)
isGenerating.value = true
errorMessage.value = ''
try {
// Stop any currently playing audio
if (audioUrl.value) {
stopTestAudio()
}
const input = useSSML.value
? ssmlText.value
: speechStore.supportsSSML ? speechStore.generateSSML(testText.value, activeSpeechVoice.value, { ...providerConfig, pitch: pitch.value }) : testText.value
const response = await generateSpeech({
...provider.speech(activeSpeechModel.value, providerConfig),
input,
voice: activeSpeechVoice.value.id,
})
// Convert the response to a blob and create an object URL
audioUrl.value = URL.createObjectURL(new Blob([response]))
// Play the audio
setTimeout(() => {
if (audioPlayer.value) {
audioPlayer.value.play()
}
}, 100)
}
catch (error) {
console.error('Error generating speech:', error)
errorMessage.value = error instanceof Error ? error.message : 'An unknown error occurred'
}
finally {
isGenerating.value = false
}
}
// Function to stop audio playback
function stopTestAudio() {
if (audioPlayer.value) {
audioPlayer.value.pause()
audioPlayer.value.currentTime = 0
}
// Clean up the object URL to prevent memory leaks
if (audioUrl.value) {
URL.revokeObjectURL(audioUrl.value)
audioUrl.value = ''
}
}
// Clean up when component is unmounted
onUnmounted(() => {
if (audioUrl.value) {
URL.revokeObjectURL(audioUrl.value)
}
})
function updateCustomVoiceName(value: string | undefined) {
if (!value) {
activeSpeechVoice.value = undefined
return
}
activeSpeechVoice.value = {
id: value,
name: value,
description: value,
previewURL: value,
languages: [{ code: 'en', title: 'English' }],
provider: activeSpeechProvider.value,
gender: 'male',
}
}
function updateCustomModelName(value: string) {
activeSpeechModel.value = value
}
</script>
<template>
<div flex="~ col md:row gap-6">
<div bg="neutral-100 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-4" class="h-fit w-full md:w-[40%]">
<div>
<div flex="~ col gap-4">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
{{ t('settings.pages.modules.speech.sections.section.provider-voice-selection.title') }}
</h2>
<div text="neutral-400 dark:neutral-500">
<span>{{ t('settings.pages.modules.speech.sections.section.provider-voice-selection.description') }}</span>
</div>
</div>
<div max-w-full>
<fieldset
v-if="configuredSpeechProvidersMetadata.length > 0" flex="~ row gap-4" :style="{ 'scrollbar-width': 'none' }"
min-w-0 of-x-scroll scroll-smooth role="radiogroup"
>
<RadioCardSimple
v-for="metadata in configuredSpeechProvidersMetadata"
:id="metadata.id"
:key="metadata.id"
v-model="activeSpeechProvider"
name="speech-provider"
:value="metadata.id"
:title="metadata.localizedName || 'Unknown'"
:description="metadata.localizedDescription"
/>
</fieldset>
<div v-else>
<RouterLink
class="flex items-center gap-3 rounded-lg p-4" border="2 dashed neutral-200 dark:neutral-800"
bg="neutral-50 dark:neutral-800" transition="colors duration-200 ease-in-out" to="/settings/providers"
>
<div i-solar:warning-circle-line-duotone class="text-2xl text-amber-500 dark:text-amber-400" />
<div class="flex flex-col">
<span class="font-medium">No Speech Providers Configured</span>
<span class="text-sm text-neutral-400 dark:text-neutral-500">Click here to set up your speech
providers</span>
</div>
<div i-solar:arrow-right-line-duotone class="ml-auto text-xl text-neutral-400 dark:text-neutral-500" />
</RouterLink>
</div>
</div>
</div>
<div>
<!-- Model selection section -->
<div v-if="activeSpeechProvider && supportsModelListing">
<div flex="~ col gap-4">
<div>
<h2 class="text-lg md:text-2xl">
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.title') }}
</h2>
<div text="neutral-400 dark:neutral-400">
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.subtitle') }}</span>
</div>
</div>
<!-- Loading state -->
<div v-if="isLoadingActiveProviderModels" class="flex items-center justify-center py-4">
<div class="mr-2 animate-spin">
<div i-solar:spinner-line-duotone text-xl />
</div>
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.loading') }}</span>
</div>
<!-- Error state -->
<ErrorContainer
v-else-if="activeProviderModelError"
:title="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.error')"
:error="activeProviderModelError"
/>
<!-- No models available -->
<Alert
v-else-if="providerModels.length === 0 && !isLoadingActiveProviderModels"
type="warning"
>
<template #title>
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_models') }}
</template>
<template #content>
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_models_description') }}
</template>
</Alert>
<!-- Using the new RadioCardManySelect component -->
<template v-else-if="providerModels.length > 0">
<RadioCardManySelect
v-model="activeSpeechModel"
v-model:search-query="modelSearchQuery"
:items="providerModels"
:searchable="true"
:search-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_placeholder')"
:search-no-results-title="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results')"
:search-no-results-description="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results_description', { query: modelSearchQuery })"
:search-results-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_results', { count: '{count}', total: '{total}' })"
:custom-input-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.custom_model_placeholder')"
:expand-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.expand')"
:collapse-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.collapse')"
@update:custom-value="updateCustomModelName"
/>
</template>
</div>
</div>
</div>
</div>
<!-- Voice Configuration Section -->
<div v-if="activeSpeechProvider">
<div flex="~ col gap-4">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
Voice Configuration
</h2>
<div text="neutral-400 dark:neutral-500">
<span>Customize how your AI assistant speaks</span>
</div>
</div>
<!-- Loading state -->
<div v-if="isLoadingSpeechProviderVoices">
<div class="flex flex-col gap-4">
<Skeleton class="w-full rounded-lg p-2.5 text-sm">
<div class="h-1lh" />
</Skeleton>
<div flex="~ row gap-4">
<Skeleton class="w-full rounded-lg p-4 text-sm">
<div class="h-1lh" />
</Skeleton>
<Skeleton class="w-full rounded-lg p-4 text-sm">
<div class="h-1lh" />
</Skeleton>
<Skeleton class="w-full rounded-lg p-4 text-sm">
<div class="h-1lh" />
</Skeleton>
</div>
<Skeleton class="w-full rounded-lg p-3 text-sm">
<div class="h-1lh" />
</Skeleton>
</div>
</div>
<!-- Error state -->
<!-- Voice selection with RadioCardManySelect -->
<div
v-else-if="availableVoices[activeSpeechProvider] && availableVoices[activeSpeechProvider].length > 0"
class="space-y-6"
>
<VoiceCardManySelect
v-model:search-query="voiceSearchQuery"
v-model:voice-id="activeSpeechVoiceId"
:voices="availableVoices[activeSpeechProvider]?.map(voice => ({
id: voice.id,
name: voice.name,
description: voice.description,
previewURL: voice.previewURL,
customizable: false,
}))"
:searchable="true"
:search-placeholder="t('settings.pages.modules.speech.sections.section.provider-voice-selection.search_voices_placeholder')"
:search-no-results-title="t('settings.pages.modules.speech.sections.section.provider-voice-selection.no_voices')"
:search-no-results-description="t('settings.pages.modules.speech.sections.section.provider-voice-selection.no_voices_description')"
:search-results-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.search_voices_results', { count: 0, total: 0 })"
:custom-input-placeholder="t('settings.pages.modules.speech.sections.section.provider-voice-selection.custom_voice_placeholder')"
:expand-button-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.show_more')"
:collapse-button-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.show_less')"
:play-button-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.play_sample')"
:pause-button-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.pause')"
@update:custom-value="updateCustomVoiceName"
/>
</div>
<ErrorContainer
v-else-if="speechProviderError"
title="Error loading voices"
:error="speechProviderError"
/>
<!-- No voices available -->
<Alert v-else type="warning">
<template #title>
{{ t('settings.pages.modules.speech.sections.section.provider-voice-selection.no_voices') }}
</template>
<template #content>
{{ t('settings.pages.modules.speech.sections.section.provider-voice-selection.no_voices_description') }}.
{{ t('settings.pages.modules.speech.sections.section.provider-voice-selection.no_voices_hint') }}
</template>
</Alert>
<!-- Voice parameters -->
<div flex="~ col gap-4">
<FieldRange
v-model="pitch"
label="Pitch"
description="Tune the pitch of the voice"
:min="-100" :max="100" :step="1"
:format-value="value => `${value}%`"
/>
<!-- SSML Support -->
<FieldCheckbox
v-model="ssmlEnabled"
label="Enable SSML"
description="Enable Speech Synthesis Markup Language for more control over speech output"
/>
</div>
<!-- Manual voice input when no voices are available -->
<div
v-if="!availableVoices[activeSpeechProvider] || availableVoices[activeSpeechProvider].length === 0"
class="mt-2 space-y-6"
>
<FieldInput
type="text"
label="Voice Name"
description="Enter the voice name for your custom voice"
placeholder="Enter voice name (e.g., 'Rachel', 'Josh')"
@update:model-value="updateCustomVoiceName"
/>
<!-- Model selection for ElevenLabs -->
<div v-if="activeSpeechProvider === 'elevenlabs'">
<label class="mb-1 block text-sm font-medium">
Model
</label>
<select
v-model="activeSpeechModel"
class="w-full border border-neutral-300 rounded bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900"
>
<option value="eleven_monolingual_v1">
Monolingual v1
</option>
<option value="eleven_multilingual_v1">
Multilingual v1
</option>
<option value="eleven_multilingual_v2">
Multilingual v2
</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div flex="~ col gap-6" class="w-full md:w-[60%]">
<div w-full rounded-xl>
<h2 class="mb-4 text-lg text-neutral-500 md:text-2xl dark:text-neutral-400" w-full>
<div class="inline-flex items-center gap-4">
<TestDummyMarker />
<div>
{{ t('settings.pages.providers.provider.elevenlabs.playground.title') }}
</div>
</div>
</h2>
<div flex="~ col gap-4">
<FieldCheckbox
v-model="useSSML"
label="Use Custom SSML"
description="Enable to input raw SSML instead of plain text"
/>
<template v-if="!useSSML">
<Textarea
v-model="testText" h-24
w-full
:placeholder="t('settings.pages.providers.provider.elevenlabs.playground.fields.field.input.placeholder')"
/>
</template>
<template v-else>
<textarea
v-model="ssmlText"
placeholder="Enter SSML text..."
border="neutral-100 dark:neutral-800 solid 2 focus:neutral-200 dark:focus:neutral-700"
transition="all duration-250 ease-in-out"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
h-48 w-full rounded-lg px-3 py-2 text-sm font-mono outline-none
/>
</template>
<div flex="~ row" gap-4>
<button
border="neutral-800 dark:neutral-200 solid 2" transition="border duration-250 ease-in-out"
rounded-lg px-4 text="neutral-100 dark:neutral-900" py-2 text-sm
:disabled="isGenerating || (!testText.trim() && !useSSML) || (useSSML && !ssmlText.trim()) || !activeSpeechVoice"
:class="{ 'opacity-50 cursor-not-allowed': isGenerating || (!testText.trim() && !useSSML) || (useSSML && !ssmlText.trim()) || !activeSpeechVoice }"
bg="neutral-700 dark:neutral-300" @click="generateTestSpeech"
>
<div flex="~ row" items-center gap-2>
<div i-solar:play-circle-bold-duotone />
<span>{{ isGenerating ? t('settings.pages.providers.provider.elevenlabs.playground.buttons.button.test-voice.generating') : t('settings.pages.providers.provider.elevenlabs.playground.buttons.button.test-voice.label') }}</span>
</div>
</button>
<button
v-if="audioUrl" border="primary-300 dark:primary-800 solid 2"
transition="border duration-250 ease-in-out" rounded-lg px-4 py-2 text-sm @click="stopTestAudio"
>
<div flex="~ row" items-center gap-2>
<div i-solar:stop-circle-bold-duotone />
<span>Stop</span>
</div>
</button>
</div>
<audio v-if="audioUrl" ref="audioPlayer" :src="audioUrl" controls class="mt-2 w-full" />
</div>
</div>
</div>
</div>
<div
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-15rem)]" bottom-0 right--5 z--1
:initial="{ scale: 0.9, opacity: 0, x: 20 }"
:enter="{ scale: 1, opacity: 1, x: 0 }"
:duration="500"
size-60
flex items-center justify-center
>
<div text="60" i-solar:user-speak-rounded-bold-duotone />
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,162 @@
<script setup lang="ts">
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
import type { UnElevenLabsOptions } from 'unspeech'
import {
SpeechPlayground,
SpeechProviderSettings,
} from '@proj-airi/stage-ui/components'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldRange } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const providerId = 'alibaba-cloud-model-studio'
const defaultModel = 'cosyvoice-v1'
// Default voice settings specific to ElevenLabs
const defaultVoiceSettings = {
speed: 1.0,
}
const pitch = ref<number>(0)
const speed = ref<number>(1.0)
const volume = ref<number>(0)
const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore)
const { t } = useI18n()
// Check if API key is configured
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
// Get available voices for ElevenLabs
const availableVoices = computed(() => {
return speechStore.availableVoices[providerId] || []
})
// Generate speech with ElevenLabs-specific parameters
async function handleGenerateSpeech(input: string, voiceId: string, _useSSML: boolean) {
const provider = await providersStore.getProviderInstance(providerId) as SpeechProviderWithExtraOptions<string, UnElevenLabsOptions>
if (!provider) {
throw new Error('Failed to initialize speech provider')
}
// Get provider configuration
const providerConfig = providersStore.getProviderConfig(providerId)
// Get model from configuration or use default
const model = providerConfig.model as string | undefined || defaultModel
// ElevenLabs doesn't need SSML conversion, but if SSML is provided, use it directly
return await speechStore.speech(
provider,
model,
input,
voiceId,
{
...providerConfig,
...defaultVoiceSettings,
},
)
}
onMounted(async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
const providerMetadata = providersStore.getProviderMetadata(providerId)
if (await providerMetadata.validators.validateProviderConfig(providerConfig)) {
await speechStore.loadVoicesForProvider(providerId)
}
else {
console.error('Failed to validate provider config', providerConfig)
}
})
watch(pitch, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
providerConfig.pitch = pitch.value
})
watch(speed, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
providerConfig.speed = speed.value
})
watch(volume, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
providerConfig.volume = volume.value
})
watch(providers, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
const providerMetadata = providersStore.getProviderMetadata(providerId)
if (await providerMetadata.validators.validateProviderConfig(providerConfig)) {
await speechStore.loadVoicesForProvider(providerId)
}
else {
console.error('Failed to validate provider config', providerConfig)
}
}, {
immediate: true,
})
</script>
<template>
<SpeechProviderSettings
:provider-id="providerId"
:default-model="defaultModel"
:additional-settings="defaultVoiceSettings"
>
<!-- Voice settings specific to ElevenLabs -->
<template #voice-settings>
<div flex="~ col gap-4">
<!-- Pitch control - common to most providers -->
<FieldRange
v-model="pitch"
:label="t('settings.pages.providers.provider.common.fields.field.pitch.label')"
:description="t('settings.pages.providers.provider.common.fields.field.pitch.description')"
:min="-100"
:max="100" :step="1" :format-value="value => `${value}%`"
/>
<!-- Speed control - common to most providers -->
<FieldRange
v-model="speed"
:label="t('settings.pages.providers.provider.common.fields.field.speed.label')"
:description="t('settings.pages.providers.provider.common.fields.field.speed.description')"
:min="0.5"
:max="2.0" :step="0.01"
/>
<!-- Volume control - available in some providers -->
<FieldRange
v-model="volume"
:label="t('settings.pages.providers.provider.common.fields.field.volume.label')"
:description="t('settings.pages.providers.provider.common.fields.field.volume.description')"
:min="-100"
:max="100" :step="1" :format-value="value => `${value}%`"
/>
</div>
</template>
<!-- Replace the default playground with our standalone component -->
<template #playground>
<SpeechPlayground
:available-voices="availableVoices"
:generate-speech="handleGenerateSpeech"
:api-key-configured="apiKeyConfigured"
default-text="Hello! This is a test of the ElevenLabs voice synthesis."
/>
</template>
</SpeechProviderSettings>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,104 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'anthropic'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-ant-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.anthropic.com/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,139 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAccountIdInput,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'azure-ai-foundry'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const resourceName = computed({
get: () => providers.value[providerId]?.resourceName || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].resourceName = value
},
})
const apiVersion = computed({
get: () => providers.value[providerId]?.apiVersion || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiVersion = value
},
})
const modelId = computed({
get: () => providers.value[providerId]?.modelId || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].modelId = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="..."
required
/>
<ProviderAccountIdInput
v-model="resourceName"
label="Resouce name"
placeholder="..."
description="Prefix used in https://<prefix>.services.ai.azure.com"
required
/>
<ProviderAccountIdInput
v-model="modelId"
label="Model id"
placeholder="..."
description="Model ID on Azure AI Foundry"
required
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderAccountIdInput
v-model="apiVersion"
label="API version"
placeholder="e.g. 2025-04-01-preview"
description="API version for snapshot of the models"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,103 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAccountIdInput,
ProviderApiKeyInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'cloudflare-workers-ai'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const accountId = computed({
get: () => providers.value[providerId]?.accountId || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].accountId = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
:placeholder="t('settings.pages.providers.provider.cloudflare-workers-ai.fields.field.api-key.placeholder')"
/>
<ProviderAccountIdInput
v-model="accountId"
:label="t('settings.pages.providers.provider.cloudflare-workers-ai.fields.field.account-id.label')"
:description="t('settings.pages.providers.provider.cloudflare-workers-ai.fields.field.account-id.description')"
:placeholder="t('settings.pages.providers.provider.cloudflare-workers-ai.fields.field.account-id.placeholder')"
/>
</ProviderBasicSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,104 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'deepseek'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="ds-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.deepseek.com/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,223 @@
<script setup lang="ts">
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
import type { UnElevenLabsOptions } from 'unspeech'
import {
SpeechPlayground,
SpeechProviderSettings,
} from '@proj-airi/stage-ui/components'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldCheckbox, FieldRange } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const providerId = 'elevenlabs'
const defaultModel = 'eleven_multilingual_v2'
// Default voice settings specific to ElevenLabs
const defaultVoiceSettings = {
similarityBoost: 0.75,
stability: 0.5,
speed: 1.0,
style: 0,
useSpeakerBoost: true,
}
const pitch = ref<number>(0)
const speed = ref<number>(1.0)
const volume = ref<number>(0)
const style = ref<number>(0)
const stability = ref<number>(0.5)
const similarityBoost = ref<number>(0.75)
const useSpeakerBoost = ref<boolean>(false)
const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore)
const { t } = useI18n()
// Check if API key is configured
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
// Get available voices for ElevenLabs
const availableVoices = computed(() => {
return speechStore.availableVoices[providerId] || []
})
// Generate speech with ElevenLabs-specific parameters
async function handleGenerateSpeech(input: string, voiceId: string, _useSSML: boolean) {
const provider = await providersStore.getProviderInstance(providerId) as SpeechProviderWithExtraOptions<string, UnElevenLabsOptions>
if (!provider) {
throw new Error('Failed to initialize speech provider')
}
// Get provider configuration
const providerConfig = providersStore.getProviderConfig(providerId)
// Get model from configuration or use default
const model = providerConfig.model as string | undefined || defaultModel
// ElevenLabs doesn't need SSML conversion, but if SSML is provided, use it directly
return await speechStore.speech(
provider,
model,
input,
voiceId,
{
...providerConfig,
...defaultVoiceSettings,
},
)
}
onMounted(async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
const providerMetadata = providersStore.getProviderMetadata(providerId)
if (await providerMetadata.validators.validateProviderConfig(providerConfig)) {
await speechStore.loadVoicesForProvider(providerId)
}
else {
console.error('Failed to validate provider config', providerConfig)
}
})
watch(pitch, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
providerConfig.pitch = pitch.value
})
watch(speed, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
providerConfig.speed = speed.value
})
watch(volume, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
providerConfig.volume = volume.value
})
watch(style, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
providerConfig.style = style.value
})
watch(stability, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
providerConfig.stability = stability.value
})
watch(similarityBoost, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
providerConfig.similarityBoost = similarityBoost.value
})
watch(useSpeakerBoost, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
providerConfig.useSpeakerBoost = useSpeakerBoost.value
})
watch(providers, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
const providerMetadata = providersStore.getProviderMetadata(providerId)
if (await providerMetadata.validators.validateProviderConfig(providerConfig)) {
await speechStore.loadVoicesForProvider(providerId)
}
else {
console.error('Failed to validate provider config', providerConfig)
}
}, {
immediate: true,
})
</script>
<template>
<SpeechProviderSettings
:provider-id="providerId"
:default-model="defaultModel"
:additional-settings="defaultVoiceSettings"
>
<!-- Voice settings specific to ElevenLabs -->
<template #voice-settings>
<div flex="~ col gap-4">
<!-- Pitch control - common to most providers -->
<FieldRange
v-model="pitch"
:label="t('settings.pages.providers.provider.common.fields.field.pitch.label')"
:description="t('settings.pages.providers.provider.common.fields.field.pitch.description')"
:min="-100"
:max="100" :step="1" :format-value="value => `${value}%`"
/>
<!-- Speed control - common to most providers -->
<FieldRange
v-model="speed"
:label="t('settings.pages.providers.provider.common.fields.field.speed.label')"
:description="t('settings.pages.providers.provider.common.fields.field.speed.description')"
:min="0.5"
:max="2.0" :step="0.01"
/>
<!-- Volume control - available in some providers -->
<FieldRange
v-model="volume"
:label="t('settings.pages.providers.provider.common.fields.field.volume.label')"
:description="t('settings.pages.providers.provider.common.fields.field.volume.description')"
:min="-100"
:max="100" :step="1" :format-value="value => `${value}%`"
/>
<!-- Style control - specific to ElevenLabs -->
<FieldRange
v-model="style"
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.style.label')"
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.style.description')"
:min="0"
:max="1" :step="0.01"
/>
<!-- Stability control - specific to ElevenLabs -->
<FieldRange
v-model="stability"
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.stability.label')"
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.stability.description')"
:min="0"
:max="1" :step="0.01"
/>
<!-- Similarity Boost control - specific to ElevenLabs -->
<FieldRange
v-model="similarityBoost"
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.simularity-boost.label')"
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.simularity-boost.description')"
:min="0"
:max="1" :step="0.01"
/>
<!-- Speaker Boost checkbox - specific to ElevenLabs -->
<FieldCheckbox
v-model="useSpeakerBoost"
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.speaker-boost.label')"
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.speaker-boost.description')"
/>
</div>
</template>
<template #playground>
<SpeechPlayground
:available-voices="availableVoices"
:generate-speech="handleGenerateSpeech"
:api-key-configured="apiKeyConfigured"
default-text="Hello! This is a test of the ElevenLabs voice synthesis."
/>
</template>
</SpeechProviderSettings>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,104 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'featherless-ai'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.featherless.ai/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,104 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'fireworks-ai'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="fw-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.fireworks.ai/inference/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,104 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'google-generative-ai'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || 'https://generativelanguage.googleapis.com/v1beta/openai/',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="AIza..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://generativelanguage.googleapis.com/v1beta/openai/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,83 @@
<script setup lang="ts">
import type { SpeechProvider } from '@xsai-ext/shared-providers'
import {
SpeechPlayground,
SpeechProviderSettings,
} from '@proj-airi/stage-ui/components'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { computed, onMounted, watch } from 'vue'
// import { useI18n } from 'vue-i18n'
// const { t } = useI18n()
const providerId = 'index-tts-vllm'
const defaultModel = 'IndexTTS-1.5'
const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
// const { providers } = storeToRefs(providersStore)
// Check if API key is configured
// const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
const apiKeyConfigured = true // Assuming API key is always configured as its not required
// Get available voices for Index TTS provider
const availableVoices = computed(() => {
return speechStore.availableVoices[providerId] || []
})
onMounted(async () => {
await speechStore.loadVoicesForProvider(providerId)
})
watch([apiKeyConfigured], async () => {
await speechStore.loadVoicesForProvider(providerId)
})
async function handleGenerateSpeech(input: string, voiceId: string) {
const provider = await providersStore.getProviderInstance(providerId) as SpeechProvider
if (!provider) {
throw new Error('Failed to initialize speech provider')
}
// Get provider configuration
const providerConfig = providersStore.getProviderConfig(providerId)
// Get model from configuration or use default
const model = providerConfig.model as string | undefined || defaultModel
const options = {
...providerConfig,
}
return await speechStore.speech(
provider,
model,
input,
voiceId,
options,
)
}
</script>
<template>
<SpeechProviderSettings :provider-id="providerId" :default-model="defaultModel">
<!-- Replace the default playground with our standalone component -->
<template #playground>
<SpeechPlayground
:available-voices="availableVoices" :generate-speech="handleGenerateSpeech"
:api-key-configured="apiKeyConfigured" :use-ssml="false"
default-text="Hello! This is a test of the Index TTS Speech synthesis?."
/>
</template>
</SpeechProviderSettings>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,163 @@
<script setup lang="ts">
import { IconStatusItem } from '@proj-airi/stage-ui/components'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import IconAnimation from '../../../components/IconAnimation.vue'
import { useIconAnimation } from '../../../composables/icon-animation'
const providersStore = useProvidersStore()
const {
allChatProvidersMetadata,
allAudioSpeechProvidersMetadata,
allAudioTranscriptionProvidersMetadata,
} = storeToRefs(providersStore)
const {
iconAnimationStarted,
showIconAnimation,
animationIcon,
} = useIconAnimation('i-solar:box-minimalistic-bold-duotone')
</script>
<template>
<div mb-6 flex flex-col gap-5>
<div bg="primary-500/10 dark:primary-800/25" rounded-lg p-4>
<div mb-2 text-xl font-normal text="primary-800 dark:primary-100">
First time here?
</div>
<div text="primary-700 dark:primary-300">
AIRI requires at least one <div bg="primary-500/10 dark:primary-800/25" inline-flex items-center gap-1 rounded-lg px-2 py-0.5 translate-y="[0.25lh]">
<div i-solar:chat-square-like-bold-duotone /><strong font-normal>Chat</strong>
</div> provider to be configured to think, and behave properly. You could think of
it as the brain of the characters living in AIRI system.
</div>
</div>
<div flex="~ row items-center gap-2">
<div i-solar:chat-square-like-bold-duotone text="neutral-500 dark:neutral-400 4xl" />
<div>
<div>
<span text="neutral-300 dark:neutral-500 sm sm:base">Text generation model providers. e.g. OpenRouter, OpenAI, Ollama.</span>
</div>
<div flex text-nowrap text="2xl sm:3xl" font-normal>
<div>
Chat
</div>
</div>
</div>
</div>
<div grid="~ cols-1 sm:cols-2 xl:cols-3 gap-4">
<IconStatusItem
v-for="(provider, index) of allChatProvidersMetadata"
:key="provider.id"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + index * 10"
:delay="index * 50"
:title="provider.localizedName || 'Unknown'"
:description="provider.localizedDescription"
:icon="provider.icon"
:icon-color="provider.iconColor"
:icon-image="provider.iconImage"
:to="`/settings/providers/${provider.id}`"
:configured="provider.configured"
/>
</div>
<div flex="~ row items-center gap-2" my-5>
<div i-solar:user-speak-rounded-bold-duotone text="neutral-500 dark:neutral-400 4xl" />
<div>
<div>
<span text="neutral-300 dark:neutral-500 sm sm:base">Speech (text-to-speech) model providers. e.g. ElevenLabs, Azure Speech.</span>
</div>
<div flex text-nowrap text="2xl sm:3xl" font-normal>
<div>
Speech
</div>
</div>
</div>
</div>
<div grid="~ cols-1 sm:cols-2 xl:cols-3 gap-4">
<IconStatusItem
v-for="(provider, index) of allAudioSpeechProvidersMetadata"
:key="provider.id"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + index * 10"
:delay="(allChatProvidersMetadata.length + index) * 50"
:title="provider.localizedName || 'Unknown'"
:description="provider.localizedDescription"
:icon="provider.icon"
:icon-color="provider.iconColor"
:icon-image="provider.iconImage"
:to="`/settings/providers/${provider.id}`"
:configured="provider.configured"
/>
</div>
<div flex="~ row items-center gap-2" my-5>
<div i-solar:microphone-3-bold-duotone text="neutral-500 dark:neutral-400 4xl" />
<div>
<div>
<span text="neutral-300 dark:neutral-500 sm sm:base">Transcription (speech-to-text) model providers. e.g. Whisper.cpp, OpenAI, Azure Speech</span>
</div>
<div flex text-nowrap text="2xl sm:3xl" font-normal>
<div>
Transcription
</div>
</div>
</div>
</div>
<div grid="~ cols-1 sm:cols-2 xl:cols-3 gap-4">
<IconStatusItem
v-for="(provider, index) of allAudioTranscriptionProvidersMetadata"
:key="provider.id"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + index * 10"
:delay="(allChatProvidersMetadata.length + allAudioSpeechProvidersMetadata.length + index) * 50"
:title="provider.localizedName || 'Unknown'"
:description="provider.localizedDescription"
:icon="provider.icon"
:icon-color="provider.iconColor"
:icon-image="provider.iconImage"
:to="`/settings/providers/${provider.id}`"
:configured="provider.configured"
/>
</div>
</div>
<IconAnimation
v-if="showIconAnimation"
:z-index="-1"
:icon="animationIcon"
:icon-size="12"
:duration="1000"
:started="iconAnimationStarted"
:is-reverse="true"
position="calc(100dvw - 9.5rem), calc(100dvh - 9.5rem)"
text-color="text-neutral-200/50 dark:text-neutral-600/20"
/>
<div
v-else
v-motion
text="neutral-500/5 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-15rem)]" bottom-0 right--5 z--1
:initial="{ scale: 0.9, opacity: 0, y: 20 }"
:enter="{ scale: 1, opacity: 1, y: 0 }"
:duration="500"
size-60
flex items-center justify-center
>
<div text="60" i-solar:box-minimalistic-bold-duotone />
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
pageSpecificAvailable: true
</route>
@@ -0,0 +1,105 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'lm-studio'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="lm-studio"
:is-required="false"
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="http://localhost:1234/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,190 @@
<script setup lang="ts">
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
import type { UnMicrosoftOptions } from 'unspeech'
import {
SpeechPlayground,
SpeechProviderSettings,
} from '@proj-airi/stage-ui/components'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldInput, FieldRange } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const providerId = 'microsoft-speech'
const defaultModel = 'v1'
// Default voice settings specific to Microsoft Speech
const defaultVoiceSettings = {
pitch: 0,
speed: 1.0,
volume: 0,
}
const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore)
const pitch = ref(0)
const speed = ref(1.0)
const volume = ref(0)
// Additional settings specific to Microsoft Speech (region)
const region = computed({
get: () => providers.value[providerId]?.region as string | undefined || 'eastasia',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = { region: 'eastasia' }
providers.value[providerId].region = value
},
})
// Check if API key is configured
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
// Get available voices for Microsoft Speech
const availableVoices = computed(() => {
return speechStore.availableVoices[providerId] || []
})
onMounted(async () => {
if (!region.value) {
region.value = 'eastasia' // Default region
}
if (!providers.value[providerId]?.region) {
if (!providers.value[providerId])
providers.value[providerId] = { region: region.value }
else
providers.value[providerId].region = region.value
}
await speechStore.loadVoicesForProvider(providerId)
})
watch([apiKeyConfigured, region], async () => {
await speechStore.loadVoicesForProvider(providerId)
})
// Generate speech with Microsoft-specific parameters
async function handleGenerateSpeech(input: string, voiceId: string, useSSML: boolean) {
const provider = await providersStore.getProviderInstance(providerId) as SpeechProviderWithExtraOptions<string, UnMicrosoftOptions>
if (!provider) {
throw new Error('Failed to initialize speech provider')
}
// Get provider configuration
const providerConfig = providersStore.getProviderConfig(providerId)
// Get model from configuration or use default
const model = providerConfig.model as string | undefined || defaultModel
// For Microsoft Speech, we need to ensure we're using the right region
const options = {
...providerConfig,
region: region.value,
disableSsml: !useSSML, // If useSSML is true, we don't disable SSML
}
// If not using SSML and we have a voice, generate SSML
if (!useSSML && voiceId) {
const voice = availableVoices.value.find(v => v.id === voiceId)
if (voice) {
const ssml = speechStore.generateSSML(
input,
voice,
{ ...providerConfig, pitch: pitch.value },
)
return await speechStore.speech(
provider,
model,
ssml,
voiceId,
options,
)
}
}
// Either using direct SSML or no voice found
return await speechStore.speech(
provider,
model,
input,
voiceId,
options,
)
}
</script>
<template>
<SpeechProviderSettings
:provider-id="providerId"
:default-model="defaultModel"
:additional-settings="defaultVoiceSettings"
>
<!-- Basic settings specific to Microsoft Speech -->
<template #basic-settings>
<FieldInput
v-model="region"
:label="t('settings.pages.providers.provider.microsoft-speech.fields.field.region.label')"
:description="t('settings.pages.providers.provider.microsoft-speech.fields.field.region.description')"
placeholder="eastasia"
required
type="text"
/>
</template>
<!-- Voice settings specific to ElevenLabs -->
<template #voice-settings>
<div flex="~ col gap-4">
<!-- Pitch control - common to most providers -->
<FieldRange
v-model="pitch"
:label="t('settings.pages.providers.provider.common.fields.field.pitch.label')"
:description="t('settings.pages.providers.provider.common.fields.field.pitch.description')"
:min="-100"
:max="100" :step="1" :format-value="value => `${value}%`"
/>
<!-- Speed control - common to most providers -->
<FieldRange
v-model="speed"
:label="t('settings.pages.providers.provider.common.fields.field.speed.label')"
:description="t('settings.pages.providers.provider.common.fields.field.speed.description')"
:min="0.5"
:max="2.0" :step="0.01"
/>
<!-- Volume control - available in some providers -->
<FieldRange
v-model="volume"
:label="t('settings.pages.providers.provider.common.fields.field.volume.label')"
:description="t('settings.pages.providers.provider.common.fields.field.volume.description')"
:min="-100"
:max="100" :step="1" :format-value="value => `${value}%`"
/>
</div>
</template>
<!-- Replace the default playground with our standalone component -->
<template #playground>
<SpeechPlayground
:available-voices="availableVoices"
:generate-speech="handleGenerateSpeech"
:api-key-configured="apiKeyConfigured"
default-text="Hello! This is a test of the Microsoft Speech synthesis."
/>
</template>
</SpeechProviderSettings>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,104 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'mistral-ai'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.mistral.ai/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,125 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed, onMounted, watch } from 'vue'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Get provider metadata
const providerId = 'modelscope'
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
// Use computed properties for settings
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
onMounted(() => {
// Initialize provider if it doesn't exist
providersStore.initializeProvider(providerId)
// Initialize refs with current values
apiKey.value = providers.value[providerId]?.apiKey || ''
baseUrl.value = providers.value[providerId]?.baseUrl || ''
})
// Watch settings and update the provider configuration
watch([apiKey, baseUrl], () => {
providers.value[providerId] = {
...providers.value[providerId],
apiKey: apiKey.value,
baseUrl: baseUrl.value || '',
}
})
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon="providerMetadata?.icon"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="ms-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api-inference.modelscope.cn/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,104 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'moonshot-ai'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.moonshot.cn/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,104 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'novita-ai'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.novita.ai/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,176 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldKeyValues } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
const providerId = 'ollama'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || 'http://localhost:11434/v1/',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
const headers = ref<{ key: string, value: string }[]>(Object.entries(providers.value[providerId]?.headers).map(([key, value]) => ({ key, value } as { key: string, value: string })) || [{ key: '', value: '' }])
function addKeyValue(headers: { key: string, value: string }[], key: string, value: string) {
if (!headers)
return
headers.push({ key, value })
}
function removeKeyValue(index: number, headers: { key: string, value: string }[]) {
if (!headers)
return
if (headers.length === 1) {
headers[0].key = ''
headers[0].value = ''
}
else {
headers.splice(index, 1)
}
}
watch(headers, (headers) => {
if (headers.length > 0 && (headers[headers.length - 1].key !== '' || headers[headers.length - 1].value !== '')) {
headers.push({ key: '', value: '' })
}
providers.value[providerId].headers = headers.filter(header => header.key !== '').reduce((acc, header) => {
acc[header.key] = header.value
return acc
}, {} as Record<string, string>)
}, {
deep: true,
immediate: true,
})
async function refetch() {
try {
const validationResult = await providerMetadata.value.validators.validateProviderConfig({
baseUrl: baseUrl.value,
headers: headers.value.filter(header => header.key !== '').reduce((acc, header) => {
acc[header.key] = header.value
return acc
}, {} as Record<string, string>),
})
if (!validationResult.valid) {
validationMessage.value = t('settings.dialogs.onboarding.validationError', {
error: validationResult.reason,
})
}
}
catch (error) {
validationMessage.value = t('settings.dialogs.onboarding.validationError', {
error: error instanceof Error ? error.message : String(error),
})
}
}
watch([baseUrl, headers], refetch, { immediate: true })
watch(headers, refetch, { deep: true })
onMounted(() => {
providersStore.initializeProvider(providerId)
// Initialize refs with current values
baseUrl.value = providers.value[providerId]?.baseUrl || providerMetadata.value?.defaultOptions?.().baseUrl || ''
// Initialize headers if not already set
if (!providers.value[providerId]?.headers) {
providers.value[providerId].headers = {}
}
if (headers.value.length === 0) {
headers.value = [{ key: '', value: '' }]
}
})
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="http://localhost:11434/v1/"
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<FieldKeyValues
v-model="headers"
:label="t('settings.pages.providers.common.section.advanced.fields.field.headers.label')"
:description="t('settings.pages.providers.common.section.advanced.fields.field.headers.description')"
:key-placeholder="t('settings.pages.providers.common.section.advanced.fields.field.headers.key.placeholder')"
:value-placeholder="t('settings.pages.providers.common.section.advanced.fields.field.headers.value.placeholder')"
@add="(key: string, value: string) => addKeyValue(headers, key, value)"
@remove="(index: number) => removeKeyValue(index, headers)"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,103 @@
<script setup lang="ts">
import type { SpeechProvider } from '@xsai-ext/shared-providers'
import {
SpeechPlayground,
SpeechProviderSettings,
} from '@proj-airi/stage-ui/components'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldRange } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore)
const { t } = useI18n()
const defaultVoiceSettings = {
speed: 1.0,
}
// Get provider metadata
const providerId = 'openai-audio-speech'
const defaultModel = 'gpt-4o-mini-tts'
const speed = ref<number>(1.0)
// Check if API key is configured
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
const availableVoices = computed(() => {
return speechStore.availableVoices[providerId] || []
})
// Generate speech with ElevenLabs-specific parameters
async function handleGenerateSpeech(input: string, voiceId: string, _useSSML: boolean) {
const provider = await providersStore.getProviderInstance<SpeechProvider<string>>(providerId)
if (!provider) {
throw new Error('Failed to initialize speech provider')
}
// Get provider configuration
const providerConfig = providersStore.getProviderConfig(providerId)
// Get model from configuration or use default
const model = providerConfig.model as string | undefined || defaultModel
// ElevenLabs doesn't need SSML conversion, but if SSML is provided, use it directly
return await speechStore.speech(
provider,
model,
input,
voiceId,
{
...providerConfig,
...defaultVoiceSettings,
},
)
}
watch(speed, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
providerConfig.speed = speed.value
})
</script>
<template>
<SpeechProviderSettings
:provider-id="providerId"
:default-model="defaultModel"
:additional-settings="defaultVoiceSettings"
>
<!-- Voice settings specific to ElevenLabs -->
<template #voice-settings>
<!-- Speed control - common to most providers -->
<FieldRange
v-model="speed"
:label="t('settings.pages.providers.provider.common.fields.field.speed.label')"
:description="t('settings.pages.providers.provider.common.fields.field.speed.description')"
:min="0.5"
:max="2.0" :step="0.01"
/>
</template>
<template #playground>
<SpeechPlayground
:available-voices="availableVoices"
:generate-speech="handleGenerateSpeech"
:api-key-configured="apiKeyConfigured"
default-text="Hello! This is a test of the OpenAI Speech."
/>
</template>
</SpeechProviderSettings>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,66 @@
<script setup lang="ts">
import type { TranscriptionProvider } from '@xsai-ext/shared-providers'
import {
TranscriptionPlayground,
TranscriptionProviderSettings,
} from '@proj-airi/stage-ui/components'
import { useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const hearingStore = useHearingStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore)
// Get provider metadata
const providerId = 'openai-audio-transcription'
const defaultModel = 'whisper-1'
// Check if API key is configured
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
// Generate speech with ElevenLabs-specific parameters
async function handleGenerateTranscription(file: File) {
const provider = await providersStore.getProviderInstance<TranscriptionProvider<string>>(providerId)
if (!provider) {
throw new Error('Failed to initialize speech provider')
}
// Get provider configuration
const providerConfig = providersStore.getProviderConfig(providerId)
// Get model from configuration or use default
const model = providerConfig.model as string | undefined || defaultModel
// ElevenLabs doesn't need SSML conversion, but if SSML is provided, use it directly
return await hearingStore.transcription(
provider,
model,
file,
'json',
)
}
</script>
<template>
<TranscriptionProviderSettings
:provider-id="providerId"
:default-model="defaultModel"
>
<template #playground>
<TranscriptionPlayground
:generate-transcription="handleGenerateTranscription"
:api-key-configured="apiKeyConfigured"
/>
</template>
</TranscriptionProviderSettings>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,170 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import type { SpeechProvider } from '@xsai-ext/shared-providers'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
SpeechPlaygroundOpenAICompatible,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldRange } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
const defaultVoiceSettings = {
speed: 1.0,
}
// Get provider metadata
const providerId = 'openai-compatible-audio-speech'
// Settings refs
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (providers.value[providerId])
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (providers.value[providerId])
providers.value[providerId].baseUrl = value
},
})
const model = computed({
get: () => providers.value[providerId]?.model || 'tts-1',
set: (value) => {
if (providers.value[providerId])
providers.value[providerId].model = value
},
})
const voice = computed({
get: () => providers.value[providerId]?.voice || 'alloy',
set: (value) => {
if (providers.value[providerId])
providers.value[providerId].voice = value
},
})
const speed = ref<number>(1.0)
// Check if API key is configured
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
// Generate speech with specific parameters
async function handleGenerateSpeech(input: string, voiceId: string, _useSSML: boolean, modelId?: string) {
const provider = await providersStore.getProviderInstance<SpeechProvider<string>>(providerId)
if (!provider)
throw new Error('Failed to initialize speech provider')
const providerConfig = providersStore.getProviderConfig(providerId)
return await speechStore.speech(
provider,
modelId || model.value,
input,
voiceId || voice.value,
{
...providerConfig,
...defaultVoiceSettings,
speed: speed.value,
},
)
}
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.openai.com/v1/"
/>
<FieldRange
v-model="speed"
:label="t('settings.pages.providers.provider.common.fields.field.speed.label')"
:description="t('settings.pages.providers.provider.common.fields.field.speed.description')"
:min="0.5"
:max="2.0" :step="0.01"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
<SpeechPlaygroundOpenAICompatible
v-model:model-value="model"
v-model:voice="voice"
:generate-speech="handleGenerateSpeech"
:api-key-configured="apiKeyConfigured"
default-text="Hello! This is a test of the OpenAI Compatible Speech."
/>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,145 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import type { TranscriptionProvider } from '@xsai-ext/shared-providers'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
TranscriptionPlayground,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation'
import { useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldInput } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'openai-compatible-audio-transcription'
const hearingStore = useHearingStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
const model = computed({
get: () => providers.value[providerId]?.model || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].model = value
},
})
// Check if API key is configured
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
// Generate transcription
async function handleGenerateTranscription(file: File) {
const provider = await providersStore.getProviderInstance<TranscriptionProvider<string>>(providerId)
if (!provider)
throw new Error('Failed to initialize transcription provider')
return await hearingStore.transcription(
provider,
model.value,
file,
'json',
)
}
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-..."
/>
<FieldInput
v-model="model"
:label="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.manual_model_name')"
:placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.manual_model_placeholder')"
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.openai.com/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
<TranscriptionPlayground
:generate-transcription="handleGenerateTranscription"
:api-key-configured="apiKeyConfigured"
/>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,104 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'openai-compatible'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.openai.com/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,104 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'openai'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.openai.com/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,104 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'openrouter-ai'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-or-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://openrouter.ai/api/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,133 @@
<script setup lang="ts">
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
import type { UnElevenLabsOptions } from 'unspeech'
import {
Alert,
SpeechPlayground,
SpeechProviderSettings,
} from '@proj-airi/stage-ui/components'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldRange } from '@proj-airi/ui'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const providerId = 'player2-speech'
const defaultModel = 'v1'
const speedRatio = ref<number>(1.0)
const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
const { t } = useI18n()
// Get available voices for Player2
const availableVoices = computed(() => {
return speechStore.availableVoices[providerId] || []
})
// Generate speech with Player2-specific parameters
async function handleGenerateSpeech(input: string, voiceId: string, _useSSML: boolean) {
const provider = await providersStore.getProviderInstance(providerId) as SpeechProviderWithExtraOptions<string, UnElevenLabsOptions>
if (!provider) {
throw new Error('Failed to initialize speech provider')
}
// Get provider configuration
const providerConfig = providersStore.getProviderConfig(providerId)
// Get model from configuration or use default
const model = providerConfig.model as string | undefined || defaultModel
// Player2 doesn't need SSML conversion, but if SSML is provided, use it directly
return await speechStore.speech(
provider,
model,
input,
voiceId,
{
...providerConfig,
},
)
}
const hasPlayer2 = ref(true)
onMounted(async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
const providerMetadata = providersStore.getProviderMetadata(providerId)
if (await providerMetadata.validators.validateProviderConfig(providerConfig)) {
await speechStore.loadVoicesForProvider(providerId)
}
else {
console.error('Failed to validate provider config', providerConfig)
}
try {
const baseUrl = (providerConfig.baseUrl as string | undefined) ?? ''
const res = await fetch(`${baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl}/health`, {
method: 'GET',
headers: {
'player2-game-key': 'airi',
},
})
hasPlayer2.value = res.status === 200
}
catch (e) {
console.error(e)
hasPlayer2.value = false
}
})
watch(speedRatio, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
providerConfig.speed = speedRatio.value
})
</script>
<template>
<SpeechProviderSettings
:provider-id="providerId"
:default-model="defaultModel"
>
<template #voice-settings>
<!-- Speed control - common to most providers -->
<FieldRange
v-model="speedRatio"
:label="t('settings.pages.providers.provider.common.fields.field.speed.label')"
:description="t('settings.pages.providers.provider.common.fields.field.speed.description')"
:min="0.5"
:max="5.0" :step="0.01"
/>
</template>
<!-- Replace the default playground with our standalone component -->
<template #playground>
<SpeechPlayground
:available-voices="availableVoices"
:generate-speech="handleGenerateSpeech"
:api-key-configured="true"
default-text="Hello! This is a test of the Player 2 voice synthesis."
/>
</template>
</SpeechProviderSettings>
<Alert v-if="!hasPlayer2" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template #content>
<div class="whitespace-pre-wrap break-all">
<div>
Please download and run the Player2 App:
<a href="https://player2.game" target="_blank" rel="noopener noreferrer">
https://player2.game
</a>
<div>
After downloading, if you still are having trouble, please reach out to us on Discord:
<a href="https://player2.game/discord" target="_blank" rel="noopener noreferrer">
https://player2.game/discord
</a>.
</div>
</div>
</div>
</template>
</Alert>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/shared'
import {
Alert,
ProviderBaseUrlInput,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Get provider metadata
const providerId = 'player2'
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
const hasPlayer2 = ref(true)
onMounted(async () => {
providersStore.initializeProvider(providerId)
baseUrl.value = providers.value[providerId]?.baseUrl || ''
try {
const res = await fetch(`${baseUrl.value}health`, {
method: 'GET',
headers: {
'player2-game-key': 'airi',
},
})
hasPlayer2.value = res.status === 200
}
catch (e) {
console.error(e)
hasPlayer2.value = false
}
})
// Watch settings and update the provider configuration
watch([baseUrl], () => {
providers.value[providerId] = {
...providers.value[providerId],
baseUrl: baseUrl.value || '',
}
})
function handleResetSettings() {
providers.value[providerId] = {
...(providerMetadata.value?.defaultOptions as any),
}
}
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon="providerMetadata?.icon"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderBaseUrlInput v-model="baseUrl" placeholder="http://localhost:4315/v1/" />
</ProviderBasicSettings>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
<Alert v-if="!hasPlayer2" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template #content>
<div class="whitespace-pre-wrap break-all">
<div>
Please download and run the Player2 App:
<a href="https://player2.game" target="_blank" rel="noopener noreferrer">
https://player2.game
</a>
<div>
After downloading, if you still are having trouble, please reach out to us on Discord:
<a href="https://player2.game/discord" target="_blank" rel="noopener noreferrer">
https://player2.game/discord
</a>.
</div>
</div>
</div>
</template>
</Alert>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation'
const providerId = 'together-ai'
const {
t,
router,
providerMetadata,
apiKey,
baseUrl,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.together.xyz/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation'
const providerId = 'vllm'
const {
t,
router,
providerMetadata,
apiKey,
baseUrl,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="token-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="http://localhost:8000/v1"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,151 @@
<script setup lang="ts">
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
import type { UnElevenLabsOptions } from 'unspeech'
import {
SpeechPlayground,
SpeechProviderSettings,
} from '@proj-airi/stage-ui/components'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldInput, FieldRange } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const providerId = 'volcengine'
const defaultModel = 'v1'
const speedRatio = ref<number>(1.0)
const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore)
const { t } = useI18n()
// Additional settings specific to Volcengine (appId)
const appId = computed({
get: () => (providers.value[providerId]?.app as any)?.appId as string | undefined || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].app = {
appId: value,
}
},
})
// Check if API key is configured
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
// Get available voices for ElevenLabs
const availableVoices = computed(() => {
return speechStore.availableVoices[providerId] || []
})
// Generate speech with ElevenLabs-specific parameters
async function handleGenerateSpeech(input: string, voiceId: string, _useSSML: boolean) {
const provider = await providersStore.getProviderInstance(providerId) as SpeechProviderWithExtraOptions<string, UnElevenLabsOptions>
if (!provider) {
throw new Error('Failed to initialize speech provider')
}
// Get provider configuration
const providerConfig = providersStore.getProviderConfig(providerId)
// Get model from configuration or use default
const model = providerConfig.model as string | undefined || defaultModel
// ElevenLabs doesn't need SSML conversion, but if SSML is provided, use it directly
return await speechStore.speech(
provider,
model,
input,
voiceId,
{
...providerConfig,
},
)
}
onMounted(async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
const providerMetadata = providersStore.getProviderMetadata(providerId)
if (await providerMetadata.validators.validateProviderConfig(providerConfig)) {
await speechStore.loadVoicesForProvider(providerId)
}
else {
console.error('Failed to validate provider config', providerConfig)
}
})
watch(speedRatio, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
if (!providerConfig.audio) {
providerConfig.audio = {}
}
(providerConfig.audio as any).speedRatio = speedRatio.value
})
watch([providers, appId], async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
const providerMetadata = providersStore.getProviderMetadata(providerId)
if (await providerMetadata.validators.validateProviderConfig(providerConfig)) {
await speechStore.loadVoicesForProvider(providerId)
}
else {
console.error('Failed to validate provider config', providerConfig)
}
}, {
immediate: true,
})
</script>
<template>
<SpeechProviderSettings
:provider-id="providerId"
:default-model="defaultModel"
>
<!-- Voice settings specific to ElevenLabs -->
<template #basic-settings>
<div flex="~ col gap-4">
<FieldInput
v-model="appId"
:label="t('settings.pages.providers.provider.volcengine.fields.field.appId.label')"
:description="t('settings.pages.providers.provider.volcengine.fields.field.appId.description')"
required
/>
</div>
</template>
<template #voice-settings>
<!-- Speed control - common to most providers -->
<FieldRange
v-model="speedRatio"
:label="t('settings.pages.providers.provider.common.fields.field.speed.label')"
:description="t('settings.pages.providers.provider.common.fields.field.speed.description')"
:min="0.5"
:max="2.0" :step="0.01"
/>
</template>
<!-- Replace the default playground with our standalone component -->
<template #playground>
<SpeechPlayground
:available-voices="availableVoices"
:generate-speech="handleGenerateSpeech"
:api-key-configured="apiKeyConfigured"
default-text="Hello! This is a test of the ElevenLabs voice synthesis."
/>
</template>
</SpeechProviderSettings>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation'
const providerId = 'xai'
const {
t,
router,
providerMetadata,
apiKey,
baseUrl,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="xai-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.x.ai/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,57 @@
<script setup lang="ts">
import IconAnimation from '../../../components/IconAnimation.vue'
import { useIconAnimation } from '../../../composables/icon-animation'
const {
iconAnimationStarted,
showIconAnimation,
animationIcon,
} = useIconAnimation('i-solar:armchair-2-bold-duotone')
</script>
<template>
<div>
<Callout
label="In development, needs your help!"
theme="orange"
>
<div>
This functionality is still under development. If you have any suggestions or would like to contribute, please reach out to us on our <a underline decoration-dotted href="https://github.com/moeru-ai/airi/issues">GitHub issues page</a>.
The source code of this page is located at <a underline decoration-dotted href="https://github.com/moeru-ai/airi/tree/main/apps/stage-tamagotchi/src/pages/settings/scene/index.vue">here</a>.
</div>
</Callout>
</div>
<IconAnimation
v-if="showIconAnimation"
:z-index="-1"
:icon="animationIcon"
:icon-size="12"
:duration="1000"
:started="iconAnimationStarted"
:is-reverse="true"
position="calc(100dvw - 9.5rem), calc(100dvh - 9.5rem)"
text-color="text-neutral-200/50 dark:text-neutral-600/20"
/>
<div
v-else
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-15rem)]" bottom-0 right--5 z--1
:initial="{ scale: 0.9, opacity: 0, y: 20 }"
:enter="{ scale: 1, opacity: 1, y: 0 }"
:duration="500"
size-60
flex items-center justify-center
>
<div text="60" i-solar:armchair-2-bold-duotone />
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
pageSpecificAvailable: true
</route>
@@ -0,0 +1,8 @@
[
[],
["#A5978B", "#D8CAAF", "#B8B4A7", "#C4BCB1", "#E5DED8", "#9A8F7D", "#BEB5A7", "#C9C0B6"],
["#7A9EAF", "#B8C7CC", "#D4B79C", "#8B9D77", "#C7D5CB", "#E6D0B1", "#94A7B1", "#B4C8C3"],
["#D9B48F", "#B5917A", "#8C7A6B", "#A17F5F", "#B98C46", "#C7A252", "#DAB300", "#D19826"],
["#9BA7B0", "#C1CBD4", "#A5ADB6", "#8B959E", "#D4DCE4", "#7F8A94", "#B3BCC6", "#98A4AE"],
["#E4C6D0", "#A61B29", "#5D513C", "#789262", "#1C0D1A", "#F7C242", "#62A9DD", "#8C4B3C"]
]
@@ -0,0 +1,187 @@
<script setup lang="ts">
import { ColorPalette, Section } from '@proj-airi/stage-ui/components'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { ColorHueRange } from '@proj-airi/ui'
import { useI18n } from 'vue-i18n'
import COLOR_PRESETS from './color-presets.json'
const settings = useSettings()
const { t } = useI18n()
</script>
<template>
<Section
v-motion
mb-2
:title="t('settings.pages.themes.sections.section.custom-color.title')"
icon="i-solar:pallete-2-bold-duotone"
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (4 * 10)"
:delay="4 * 50"
transition="all ease-in-out duration-250"
>
<div
v-motion flex items-center
justify-between
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (5 * 10)"
:delay="5 * 50"
transition="all ease-in-out duration-250"
>
<span text-lg font-normal>{{ $t('settings.pages.themes.sections.section.custom-color.fields.field.primary-color.label') }}</span>
<label relative flex cursor-pointer items-center gap-2>
<input
v-model="settings.themeColorsHueDynamic"
type="checkbox"
class="peer sr-only"
>
<div
class="h-6 w-11 rounded-full bg-neutral-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white dark:bg-neutral-600 peer-checked:bg-primary-500 after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
/>
{{ $t('settings.pages.themes.sections.section.custom-color.fields.field.primary-color.rgb-on.title') }}
</label>
</div>
<ColorHueRange
v-model="settings.themeColorsHue"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (6 * 10)"
:delay="6 * 50"
:disabled="settings.themeColorsHueDynamic"
/>
<div
v-motion
class="color-bar text-[10px] md:text-base sm:text-xs"
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (7 * 10)"
:delay="7 * 50"
transition="all ease-in-out duration-250"
>
<span bg-primary-50>50</span>
<span bg-primary-100>100</span>
<span bg-primary-200>200</span>
<span bg-primary-300>300</span>
<span bg-primary-400>400</span>
<span bg-primary-500>500</span>
<div
v-motion
text-white
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (8 * 10)"
:delay="8 * 50"
transition="all ease-in-out duration-250"
>
<span bg-primary-600>600</span>
<span bg-primary-700>700</span>
<span bg-primary-800>800</span>
<span bg-primary-900>900</span>
<span bg-primary-950>950</span>
</div>
</div>
<div
v-motion
class="color-bar transparency-grid text-[10px] md:text-base sm:text-xs"
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (9 * 10)"
:delay="9 * 50"
transition="all ease-in-out duration-250"
>
<span bg="primary-500/5">500/5</span>
<span bg="primary-500/10">500/10</span>
<span bg="primary-500/20">500/20</span>
<span bg="primary-500/30">500/30</span>
<span bg="primary-500/40">500/40</span>
<span bg="primary-500/50">500/50</span>
<span bg="primary-500/60">500/60</span>
<span bg="primary-500/70">500/70</span>
<span bg="primary-500/80">500/80</span>
<span bg="primary-500/90">500/90</span>
<span bg="primary-500">500</span>
</div>
</Section>
<Section
v-motion
mb-2 :title="t('settings.pages.themes.sections.section.theme-presets.title')"
icon="i-solar:magic-stick-2-bold-duotone"
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (10 * 10)"
:delay="10 * 50"
transition="all ease-in-out duration-250"
>
<div
v-for="({ title, description, colors }, i) in $tm('settings.pages.themes.sections.section.theme-presets.presets')" :key="i"
v-motion
class="w-full flex flex-col items-start justify-between gap-2 rounded-lg px-4 py-3 outline-none transition-all duration-250 ease-in-out md:flex-row md:items-center md:gap-0"
bg="neutral-100 dark:neutral-800"
hover="bg-neutral-200 dark:bg-neutral-700"
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (11 * 10) + (i * 10)"
:delay="11 * 50 + (i * 50)"
transition="all ease-in-out duration-250"
>
<div>
<span font-medium>{{ $rt(title) }}</span>
<div text="sm neutral-500">
{{ $rt(description) }}
</div>
</div>
<ColorPalette :colors="(colors as any[]).map((name, j) => ({ hex: COLOR_PRESETS[i][j], name: $rt(name) }))" />
</div>
</Section>
<div
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[65dvh]" right--15 z--1
:initial="{ scale: 0.9, opacity: 0, rotate: 30 }"
:enter="{ scale: 1, opacity: 1, rotate: 0 }"
:duration="250"
flex items-center justify-center
>
<div text="60" i-solar:pallete-2-bold-duotone />
</div>
</template>
<style scoped>
.color-bar {
--at-apply: flex of-hidden rounded-lg lh-10 text-center text-black;
* {
flex: 1;
}
div {
display: contents;
}
}
.transparency-grid {
background-image: linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%);
background-size: 20px 20px;
background-position:
0 0,
0 10px,
10px -10px,
-10px 0px;
background-color: #fff;
}
</style>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { IconItem } from '@proj-airi/stage-ui/components'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import CheckBar from '../../../components/Settings/CheckBar.vue'
const { t } = useI18n()
const settings = useSettings()
const menu = computed(() => [
{
title: t('settings.pages.system.sections.section.developer.sections.section.use-magic-keys.title'),
description: t('settings.pages.system.sections.section.developer.sections.section.use-magic-keys.description'),
icon: 'i-solar:sledgehammer-bold-duotone',
to: '/devtools/use-magic-keys',
},
])
</script>
<template>
<CheckBar
v-model="settings.disableTransitions"
v-motion
mb-2
icon-on="i-solar:people-nearby-bold-duotone"
icon-off="i-solar:running-2-line-duotone"
text="settings.animations.stage-transitions.title"
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (19 * 10)"
:delay="1 * 50"
transition="all ease-in-out duration-250"
/>
<CheckBar
v-model="settings.usePageSpecificTransitions"
v-motion
:disabled="settings.disableTransitions"
icon-on="i-solar:running-2-line-duotone"
icon-off="i-solar:people-nearby-bold-duotone"
text="settings.animations.use-page-specific-transitions.title"
description="settings.animations.use-page-specific-transitions.description"
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (20 * 10)"
:delay="2 * 50"
transition="all ease-in-out duration-250"
/>
<div flex="~ col gap-4" pb-12>
<IconItem
v-for="(item, index) in menu"
:key="item.to"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250"
:style="{
transitionDelay: `${index * 50}ms`, // delay between each item, unocss doesn't support dynamic generation of classes now
}"
:title="item.title"
:description="item.description"
:icon="item.icon"
:to="item.to"
/>
</div>
<div
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[65dvh]" right--15 z--1
:initial="{ scale: 0.9, opacity: 0, rotate: 30 }"
:enter="{ scale: 1, opacity: 1, rotate: 0 }"
:duration="250"
flex items-center justify-center
>
<div text="60" i-solar:code-bold-duotone />
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { all } from '@proj-airi/i18n'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { FieldCheckbox, FieldSelect } from '@proj-airi/ui'
import { useDark } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const settings = useSettings()
const { t } = useI18n()
const { allowVisibleOnAllWorkspaces } = storeToRefs(settings)
const dark = useDark()
const languages = computed(() => {
return Object.entries(all).map(([value, label]) => ({ label, value }))
})
</script>
<template>
<div rounded-lg bg-neutral-50 p-4 dark:bg-neutral-800 flex="~ col gap-4">
<FieldCheckbox
v-model="dark"
v-motion
mb-2
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (2 * 10)"
:delay="2 * 50"
:label="t('settings.theme.title')"
:description="t('settings.theme.description')"
/>
<FieldCheckbox
v-model="allowVisibleOnAllWorkspaces"
v-motion
mb-2
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (2 * 10)"
:delay="2 * 50"
:label="t('tamagotchi.settings.allow-visible-on-all-workspaces.title')"
:description="t('tamagotchi.settings.allow-visible-on-all-workspaces.description')"
/>
<!-- Language Setting -->
<FieldSelect
v-model="settings.language"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (3 * 10)"
:delay="3 * 50"
transition="all ease-in-out duration-250"
:label="t('settings.language.title')"
:description="t('settings.language.description')"
:options="languages"
/>
<div
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[65dvh]" right--15 z--1
:initial="{ scale: 0.9, opacity: 0, rotate: 30 }"
:enter="{ scale: 1, opacity: 1, rotate: 0 }"
:duration="250"
flex items-center justify-center
>
<div text="60" i-solar:emoji-funny-square-bold-duotone />
</div>
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff. Show more