feat(stage-tamagotchi): settings from web ui

This commit is contained in:
Neko Ayaka
2025-03-11 16:41:09 +08:00
parent 6aadb9f0e6
commit d6a2ac35a7
149 changed files with 1609 additions and 328 deletions
+7 -1
View File
@@ -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;
}