feat(stage-tamagotchi): settings from web ui
This commit is contained in:
@@ -116,7 +116,7 @@ function createSettingsWindow() {
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
settingsWindow.on('close', () => {
|
||||
settingsWindow.on('closed', () => {
|
||||
settingsWindow = null
|
||||
})
|
||||
|
||||
@@ -153,6 +153,12 @@ app.whenReady().then(() => {
|
||||
|
||||
ipcMain.on('open-settings', () => createSettingsWindow())
|
||||
|
||||
ipcMain.on('close-settings', () => {
|
||||
if (settingsWindow) {
|
||||
settingsWindow.close()
|
||||
}
|
||||
})
|
||||
|
||||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div 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>
|
||||
@@ -2,6 +2,7 @@ import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
|
||||
import Tres from '@tresjs/core'
|
||||
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'
|
||||
@@ -10,13 +11,13 @@ import App from './App.vue'
|
||||
import { i18n } from './modules/i18n'
|
||||
import '@unocss/reset/tailwind.css'
|
||||
import 'uno.css'
|
||||
import './main.css'
|
||||
import './styles/main.css'
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
routes: setupLayouts(routes),
|
||||
})
|
||||
|
||||
createApp(App)
|
||||
|
||||
@@ -127,3 +127,8 @@ const modeIndicatorClass = computed(() => {
|
||||
width: calc(100% + 4 * var(--wall-width));
|
||||
}
|
||||
</style>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: stage
|
||||
</route>
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Voice } from '@proj-airi/stage-ui/constants'
|
||||
|
||||
import { voiceMap } from '@proj-airi/stage-ui/constants'
|
||||
import { useConsciousnessStore, useLLM, useProvidersStore, useSettings, useSpeechStore } from '@proj-airi/stage-ui/stores'
|
||||
import { useShortcutsStore } from '@renderer/stores/shortcuts'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const settings = useSettings()
|
||||
const { shortcuts } = storeToRefs(useShortcutsStore())
|
||||
const supportedModels = ref<{ id: string, name?: string }[]>([])
|
||||
const { models } = useLLM()
|
||||
const { language } = storeToRefs(settings)
|
||||
const consciousnessStore = useConsciousnessStore()
|
||||
const speechStore = useSpeechStore()
|
||||
const providersStore = useProvidersStore()
|
||||
|
||||
const { activeModel } = storeToRefs(consciousnessStore)
|
||||
const { voiceId } = storeToRefs(speechStore)
|
||||
const { providers } = storeToRefs(providersStore)
|
||||
|
||||
const apiKey = ref<string>(providers.value['openrouter-ai']?.apiKey as string || '')
|
||||
const baseUrl = ref<string>(providers.value['openrouter-ai']?.baseUrl as string || '')
|
||||
const elevenLabsApiKey = ref<string>(providers.value.elevenlabs?.apiKey as string || '')
|
||||
|
||||
const recordingFor = ref<string | null>(null)
|
||||
const recordingKeys = ref<{
|
||||
modifier: string[]
|
||||
key: string
|
||||
}>({
|
||||
modifier: [],
|
||||
key: '',
|
||||
})
|
||||
|
||||
function handleModelChange(event: Event) {
|
||||
const target = event.target as HTMLSelectElement
|
||||
const found = supportedModels.value.find(m => m.id === target.value)
|
||||
if (!found) {
|
||||
activeModel.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
activeModel.value = found.id
|
||||
}
|
||||
|
||||
function handleViewChange(event: Event) {
|
||||
const target = event.target as HTMLSelectElement
|
||||
settings.stageView = target.value
|
||||
}
|
||||
|
||||
function handleVoiceChange(event: Event) {
|
||||
const value = (event.target as HTMLSelectElement).value as Voice
|
||||
switch (locale.value) {
|
||||
case 'en':
|
||||
case 'en-US':
|
||||
voiceId.value = value
|
||||
break
|
||||
case 'zh':
|
||||
case 'zh-CN':
|
||||
case 'zh-TW':
|
||||
case 'zh-HK':
|
||||
voiceId.value = value
|
||||
break
|
||||
case 'jp':
|
||||
case 'jp-JP':
|
||||
voiceId.value = value
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
watch([baseUrl, apiKey], async ([baseUrl, apiKey]) => {
|
||||
if (!baseUrl || !apiKey) {
|
||||
supportedModels.value = []
|
||||
return
|
||||
}
|
||||
|
||||
supportedModels.value = await models(baseUrl, apiKey)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!baseUrl.value || !apiKey.value)
|
||||
return
|
||||
|
||||
supportedModels.value = await models(baseUrl.value, apiKey.value)
|
||||
})
|
||||
|
||||
function handleQuit() {
|
||||
window.electron.ipcRenderer.send('quit')
|
||||
}
|
||||
|
||||
// Add function to handle shortcut recording
|
||||
function startRecording(shortcut: typeof shortcuts.value[0]) {
|
||||
recordingFor.value = shortcut.type
|
||||
}
|
||||
|
||||
function isModifierKey(key: string) {
|
||||
return ['Shift', 'Control', 'Alt', 'Meta'].includes(key)
|
||||
}
|
||||
|
||||
// Handle key combinations
|
||||
useEventListener('keydown', (e) => {
|
||||
if (!recordingFor.value)
|
||||
return
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
if (isModifierKey(e.key)) {
|
||||
if (recordingKeys.value.modifier.includes(e.key))
|
||||
return
|
||||
|
||||
recordingKeys.value.modifier.push(e.key)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (recordingKeys.value.modifier.length === 0)
|
||||
return
|
||||
|
||||
recordingKeys.value.key = e.key.toUpperCase()
|
||||
|
||||
const shortcut = shortcuts.value.find(s => s.type === recordingFor.value)
|
||||
if (shortcut)
|
||||
shortcut.shortcut = `${recordingKeys.value.modifier.join('+')}+${recordingKeys.value.key}`
|
||||
|
||||
recordingKeys.value = {
|
||||
modifier: [],
|
||||
key: '',
|
||||
}
|
||||
recordingFor.value = null
|
||||
}, { passive: false })
|
||||
|
||||
// Add click outside handler to cancel recording
|
||||
useEventListener('click', (e) => {
|
||||
if (recordingFor.value) {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('.shortcut-item')) {
|
||||
recordingFor.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const pressKeysMessage = computed(() => {
|
||||
if (recordingKeys.value.modifier.length === 0)
|
||||
return t('settings.press_keys')
|
||||
|
||||
return `${t('settings.press_keys')}: ${recordingKeys.value.modifier.join('+')}+${recordingKeys.value.key}`
|
||||
})
|
||||
|
||||
function isConflict(shortcut: typeof shortcuts.value[0]) {
|
||||
return shortcuts.value.some(s => s.type !== shortcut.type && s.shortcut === shortcut.shortcut)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div m-4>
|
||||
<h2 text="slate-800/80" font-bold>
|
||||
Settings
|
||||
</h2>
|
||||
<div>
|
||||
<div grid="~ cols-[140px_1fr]" my-2 items-center gap-1.5 rounded-lg bg="[#fff6fc]" px-2 py-1 text="primary-400">
|
||||
<div text="xs primary-500">
|
||||
<span>{{ t('settings.openai-base-url.label') }}</span>
|
||||
</div>
|
||||
<div flex="~ row" w-full text="xs">
|
||||
<input
|
||||
v-model="baseUrl" type="text" :placeholder="t('settings.openai-base-url.placeholder_mobile')" h-6
|
||||
w-full rounded-md bg-transparent px-2 py-1 text-right font-mono outline-none
|
||||
>
|
||||
</div>
|
||||
<div text="xs primary-500">
|
||||
<span>{{ t('settings.openai-api-key.label') }}</span>
|
||||
</div>
|
||||
<div flex="~ row" w-full text="xs">
|
||||
<input
|
||||
v-model="apiKey" type="text" :placeholder="t('settings.openai-api-key.placeholder_mobile')" h-6 w-full
|
||||
rounded-md bg-transparent px-2 py-1 text-right font-mono outline-none
|
||||
>
|
||||
</div>
|
||||
<div text="xs primary-500">
|
||||
<span>{{ t('settings.elevenlabs-api-key.label') }}</span>
|
||||
</div>
|
||||
<div flex="~ row" w-full text="xs">
|
||||
<input
|
||||
v-model="elevenLabsApiKey" type="text"
|
||||
:placeholder="t('settings.elevenlabs-api-key.placeholder_mobile')" h-6 w-full rounded-md bg-transparent px-2
|
||||
py-1 text-right font-mono outline-none
|
||||
>
|
||||
</div>
|
||||
<div text="xs primary-500">
|
||||
<span>{{ t('settings.language.title') }}</span>
|
||||
</div>
|
||||
<div flex="~ row" w-full text="xs">
|
||||
<select v-model="language" h-6 w-full rounded-md bg-transparent px-2 py-1 text-right font-mono outline-none>
|
||||
<option value="en-US">
|
||||
English
|
||||
</option>
|
||||
<option value="zh-CN">
|
||||
简体中文
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div text="xs primary-500">
|
||||
<span>{{ t('settings.models') }}</span>
|
||||
</div>
|
||||
<div flex="~ row" w-full text="xs">
|
||||
<select
|
||||
h-6 w-full rounded-md bg-transparent px-2 py-1 text-right font-mono outline-none
|
||||
@change="handleModelChange"
|
||||
>
|
||||
<option disabled class="bg-white">
|
||||
{{ t('stage.select-a-model') }}
|
||||
</option>
|
||||
<option v-if="providersStore.getModelsForProvider('openrouter-ai')" :value="activeModel">
|
||||
{{ activeModel }}
|
||||
</option>
|
||||
<option v-for="m in supportedModels" :key="m.id" :value="m.id">
|
||||
{{ 'name' in m ? `${m.name} (${m.id})` : m.id }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div text="xs primary-500">
|
||||
<span>{{ t('settings.voices') }}</span>
|
||||
</div>
|
||||
<div flex="~ row" w-full text="xs">
|
||||
<select
|
||||
h-6 w-full rounded-md bg-transparent px-2 py-1 text-right font-mono outline-none
|
||||
@change="handleVoiceChange"
|
||||
>
|
||||
<option disabled class="bg-white">
|
||||
{{ t('stage.select-a-voice') }}
|
||||
</option>
|
||||
<option v-for="(voice, index) of Object.entries(voiceMap)" :key="index" :value="voice[1]">
|
||||
{{ voice[0] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 text="slate-800/80" font-bold>
|
||||
View
|
||||
</h2>
|
||||
<div>
|
||||
<div grid="~ cols-[140px_1fr]" my-2 items-center gap-1.5 rounded-lg bg="[#fff6fc]" px-2 py-1 text="primary-400">
|
||||
<div text="xs primary-500">
|
||||
<span>{{ t('settings.viewer') }}</span>
|
||||
</div>
|
||||
<select
|
||||
h-6 w-full rounded-md bg-transparent px-2 py-1 text-right text-xs font-mono outline-none
|
||||
@change="handleViewChange"
|
||||
>
|
||||
<option value="2d">
|
||||
2D
|
||||
</option>
|
||||
<option value="3d">
|
||||
3D
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<h2 text="slate-800/80" font-bold>
|
||||
{{ t('settings.shortcuts.title') }}
|
||||
</h2>
|
||||
<div pb-2>
|
||||
<div grid="~ cols-[140px_1fr]" my-2 items-center gap-1.5 rounded-lg bg="[#fff6fc]" p-2 text="primary-400">
|
||||
<template v-for="shortcut in shortcuts" :key="shortcut.type">
|
||||
<span text="xs primary-500">
|
||||
{{ t(shortcut.name) }}
|
||||
</span>
|
||||
<div
|
||||
class="shortcut-item flex items-center justify-end gap-x-2 px-2 py-0.5"
|
||||
:class="{ recording: recordingFor === shortcut.type }" text="xs primary-500" cursor-pointer
|
||||
@click="startRecording(shortcut)"
|
||||
>
|
||||
<div v-if="recordingFor === shortcut.type" class="pointer-events-none animate-flash animate-count-infinite">
|
||||
{{ pressKeysMessage }}
|
||||
</div>
|
||||
<div v-else class="pointer-events-none">
|
||||
{{ shortcut.shortcut }}
|
||||
</div>
|
||||
<div v-if="isConflict(shortcut)" text="xs primary-500" i-solar:danger-square-bold w-4 />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<h2 text="slate-800/80" font-bold>
|
||||
{{ t('settings.other') }}
|
||||
</h2>
|
||||
<div pb-2>
|
||||
<div
|
||||
grid="~ cols-[140px_1fr]" my-2 items-center gap-1.5 rounded-lg bg="[#fff6fc]" p-2 text="primary-400"
|
||||
@click="handleQuit"
|
||||
>
|
||||
<div text="xs primary-500">
|
||||
<span>
|
||||
{{ t('settings.quit') }}
|
||||
</span>
|
||||
</div>
|
||||
<div text="sm primary-500" text-right>
|
||||
<div i-solar:exit-bold-duotone ml-auto />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import { IconItem } from '@proj-airi/stage-ui/components'
|
||||
import { useSettings } from '@proj-airi/stage-ui/stores'
|
||||
import { useDark } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { language, disableTransitions } = storeToRefs(useSettings())
|
||||
const dark = useDark()
|
||||
|
||||
function handleLanguageChange(event: Event) {
|
||||
const target = event.target as HTMLSelectElement
|
||||
language.value = target.value
|
||||
// Send IPC message to main process for locale change
|
||||
window.electron?.ipcRenderer.send('locale-changed', target.value)
|
||||
}
|
||||
|
||||
// Handle window close
|
||||
function handleClose() {
|
||||
window.electron?.ipcRenderer.send('close-settings')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<button @click="handleClose">
|
||||
<div i-solar:alt-arrow-left-line-duotone text-2xl />
|
||||
</button>
|
||||
<h1 text-3xl>
|
||||
Settings
|
||||
</h1>
|
||||
</div>
|
||||
<div flex="~ col gap-4">
|
||||
<div flex="~ col gap-4">
|
||||
<IconItem title="Modules" description="Thinking, vision, speech synthesis, gaming, etc." icon="i-lucide:blocks" to="/settings/modules" />
|
||||
<!-- <IconItem title="Models" description="Live2D, VRM, etc." icon="i-lucide:person-standing" to="/settings/models" /> -->
|
||||
<IconItem title="Providers" description="LLMs, speech providers, etc." icon="i-lucide:brain" to="/settings/providers" />
|
||||
<IconItem title="Themes" description="Customize your stage!" icon="i-lucide:paintbrush" to="/settings/themes" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 text-2xl>
|
||||
General
|
||||
</h2>
|
||||
</div>
|
||||
<div flex="~ col gap-4">
|
||||
<!-- Language Setting -->
|
||||
<div
|
||||
grid="~ cols-[150px_1fr]"
|
||||
bg="neutral-100 dark:neutral-800"
|
||||
hover="bg-neutral-200 dark:bg-neutral-700"
|
||||
transition="all ease-in-out duration-250"
|
||||
items-center gap-1.5 rounded-lg px-4 py-3
|
||||
>
|
||||
<div text="sm">
|
||||
<span>{{ t('settings.language.title') }}</span>
|
||||
</div>
|
||||
<div flex="~ row" w-full justify-end>
|
||||
<select
|
||||
class="w-32"
|
||||
bg="transparent"
|
||||
text="sm right neutral-800 dark:neutral-100"
|
||||
transition="all ease-in-out duration-250"
|
||||
outline="none"
|
||||
cursor-pointer
|
||||
@change="handleLanguageChange"
|
||||
>
|
||||
<option value="en-US">
|
||||
{{ t('settings.language.english') }}
|
||||
</option>
|
||||
<option value="zh-CN">
|
||||
{{ t('settings.language.chinese') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Theme Setting -->
|
||||
<label
|
||||
bg="neutral-100 dark:neutral-800"
|
||||
hover="bg-neutral-200 dark:bg-neutral-700"
|
||||
transition="all ease-in-out duration-250"
|
||||
w-full flex cursor-pointer rounded-lg px-4 py-3
|
||||
>
|
||||
<input
|
||||
v-model="dark"
|
||||
text="neutral-800 dark:neutral-100"
|
||||
:checked="dark"
|
||||
:aria-checked="dark"
|
||||
type="checkbox"
|
||||
hidden appearance-none outline-none
|
||||
>
|
||||
<div flex="~ row" w-full items-center gap-1.5>
|
||||
<div text="sm" w-full flex-1>
|
||||
<span>{{ t('settings.theme') }}</span>
|
||||
</div>
|
||||
<div select-none>
|
||||
<Transition name="slide-away" mode="out-in">
|
||||
<div
|
||||
v-if="dark"
|
||||
i-solar:moon-stars-bold-duotone
|
||||
transition="all ease-in-out duration-250"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
i-solar:sun-fog-bold-duotone
|
||||
transition="all ease-in-out duration-250"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<!-- Developer Settings -->
|
||||
<label
|
||||
bg="neutral-100 dark:neutral-800"
|
||||
hover="bg-neutral-200 dark:bg-neutral-700"
|
||||
transition="all ease-in-out duration-250"
|
||||
w-full flex cursor-pointer rounded-lg px-4 py-3
|
||||
>
|
||||
<input
|
||||
v-model="disableTransitions"
|
||||
text="neutral-800 dark:neutral-100"
|
||||
:checked="disableTransitions"
|
||||
:aria-checked="disableTransitions"
|
||||
type="checkbox"
|
||||
hidden appearance-none outline-none
|
||||
>
|
||||
<div flex="~ row" w-full items-center gap-1.5>
|
||||
<div text="sm" w-full flex-1>
|
||||
<span>Disable Transitions (for debugging)</span>
|
||||
</div>
|
||||
<div select-none>
|
||||
<Transition name="slide-away" mode="out-in">
|
||||
<div
|
||||
v-if="disableTransitions"
|
||||
i-solar:people-nearby-bold-duotone
|
||||
transition="all ease-in-out duration-250"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
i-solar:running-2-line-duotone
|
||||
transition="all ease-in-out duration-250"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div text="neutral-100/50 dark:neutral-500/20" pointer-events-none fixed bottom-0 right-0>
|
||||
<div text="40" i-lucide:cog translate-x-10 translate-y-10 />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
import { RadioCardDetailManySelect, RadioCardSimple } from '@proj-airi/stage-ui/components'
|
||||
import { useConsciousnessStore, useProvidersStore } from '@proj-airi/stage-ui/stores'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { onMounted } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
|
||||
const providersStore = useProvidersStore()
|
||||
const consciousnessStore = useConsciousnessStore()
|
||||
const { availableProviders, availableProvidersMetadata } = storeToRefs(providersStore)
|
||||
const {
|
||||
activeProvider,
|
||||
activeModel,
|
||||
customModelName,
|
||||
modelSearchQuery,
|
||||
supportsModelListing,
|
||||
providerModels,
|
||||
isLoadingActiveProviderModels,
|
||||
activeProviderModelError,
|
||||
} = storeToRefs(consciousnessStore)
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(async () => {
|
||||
await consciousnessStore.loadModelsForProvider(activeProvider.value)
|
||||
})
|
||||
|
||||
function updateCustomModelName(value: string) {
|
||||
customModelName.value = value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<button @click="router.back()">
|
||||
<div i-solar:alt-arrow-left-line-duotone text-xl />
|
||||
</button>
|
||||
<h1 relative>
|
||||
<div absolute left-0 top-0 translate-y="[-80%]">
|
||||
<span text="neutral-300 dark:neutral-500">Modules</span>
|
||||
</div>
|
||||
<div text-3xl font-semibold>
|
||||
Consciousness
|
||||
</div>
|
||||
</h1>
|
||||
</div>
|
||||
<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">
|
||||
Provider
|
||||
</h2>
|
||||
<div text="neutral-400 dark:neutral-400">
|
||||
<span>Select the suitable LLM provider for consciousness</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="availableProviders.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 availableProvidersMetadata"
|
||||
:id="metadata.id"
|
||||
:key="metadata.id"
|
||||
v-model="activeProvider"
|
||||
name="provider"
|
||||
:value="metadata.id"
|
||||
:title="metadata.localizedName"
|
||||
: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.modules.consciousness.provider-model-selection.title') }}
|
||||
</h2>
|
||||
<div text="neutral-400 dark:neutral-400">
|
||||
<span>{{ $t('settings.modules.consciousness.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.modules.consciousness.provider-model-selection.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div
|
||||
v-else-if="activeProviderModelError"
|
||||
class="flex items-center gap-3 border border-red-200 rounded-lg bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20"
|
||||
>
|
||||
<div i-solar:close-circle-line-duotone class="text-2xl text-red-500 dark:text-red-400" />
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium">{{ $t('settings.modules.consciousness.provider-model-selection.error') }}</span>
|
||||
<span class="text-sm text-red-600 dark:text-red-400">{{ activeProviderModelError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No models available -->
|
||||
<div
|
||||
v-else-if="providerModels.length === 0 && !isLoadingActiveProviderModels"
|
||||
class="flex items-center gap-3 border border-amber-200 rounded-lg bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20"
|
||||
>
|
||||
<div i-solar:info-circle-line-duotone class="text-2xl text-amber-500 dark:text-amber-400" />
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium">{{ $t('settings.modules.consciousness.provider-model-selection.no_models')
|
||||
}}</span>
|
||||
<span class="text-sm text-amber-600 dark:text-amber-400">{{
|
||||
$t('settings.modules.consciousness.provider-model-selection.no_models_description') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Using the new RadioCardDetailManySelect component -->
|
||||
<template v-else-if="providerModels.length > 0">
|
||||
<RadioCardDetailManySelect
|
||||
v-model="activeModel"
|
||||
v-model:search-query="modelSearchQuery"
|
||||
:items="providerModels"
|
||||
:searchable="true"
|
||||
:search-placeholder="$t('settings.modules.consciousness.provider-model-selection.search_placeholder')"
|
||||
:search-no-results-title="$t('settings.modules.consciousness.provider-model-selection.no_search_results')"
|
||||
:search-no-results-description="$t('settings.modules.consciousness.provider-model-selection.no_search_results_description', { query: modelSearchQuery })"
|
||||
:search-results-text="$t('settings.modules.consciousness.provider-model-selection.search_results', { count: '{count}', total: '{total}' })"
|
||||
:custom-input-placeholder="$t('settings.modules.consciousness.provider-model-selection.custom_model_placeholder')"
|
||||
:expand-button-text="$t('settings.modules.consciousness.provider-model-selection.expand')"
|
||||
:collapse-button-text="$t('settings.modules.consciousness.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.modules.consciousness.provider-model-selection.title') }}
|
||||
</h2>
|
||||
<div text="neutral-400 dark:neutral-500">
|
||||
<span>{{ $t('settings.modules.consciousness.provider-model-selection.subtitle') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-primary-50 border-primary-200 dark:bg-primary-900/20 dark:border-primary-800 flex items-center gap-3 border rounded-lg p-4"
|
||||
>
|
||||
<div i-solar:info-circle-line-duotone class="text-primary-500 dark:text-primary-400 text-2xl" />
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium">{{ $t('settings.modules.consciousness.provider-model-selection.not_supported')
|
||||
}}</span>
|
||||
<span class="dark:text-primary-400 text-primary-600 text-sm">{{
|
||||
$t('settings.modules.consciousness.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.modules.consciousness.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.modules.consciousness.provider-model-selection.manual_model_placeholder')"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div fixed bottom-0 right-0 z--1 class="text-neutral-100/80 dark:text-neutral-500/20">
|
||||
<div text="40" i-lucide:ghost translate-x-10 translate-y-10 />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { IconStatusItem } from '@proj-airi/stage-ui/components'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
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: 'Consciousness',
|
||||
description: 'Thinking, vision, speech synthesis, gaming, etc.',
|
||||
icon: 'i-lucide:ghost',
|
||||
to: '/settings/modules/consciousness',
|
||||
configured: false,
|
||||
},
|
||||
// {
|
||||
// id: 'hearing',
|
||||
// name: 'Hearing',
|
||||
// description: 'Hearing, speech recognition, etc.',
|
||||
// icon: 'i-lucide:ear',
|
||||
// to: '',
|
||||
// configured: false,
|
||||
// },
|
||||
// {
|
||||
// id: 'messaging-discord',
|
||||
// name: 'Discord',
|
||||
// description: 'Messaging, notifications, etc.',
|
||||
// icon: 'i-simple-icons:discord',
|
||||
// to: '',
|
||||
// configured: false,
|
||||
// },
|
||||
{
|
||||
id: 'speech',
|
||||
name: 'Speech',
|
||||
description: 'Speech synthesis, etc.',
|
||||
icon: 'i-lucide:mic',
|
||||
to: '/settings/modules/speech',
|
||||
configured: false,
|
||||
},
|
||||
// {
|
||||
// id: 'memory-short-term',
|
||||
// name: 'Short-Term Memory',
|
||||
// description: 'Short-term memory, etc.',
|
||||
// icon: 'i-lucide:book',
|
||||
// to: '',
|
||||
// configured: false,
|
||||
// },
|
||||
// {
|
||||
// id: 'memory-long-term',
|
||||
// name: 'Long-Term Memory',
|
||||
// description: 'Long-term memory, etc.',
|
||||
// icon: 'i-lucide:book-copy',
|
||||
// to: '',
|
||||
// configured: false,
|
||||
// },
|
||||
// {
|
||||
// id: 'vision',
|
||||
// name: 'Vision',
|
||||
// description: 'Vision, etc.',
|
||||
// icon: 'i-lucide:eye',
|
||||
// to: '',
|
||||
// configured: false,
|
||||
// },
|
||||
// {
|
||||
// id: 'game-minecraft',
|
||||
// name: 'Minecraft',
|
||||
// description: 'Playing Minecraft with you, etc.',
|
||||
// iconColor: 'i-vscode-icons:file-type-minecraft',
|
||||
// to: '',
|
||||
// configured: false,
|
||||
// },
|
||||
// {
|
||||
// id: 'game-factorio',
|
||||
// name: 'Factorio',
|
||||
// description: 'Playing Factorio with you, etc.',
|
||||
// iconImage: FactorioIcon,
|
||||
// to: '',
|
||||
// configured: false,
|
||||
// },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<button @click="router.back()">
|
||||
<div i-solar:alt-arrow-left-line-duotone text-2xl />
|
||||
</button>
|
||||
<h1 relative>
|
||||
<div absolute left-0 top-0 translate-y="[-80%]">
|
||||
<span text="neutral-300 dark:neutral-500">Settings</span>
|
||||
</div>
|
||||
<div text-3xl font-semibold>
|
||||
Modules
|
||||
</div>
|
||||
</h1>
|
||||
</div>
|
||||
<div grid="~ cols-1 sm:cols-2 gap-4">
|
||||
<IconStatusItem
|
||||
v-for="module in modulesList"
|
||||
:key="module.id"
|
||||
:title="module.name"
|
||||
:description="module.description"
|
||||
:icon="module.icon"
|
||||
:icon-color="module.iconColor"
|
||||
:icon-image="module.iconImage"
|
||||
:to="module.to"
|
||||
:configured="module.configured"
|
||||
/>
|
||||
</div>
|
||||
<div fixed bottom-0 right-0 z--1 text="neutral-100/80 dark:neutral-500/20">
|
||||
<div text="40" i-lucide:blocks translate-x-10 translate-y-10 />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,267 @@
|
||||
<script setup lang="ts">
|
||||
import { RadioCardSimple } from '@proj-airi/stage-ui/components'
|
||||
import { useProvidersStore, useSpeechStore } from '@proj-airi/stage-ui/stores'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
|
||||
const providersStore = useProvidersStore()
|
||||
const speechStore = useSpeechStore()
|
||||
const { availableProviders, availableProvidersMetadata } = storeToRefs(providersStore)
|
||||
const {
|
||||
activeSpeechProvider,
|
||||
activeSpeechModel,
|
||||
voiceName,
|
||||
pitch,
|
||||
rate,
|
||||
isLoadingSpeechProviderVoices,
|
||||
speechProviderError,
|
||||
supportsSSML,
|
||||
ssmlEnabled,
|
||||
} = storeToRefs(speechStore)
|
||||
|
||||
const router = useRouter()
|
||||
const ssmlExample = ref(`<speak>
|
||||
Hello, my name is <voice name="${voiceName.value || 'Default'}">
|
||||
<prosody pitch="+${pitch.value || 0}%" rate="${rate.value || 1}">
|
||||
AI Assistant
|
||||
</prosody>
|
||||
</voice>
|
||||
</speak>`)
|
||||
|
||||
onMounted(async () => {
|
||||
await speechStore.loadVoicesForProvider(activeSpeechProvider.value)
|
||||
})
|
||||
|
||||
function updateVoiceName(value: string) {
|
||||
voiceName.value = value
|
||||
updateSSMLExample()
|
||||
}
|
||||
|
||||
function updatePitch(value: number) {
|
||||
pitch.value = value
|
||||
updateSSMLExample()
|
||||
}
|
||||
|
||||
function updateRate(value: number) {
|
||||
rate.value = value
|
||||
updateSSMLExample()
|
||||
}
|
||||
|
||||
function updateSSMLExample() {
|
||||
ssmlExample.value = `<speak>
|
||||
Hello, my name is <voice name="${voiceName.value || 'Default'}">
|
||||
<prosody pitch="+${pitch.value || 0}%" rate="${rate.value || 1}">
|
||||
AI Assistant
|
||||
</prosody>
|
||||
</voice>
|
||||
</speak>`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<button @click="router.back()">
|
||||
<div i-solar:alt-arrow-left-line-duotone text-xl />
|
||||
</button>
|
||||
<h1 relative>
|
||||
<div absolute left-0 top-0 translate-y="[-80%]">
|
||||
<span text="neutral-300 dark:neutral-500">Modules</span>
|
||||
</div>
|
||||
<div text-3xl font-semibold>
|
||||
Speech
|
||||
</div>
|
||||
</h1>
|
||||
</div>
|
||||
<div bg="neutral-100 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-400">
|
||||
Provider
|
||||
</h2>
|
||||
<div text="neutral-400 dark:neutral-500">
|
||||
<span>Select the suitable speech provider</span>
|
||||
</div>
|
||||
</div>
|
||||
<div max-w-full>
|
||||
<fieldset
|
||||
v-if="availableProviders.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 availableProvidersMetadata"
|
||||
:id="metadata.id"
|
||||
:key="metadata.id"
|
||||
v-model="activeSpeechProvider"
|
||||
name="speech-provider"
|
||||
:value="metadata.id"
|
||||
:title="metadata.localizedName"
|
||||
: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>
|
||||
|
||||
<!-- 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" class="flex items-center justify-center py-4">
|
||||
<div class="mr-2 animate-spin">
|
||||
<div i-solar:spinner-line-duotone text-xl />
|
||||
</div>
|
||||
<span>Loading available voices...</span>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div
|
||||
v-else-if="speechProviderError"
|
||||
class="flex items-center gap-3 border border-red-200 rounded-lg bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20"
|
||||
>
|
||||
<div i-solar:close-circle-line-duotone class="text-2xl text-red-500 dark:text-red-400" />
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium">Error loading voices</span>
|
||||
<span class="text-sm text-red-600 dark:text-red-400">{{ speechProviderError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Voice configuration form -->
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Voice selection -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">
|
||||
Voice Name
|
||||
</label>
|
||||
<input
|
||||
v-model="voiceName" 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="Enter voice name (e.g., 'Rachel', 'Josh')"
|
||||
@input="(event) => updateVoiceName((event.target as HTMLInputElement).value)"
|
||||
>
|
||||
<p class="mt-1 text-xs text-neutral-500">
|
||||
For ElevenLabs, enter the exact voice name from your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Voice parameters -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">
|
||||
Pitch Adjustment (%)
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
v-model="pitch" type="range" min="-50" max="50" step="5"
|
||||
class="w-full"
|
||||
@input="(event) => updatePitch(parseInt((event.target as HTMLInputElement).value))"
|
||||
>
|
||||
<span class="w-12 text-center">{{ pitch }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">
|
||||
Speech Rate
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
v-model="rate" type="range" min="0.5" max="2" step="0.1"
|
||||
class="w-full"
|
||||
@input="(event) => updateRate(parseFloat((event.target as HTMLInputElement).value))"
|
||||
>
|
||||
<span class="w-12 text-center">{{ rate }}x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSML Support -->
|
||||
<div v-if="supportsSSML" class="border border-neutral-200 rounded-lg p-4 dark:border-neutral-700">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="font-medium">SSML Support</label>
|
||||
<div class="relative mr-2 inline-block w-10 select-none align-middle">
|
||||
<input
|
||||
id="ssml-toggle"
|
||||
v-model="ssmlEnabled"
|
||||
type="checkbox"
|
||||
class="sr-only"
|
||||
>
|
||||
<label
|
||||
for="ssml-toggle"
|
||||
class="block h-6 cursor-pointer overflow-hidden rounded-full bg-neutral-300 dark:bg-neutral-700"
|
||||
>
|
||||
<span
|
||||
:class="{ 'translate-x-4': ssmlEnabled, 'translate-x-0': !ssmlEnabled }"
|
||||
class="block h-6 w-6 transform rounded-full bg-white shadow transition-transform duration-200 ease-in-out"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mb-3 text-sm text-neutral-500">
|
||||
Enable Speech Synthesis Markup Language for more control over speech output
|
||||
</p>
|
||||
<div v-if="ssmlEnabled" class="mt-3">
|
||||
<label class="mb-1 block text-sm font-medium">
|
||||
SSML Example
|
||||
</label>
|
||||
<pre class="overflow-auto rounded bg-neutral-50 p-3 text-xs dark:bg-neutral-800">{{ ssmlExample }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div fixed bottom-0 right-0 z--1 class="text-neutral-100/80 dark:text-neutral-500/20">
|
||||
<div text="40" i-lucide:volume-2 translate-x-10 translate-y-10 />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,272 @@
|
||||
<script setup lang="ts">
|
||||
import { Collapsable } from '@proj-airi/stage-ui/components'
|
||||
import { useProvidersStore, useSpeechStore } from '@proj-airi/stage-ui/stores'
|
||||
import { useToggle } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const providersStore = useProvidersStore()
|
||||
const speechStore = useSpeechStore()
|
||||
const { providers } = storeToRefs(providersStore)
|
||||
|
||||
// Get provider metadata
|
||||
const providerId = 'elevenlabs'
|
||||
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
|
||||
|
||||
const apiKey = ref(providers.value[providerId]?.apiKey || '')
|
||||
const baseUrl = ref(providers.value[providerId]?.baseUrl || '')
|
||||
|
||||
// Speech settings
|
||||
const selectedLanguage = ref(speechStore.selectedLanguage)
|
||||
const selectedVoice = ref(speechStore.voiceName)
|
||||
const availableVoices = computed(() => speechStore.availableVoicesForLanguage)
|
||||
|
||||
const advancedVisible = ref(false)
|
||||
const toggleAdvancedVisible = useToggle(advancedVisible)
|
||||
|
||||
onMounted(() => {
|
||||
providersStore.initializeProvider(providerId)
|
||||
|
||||
// Initialize refs with current values
|
||||
apiKey.value = providers.value[providerId]?.apiKey || ''
|
||||
baseUrl.value = providers.value[providerId]?.baseUrl || providerMetadata.value?.baseUrlDefault || ''
|
||||
|
||||
// Load voices if provider is configured
|
||||
if (providersStore.configuredProviders[providerId]) {
|
||||
speechStore.loadVoicesForProvider(providerId)
|
||||
}
|
||||
})
|
||||
|
||||
watch([apiKey, baseUrl], () => {
|
||||
providers.value[providerId] = {
|
||||
apiKey: apiKey.value,
|
||||
baseUrl: baseUrl.value || providerMetadata.value?.baseUrlDefault || '',
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedLanguage, (newLanguage) => {
|
||||
speechStore.setLanguage(newLanguage)
|
||||
})
|
||||
|
||||
watch(selectedVoice, (newVoice) => {
|
||||
speechStore.setVoiceName(newVoice)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<button @click="router.back()">
|
||||
<div i-solar:alt-arrow-left-line-duotone text-2xl />
|
||||
</button>
|
||||
<h1 relative>
|
||||
<div absolute left-0 top-0 translate-y="[-80%]">
|
||||
<span text="neutral-300 dark:neutral-500">Provider</span>
|
||||
</div>
|
||||
<div text-3xl font-semibold>
|
||||
{{ providerMetadata?.localizedName }}
|
||||
</div>
|
||||
</h1>
|
||||
</div>
|
||||
<div bg="neutral-50 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-6">
|
||||
<div>
|
||||
<div flex="~ col gap-6">
|
||||
<div>
|
||||
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
|
||||
Basic
|
||||
</h2>
|
||||
<div text="neutral-400 dark:neutral-500">
|
||||
<span>Essential settings</span>
|
||||
</div>
|
||||
</div>
|
||||
<div max-w-full>
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
API Key
|
||||
<span class="text-red-500">*</span>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400" text-nowrap>
|
||||
API Key for {{ providerMetadata?.localizedName }}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="apiKey" type="password"
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out"
|
||||
w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
placeholder="..."
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div flex="~ col gap-6">
|
||||
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
|
||||
Voice Settings
|
||||
</h2>
|
||||
<div flex="~ col gap-6">
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Language
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Select voice language
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
v-model="selectedLanguage"
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out"
|
||||
w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
>
|
||||
<option v-for="language in speechStore.availableLanguages" :key="language" :value="language">
|
||||
{{ language }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Voice
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Select preferred voice
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
v-model="selectedVoice"
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out"
|
||||
w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
>
|
||||
<option v-for="voice in availableVoices" :key="voice.id" :value="voice.name">
|
||||
{{ voice.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Pitch
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Adjust voice pitch
|
||||
</div>
|
||||
</div>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<input
|
||||
v-model="speechStore.pitch"
|
||||
type="range"
|
||||
min="-100"
|
||||
max="100"
|
||||
step="1"
|
||||
w-full
|
||||
>
|
||||
<span class="text-xs">{{ speechStore.pitch }}</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Rate
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Adjust speaking rate
|
||||
</div>
|
||||
</div>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<input
|
||||
v-model="speechStore.rate"
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="2"
|
||||
step="0.1"
|
||||
w-full
|
||||
>
|
||||
<span class="text-xs">{{ speechStore.rate.toFixed(1) }}</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
SSML
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Enable SSML support
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
v-model="speechStore.ssmlEnabled"
|
||||
type="checkbox"
|
||||
class="mr-2"
|
||||
:disabled="!speechStore.supportsSSML"
|
||||
>
|
||||
<span class="text-sm">{{ speechStore.ssmlEnabled ? 'Enabled' : 'Disabled' }}</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Collapsable w-full>
|
||||
<template #trigger="slotProps">
|
||||
<button
|
||||
transition="all ease-in-out duration-250"
|
||||
w-full flex items-center gap-1.5 outline-none
|
||||
class="[&_.provider-icon]:grayscale-100 [&_.provider-icon]:hover:grayscale-0"
|
||||
@click="() => slotProps.setVisible(!slotProps.visible) && toggleAdvancedVisible()"
|
||||
>
|
||||
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
|
||||
<span>Advanced</span>
|
||||
</h2>
|
||||
<div transform transition="transform duration-250" :class="{ 'rotate-180': slotProps.visible }">
|
||||
<div i-solar:alt-arrow-down-bold-duotone />
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<div mt-4>
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Base URL
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Custom base URL (optional)
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="baseUrl" type="text"
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out"
|
||||
w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
:placeholder="providerMetadata?.baseUrlDefault"
|
||||
>
|
||||
</label>
|
||||
|
||||
<div mt-4>
|
||||
<button
|
||||
border="zinc-300 dark:zinc-800 solid 1"
|
||||
transition="border duration-250 ease-in-out"
|
||||
rounded
|
||||
px-4 py-2 text-sm @click="speechStore.resetVoiceSettings"
|
||||
>
|
||||
Reset Voice Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsable>
|
||||
</div>
|
||||
</div>
|
||||
<div fixed bottom-0 right-0 text="neutral-100/80 dark:neutral-500/20">
|
||||
<div text="40" :class="providerMetadata?.icon" translate-x-10 translate-y-10 />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { IconStatusItem } from '@proj-airi/stage-ui/components'
|
||||
import { useProvidersStore } from '@proj-airi/stage-ui/stores'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const providersStore = useProvidersStore()
|
||||
const { allProvidersMetadata } = storeToRefs(providersStore)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<button @click="router.back()">
|
||||
<div i-solar:alt-arrow-left-line-duotone text-2xl />
|
||||
</button>
|
||||
<h1 relative>
|
||||
<div absolute left-0 top-0 translate-y="[-80%]">
|
||||
<span text="neutral-300 dark:neutral-500">Settings</span>
|
||||
</div>
|
||||
<div text-3xl font-semibold>
|
||||
Providers
|
||||
</div>
|
||||
</h1>
|
||||
</div>
|
||||
<div grid="~ cols-2 gap-2">
|
||||
<IconStatusItem
|
||||
v-for="provider in allProvidersMetadata"
|
||||
:key="provider.id"
|
||||
:title="provider.localizedName"
|
||||
:description="provider.localizedDescription"
|
||||
:icon="provider.icon"
|
||||
:icon-color="provider.iconColor"
|
||||
:icon-image="provider.iconImage"
|
||||
:to="`/settings/providers/${provider.id.replace('-ai', '')}`"
|
||||
:configured="provider.configured"
|
||||
/>
|
||||
</div>
|
||||
<div fixed bottom-0 right-0 z--1 text="neutral-100/80 dark:neutral-500/20">
|
||||
<div text="40" i-lucide:brain translate-x-10 translate-y-10 />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { Collapsable } from '@proj-airi/stage-ui/components'
|
||||
import { useProvidersStore } from '@proj-airi/stage-ui/stores'
|
||||
import { useToggle } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const providersStore = useProvidersStore()
|
||||
const { providers } = storeToRefs(providersStore)
|
||||
|
||||
const apiKey = ref(providers.value.openai?.apiKey || '')
|
||||
const baseUrl = ref(providers.value.openai?.baseUrl || '')
|
||||
|
||||
const advancedVisible = ref(false)
|
||||
const toggleAdvancedVisible = useToggle(advancedVisible)
|
||||
|
||||
onMounted(() => {
|
||||
if (!providers.value.openai) {
|
||||
providers.value.openai = {
|
||||
baseUrl: 'https://api.openai.com/v1/',
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch([apiKey, baseUrl], () => {
|
||||
providers.value.openai = {
|
||||
apiKey: apiKey.value,
|
||||
baseUrl: baseUrl.value || 'https://api.openai.com/v1/',
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<button @click="router.back()">
|
||||
<div i-solar:alt-arrow-left-line-duotone text-2xl />
|
||||
</button>
|
||||
<h1 relative>
|
||||
<div absolute left-0 top-0 translate-y="[-80%]">
|
||||
<span text="neutral-300 dark:neutral-500">Provider</span>
|
||||
</div>
|
||||
<div text-3xl font-semibold>
|
||||
OpenAI
|
||||
</div>
|
||||
</h1>
|
||||
</div>
|
||||
<div bg="neutral-50 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-4">
|
||||
<div>
|
||||
<div flex="~ col gap-6">
|
||||
<div>
|
||||
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
|
||||
Basic
|
||||
</h2>
|
||||
<div text="neutral-400 dark:neutral-500">
|
||||
<span>Essential settings</span>
|
||||
</div>
|
||||
</div>
|
||||
<div max-w-full>
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
API Key
|
||||
<span class="text-red-500">*</span>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400" text-nowrap>
|
||||
API Key for OpenAI
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="apiKey" type="password"
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out"
|
||||
w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
placeholder="sk-..."
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Collapsable w-full>
|
||||
<template #trigger="slotProps">
|
||||
<button
|
||||
transition="all ease-in-out duration-250"
|
||||
w-full flex items-center gap-1.5 outline-none
|
||||
class="[&_.provider-icon]:grayscale-100 [&_.provider-icon]:hover:grayscale-0"
|
||||
@click="() => slotProps.setVisible(!slotProps.visible) && toggleAdvancedVisible()"
|
||||
>
|
||||
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
|
||||
<span>Advanced</span>
|
||||
</h2>
|
||||
<div transform transition="transform duration-250" :class="{ 'rotate-180': slotProps.visible }">
|
||||
<div i-solar:alt-arrow-down-bold-duotone />
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<div mt-4>
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Base URL
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Custom base URL (optional)
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="baseUrl" type="text"
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out"
|
||||
w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
placeholder="https://api.openai.com/v1/"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</Collapsable>
|
||||
</div>
|
||||
</div>
|
||||
<div fixed bottom-0 right-0 text="neutral-100/80 dark:neutral-500/20">
|
||||
<div text="40" i-lobe-icons:openai translate-x-10 translate-y-10 />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import { Collapsable } from '@proj-airi/stage-ui/components'
|
||||
import { useProvidersStore } from '@proj-airi/stage-ui/stores'
|
||||
import { useToggle } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const providersStore = useProvidersStore()
|
||||
const { providers } = storeToRefs(providersStore)
|
||||
|
||||
// Get provider metadata
|
||||
const providerId = 'openrouter-ai'
|
||||
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
|
||||
|
||||
const apiKey = ref(providers.value[providerId]?.apiKey || '')
|
||||
const baseUrl = ref(providers.value[providerId]?.baseUrl || '')
|
||||
|
||||
const advancedVisible = ref(false)
|
||||
const toggleAdvancedVisible = useToggle(advancedVisible)
|
||||
|
||||
onMounted(() => {
|
||||
providersStore.initializeProvider(providerId)
|
||||
|
||||
// Initialize refs with current values
|
||||
apiKey.value = providers.value[providerId]?.apiKey || ''
|
||||
baseUrl.value = providers.value[providerId]?.baseUrl || providerMetadata.value?.baseUrlDefault || ''
|
||||
})
|
||||
|
||||
watch([apiKey, baseUrl], () => {
|
||||
providers.value[providerId] = {
|
||||
apiKey: apiKey.value,
|
||||
baseUrl: baseUrl.value || providerMetadata.value?.baseUrlDefault || '',
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<button @click="router.back()">
|
||||
<div i-solar:alt-arrow-left-line-duotone text-2xl />
|
||||
</button>
|
||||
<h1 relative>
|
||||
<div absolute left-0 top-0 translate-y="[-80%]">
|
||||
<span text="neutral-300 dark:neutral-500">Provider</span>
|
||||
</div>
|
||||
<div text-3xl font-semibold>
|
||||
{{ providerMetadata?.localizedName }}
|
||||
</div>
|
||||
</h1>
|
||||
</div>
|
||||
<div bg="neutral-50 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-4">
|
||||
<div>
|
||||
<div flex="~ col gap-6">
|
||||
<div>
|
||||
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
|
||||
Basic
|
||||
</h2>
|
||||
<div text="neutral-400 dark:neutral-500">
|
||||
<span>Essential settings</span>
|
||||
</div>
|
||||
</div>
|
||||
<div max-w-full>
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
API Key
|
||||
<span class="text-red-500">*</span>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400" text-nowrap>
|
||||
API Key for {{ providerMetadata?.localizedName }}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="apiKey" type="password"
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out"
|
||||
w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
placeholder="sk-or-..."
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Collapsable w-full>
|
||||
<template #trigger="slotProps">
|
||||
<button
|
||||
transition="all ease-in-out duration-250"
|
||||
w-full flex items-center gap-1.5 outline-none
|
||||
class="[&_.provider-icon]:grayscale-100 [&_.provider-icon]:hover:grayscale-0"
|
||||
@click="() => slotProps.setVisible(!slotProps.visible) && toggleAdvancedVisible()"
|
||||
>
|
||||
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
|
||||
<span>Advanced</span>
|
||||
</h2>
|
||||
<div transform transition="transform duration-250" :class="{ 'rotate-180': slotProps.visible }">
|
||||
<div i-solar:alt-arrow-down-bold-duotone />
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<div mt-4>
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Base URL
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Custom base URL (optional)
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="baseUrl" type="text"
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out"
|
||||
w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
:placeholder="providerMetadata?.baseUrlDefault"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</Collapsable>
|
||||
</div>
|
||||
</div>
|
||||
<div fixed bottom-0 right-0 text="neutral-100/80 dark:neutral-500/20">
|
||||
<div text="40" :class="providerMetadata?.icon" translate-x-10 translate-y-10 />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,199 @@
|
||||
<script setup lang="ts">
|
||||
import { Collapsable } from '@proj-airi/stage-ui/components'
|
||||
import { DEFAULT_THEME_COLORS_HUE, useSettings } from '@proj-airi/stage-ui/stores'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const settings = useSettings()
|
||||
|
||||
function resetToDefault() {
|
||||
settings.themeColorsHue = DEFAULT_THEME_COLORS_HUE
|
||||
settings.themeColorsHueDynamic = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<button @click="router.back()">
|
||||
<div i-solar:alt-arrow-left-line-duotone text-2xl />
|
||||
</button>
|
||||
<h1 relative>
|
||||
<div absolute left-0 top-0 translate-y="[-80%]">
|
||||
<span text="neutral-300 dark:neutral-500">Settings</span>
|
||||
</div>
|
||||
<div text-3xl font-semibold>
|
||||
Themes
|
||||
</div>
|
||||
</h1>
|
||||
</div>
|
||||
<Collapsable mt-4 w-full :default="true">
|
||||
<template #trigger="slotProps">
|
||||
<button
|
||||
bg="zinc-100 dark:zinc-800"
|
||||
hover="bg-zinc-200 dark:bg-zinc-700"
|
||||
transition="all ease-in-out duration-250"
|
||||
w-full flex items-center gap-1.5 rounded-lg px-4 py-3 outline-none
|
||||
class="[&_.provider-icon]:grayscale-100 [&_.provider-icon]:hover:grayscale-0"
|
||||
@click="slotProps.setVisible(!slotProps.visible)"
|
||||
>
|
||||
<div flex="~ row 1" items-center gap-1.5>
|
||||
<div
|
||||
i-solar:pallete-2-bold-duotone class="provider-icon size-6"
|
||||
transition="filter duration-250 ease-in-out"
|
||||
/>
|
||||
<div>
|
||||
Colors
|
||||
</div>
|
||||
</div>
|
||||
<div transform transition="transform duration-250" :class="{ 'rotate-180': slotProps.visible }">
|
||||
<div i-solar:alt-arrow-down-bold-duotone />
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<div p-4>
|
||||
<div class="flex items-center gap-8">
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Primary color
|
||||
</div>
|
||||
|
||||
<input
|
||||
v-model="settings.themeColorsHue"
|
||||
type="range"
|
||||
min="0"
|
||||
max="360"
|
||||
step="0.01"
|
||||
class="theme-hue-slider"
|
||||
:disabled="settings.themeColorsHueDynamic"
|
||||
:class="{ 'opacity-25 cursor-not-allowed': settings.themeColorsHueDynamic }"
|
||||
>
|
||||
</div>
|
||||
<div mt-4 h-10 w-full flex overflow-hidden rounded-lg>
|
||||
<div bg="primary-50" class="primary-color-bar" text-black>
|
||||
50
|
||||
</div>
|
||||
<div bg="primary-100" class="primary-color-bar" text-black>
|
||||
100
|
||||
</div>
|
||||
<div bg="primary-200" class="primary-color-bar" text-black>
|
||||
200
|
||||
</div>
|
||||
<div bg="primary-300" class="primary-color-bar" text-black>
|
||||
300
|
||||
</div>
|
||||
<div bg="primary-400" class="primary-color-bar" text-black>
|
||||
400
|
||||
</div>
|
||||
<div bg="primary-500" class="primary-color-bar" text-black>
|
||||
500
|
||||
</div>
|
||||
<div bg="primary-600" class="primary-color-bar" text-white>
|
||||
600
|
||||
</div>
|
||||
<div bg="primary-700" class="primary-color-bar" text-white>
|
||||
700
|
||||
</div>
|
||||
<div bg="primary-800" class="primary-color-bar" text-white>
|
||||
800
|
||||
</div>
|
||||
<div bg="primary-900" class="primary-color-bar" text-white>
|
||||
900
|
||||
</div>
|
||||
<div bg="primary-950" class="primary-color-bar" text-white>
|
||||
950
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div mt-4 h-10 w-full flex overflow-hidden rounded-lg class="transparency-grid">
|
||||
<div bg="primary-500/5" class="primary-color-bar" text-black>
|
||||
500/5
|
||||
</div>
|
||||
<div bg="primary-500/10" class="primary-color-bar" text-black>
|
||||
500/10
|
||||
</div>
|
||||
<div bg="primary-500/20" class="primary-color-bar" text-black>
|
||||
500/20
|
||||
</div>
|
||||
<div bg="primary-500/30" class="primary-color-bar" text-black>
|
||||
500/30
|
||||
</div>
|
||||
<div bg="primary-500/40" class="primary-color-bar" text-black>
|
||||
500/40
|
||||
</div>
|
||||
<div bg="primary-500/50" class="primary-color-bar" text-black>
|
||||
500/50
|
||||
</div>
|
||||
<div bg="primary-500/60" class="primary-color-bar" text-black>
|
||||
500/60
|
||||
</div>
|
||||
<div bg="primary-500/70" class="primary-color-bar" text-black>
|
||||
500/70
|
||||
</div>
|
||||
<div bg="primary-500/80" class="primary-color-bar" text-black>
|
||||
500/80
|
||||
</div>
|
||||
<div bg="primary-500/90" class="primary-color-bar" text-black>
|
||||
500/90
|
||||
</div>
|
||||
<div bg="primary-500" class="primary-color-bar" text-black>
|
||||
500
|
||||
</div>
|
||||
</div>
|
||||
<div mt-4 class="flex items-center justify-end gap-4">
|
||||
<label class="relative inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="settings.themeColorsHueDynamic"
|
||||
type="checkbox"
|
||||
class="peer sr-only"
|
||||
>
|
||||
<div
|
||||
class="peer-checked:bg-primary-500 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 after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
|
||||
/>
|
||||
<span class="ml-2 text-sm font-medium">I Want It Dynamic!</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
class="rounded-md bg-neutral-100 px-3 py-1.5 text-sm transition-colors dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700"
|
||||
@click="resetToDefault"
|
||||
>
|
||||
Reset to Default
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsable>
|
||||
<div fixed bottom-0 right-0 z--1 text="neutral-100/80 dark:neutral-500/20">
|
||||
<div text="40" i-lucide:paintbrush translate-x-10 translate-y-10 />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.primary-color-bar {
|
||||
@apply w-full h-full flex-1 flex items-center justify-center;
|
||||
}
|
||||
|
||||
.theme-hue-slider {
|
||||
@apply flex-1 w-32 h-2 rounded-full appearance-none;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
oklch(85% 0.2 0),
|
||||
oklch(85% 0.2 60),
|
||||
oklch(85% 0.2 120),
|
||||
oklch(85% 0.2 180),
|
||||
oklch(85% 0.2 240),
|
||||
oklch(85% 0.2 300),
|
||||
oklch(85% 0.2 360)
|
||||
);
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -0,0 +1,26 @@
|
||||
:root {
|
||||
--airi-theme-primary-50: #fff0f2;
|
||||
--airi-theme-primary-100: #ffe3e6;
|
||||
--airi-theme-primary-200: #ffcad4;
|
||||
--airi-theme-primary-300: #ff9fb0;
|
||||
--airi-theme-primary-400: #ff6988;
|
||||
--airi-theme-primary-500: #fe456e;
|
||||
--airi-theme-primary-600: #ec124d;
|
||||
--airi-theme-primary-700: #c70941;
|
||||
--airi-theme-primary-800: #a70a3e;
|
||||
--airi-theme-primary-900: #8e0d3b;
|
||||
--airi-theme-primary-950: #50011b;
|
||||
|
||||
--theme-colors-hue: 354.31;
|
||||
--theme-colors-chroma: calc(0.18 + (cos(var(--theme-colors-hue) * 3.14159265 / 180) * 0.04));
|
||||
--theme-colors-chroma-50: calc(var(--theme-colors-chroma) * 0.3);
|
||||
--theme-colors-chroma-100: calc(var(--theme-colors-chroma) * 0.5);
|
||||
--theme-colors-chroma-200: calc(var(--theme-colors-chroma) * 0.6);
|
||||
--theme-colors-chroma-300: calc(var(--theme-colors-chroma) * 0.75);
|
||||
--theme-colors-chroma-400: var(--theme-colors-chroma);
|
||||
--theme-colors-chroma-600: calc(var(--theme-colors-chroma) * 1.15);
|
||||
--theme-colors-chroma-700: calc(var(--theme-colors-chroma) * 1.1);
|
||||
--theme-colors-chroma-800: calc(var(--theme-colors-chroma) * 0.85);
|
||||
--theme-colors-chroma-900: calc(var(--theme-colors-chroma) * 0.7);
|
||||
--theme-colors-chroma-950: calc(var(--theme-colors-chroma) * 0.5);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
:root {
|
||||
--airi-theme-primary-50: #fff0f2;
|
||||
--airi-theme-primary-100: #ffe3e6;
|
||||
--airi-theme-primary-200: #ffcad4;
|
||||
--airi-theme-primary-300: #ff9fb0;
|
||||
--airi-theme-primary-400: #ff6988;
|
||||
--airi-theme-primary-500: #fe456e;
|
||||
--airi-theme-primary-600: #ec124d;
|
||||
--airi-theme-primary-700: #c70941;
|
||||
--airi-theme-primary-800: #a70a3e;
|
||||
--airi-theme-primary-900: #8e0d3b;
|
||||
--airi-theme-primary-950: #50011b;
|
||||
}
|
||||
Reference in New Issue
Block a user