refactor(stage-tamagotchi-electron): init stage-tamagotchi-electron app
Co-authored-by: LemonNeko <17664845+LemonNekoGH@users.noreply.github.com>
This commit is contained in:
123 files changed
+14590
-154
No files matched your search
Vendored
+39
@@ -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/**'],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
+97
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
+89
@@ -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)">
|
||||
×
|
||||
</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>
|
||||
+36
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<slot />
|
||||
</template>
|
||||
+76
@@ -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>
|
||||
+85
@@ -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
|
||||
// }
|
||||
+134
@@ -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>
|
||||
+22
@@ -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>
|
||||
+281
@@ -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>
|
||||
+352
@@ -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>
|
||||
+101
@@ -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>
|
||||
+73
@@ -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>
|
||||
+16
@@ -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>
|
||||
+16
@@ -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>
|
||||
+162
@@ -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>
|
||||
+139
@@ -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>
|
||||
+103
@@ -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>
|
||||
+104
@@ -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>
|
||||
+104
@@ -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>
|
||||
+104
@@ -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>
|
||||
+83
@@ -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>
|
||||
+190
@@ -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>
|
||||
+103
@@ -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>
|
||||
+66
@@ -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>
|
||||
+170
@@ -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>
|
||||
+145
@@ -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>
|
||||
+104
@@ -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>
|
||||
+104
@@ -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>
|
||||
+133
@@ -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
Reference in new issue
Block a user