refactor(stage-*): move shared pages to stage-pages package

This commit is contained in:
Neko Ayaka
2025-10-03 15:48:45 +08:00
parent e2df261364
commit e52efebd6c
145 changed files with 1130 additions and 8248 deletions
@@ -24,15 +24,45 @@ export default defineConfig({
renderer: {
optimizeDeps: {
exclude: [
// Internal Packages
'@proj-airi/stage-ui/*',
'@proj-airi/drizzle-duckdb-wasm',
'@proj-airi/drizzle-duckdb-wasm/*',
// Static Assets: Models, Images, etc.
'src/renderer/public/assets/*',
// Live2D SDK
'@framework/live2dcubismframework',
'@framework/math/cubismmatrix44',
'@framework/type/csmvector',
'@framework/math/cubismviewmatrix',
'@framework/cubismdefaultparameterid',
'@framework/cubismmodelsettingjson',
'@framework/effect/cubismbreath',
'@framework/effect/cubismeyeblink',
'@framework/model/cubismusermodel',
'@framework/motion/acubismmotion',
'@framework/motion/cubismmotionqueuemanager',
'@framework/type/csmmap',
'@framework/utils/cubismdebug',
'@framework/model/cubismmoc',
],
},
resolve: {
alias: {
'@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')),
'@proj-airi/server-sdk': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-sdk', 'src')),
'@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')),
'@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')),
'@proj-airi/stage-pages': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src')),
},
},
server: {
warmup: {
clientFiles: [
`${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src'))}/*.vue`,
`${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src'))}/*.vue`,
],
},
},
plugins: [
@@ -51,7 +81,10 @@ export default defineConfig({
VueRouter({
dts: resolve(import.meta.dirname, 'src/renderer/typed-router.d.ts'),
routesFolder: 'src/renderer/pages',
routesFolder: [
resolve(import.meta.dirname, 'src', 'renderer', 'pages'),
resolve(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src', 'pages'),
],
}),
VitePluginVueDevTools(),
@@ -74,10 +107,5 @@ export default defineConfig({
Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', 'AvatarSample_A.vrm', 'assets/vrm/models/AvatarSample-A'),
Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', 'AvatarSample_B.vrm', 'assets/vrm/models/AvatarSample-B'),
],
server: {
watch: {
ignored: ['**/src-tauri/**'],
},
},
},
})
@@ -1,313 +0,0 @@
<script setup lang="ts">
import type { ccv3 } from '@proj-airi/ccc'
import { Alert } from '@proj-airi/stage-ui/components'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
import { InputFile } from '@proj-airi/ui'
import { Select } from '@proj-airi/ui/components/form'
import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import CardCreate from './components/CardCreate.vue'
import CardCreationDialog from './components/CardCreationDialog.vue'
import CardDetailDialog from './components/CardDetailDialog.vue'
import CardListItem from './components/CardListItem.vue'
import DeleteCardDialog from './components/DeleteCardDialog.vue'
const { t } = useI18n()
const cardStore = useAiriCardStore()
const { addCard, removeCard } = cardStore
const { cards, activeCardId } = storeToRefs(cardStore)
// Currently selected card ID (different from active card ID)
const selectedCardId = ref<string>('')
// Dialog state
const isCardDialogOpen = ref(false)
const isCardCreationDialogOpen = ref(false)
// Search query
const searchQuery = ref('')
// Sort option
const sortOption = ref('nameAsc')
const inputFiles = ref<File[]>([])
// Card list data structure
interface CardItem {
id: string
name: string
description?: string
deprecated?: boolean
customizable?: boolean
}
watch(inputFiles, async (newFiles) => {
const file = newFiles[0]
if (!file)
return
try {
const content = await file.text()
const cardJSON = JSON.parse(content) as ccv3.CharacterCardV3
// Add card and select it
selectedCardId.value = addCard(cardJSON)
isCardDialogOpen.value = true
}
catch (error) {
console.error('Error processing card file:', error)
}
})
// Transform cards Map to array for display
const cardsArray = computed<CardItem[]>(() =>
Array.from(cards.value.entries()).map(([id, card]) => ({
id,
name: card.name,
description: card.description,
})),
)
// Filtered cards based on search query
const filteredCards = computed<CardItem[]>(() => {
if (!searchQuery.value)
return cardsArray.value
const query = searchQuery.value.toLowerCase()
return cardsArray.value.filter(item =>
item.name.toLowerCase().includes(query)
|| (item.description && item.description.toLowerCase().includes(query)),
)
})
// Sorted filtered cards based on sort option
const sortedFilteredCards = computed<CardItem[]>(() => {
// Create a new array to avoid mutating the source
const sorted = [...filteredCards.value]
if (sortOption.value === 'nameAsc')
return sorted.sort((a, b) => a.name.localeCompare(b.name))
else if (sortOption.value === 'nameDesc')
return sorted.sort((a, b) => b.name.localeCompare(a.name))
else if (sortOption.value === 'recent')
return sorted.sort((a, b) => b.id.localeCompare(a.id))
else
return sorted
})
// Delete confirmation
const showDeleteConfirm = ref(false)
const cardToDelete = ref<string | null>(null)
function handleDeleteConfirm() {
if (cardToDelete.value) {
removeCard(cardToDelete.value)
cardToDelete.value = null
showDeleteConfirm.value = false
}
}
// Card deletion confirmation
function confirmDelete(id: string) {
cardToDelete.value = id
showDeleteConfirm.value = true
}
function handleSelectCard(cardId: string) {
selectedCardId.value = cardId
isCardDialogOpen.value = true
}
function handleCardCreationDialog() {
isCardCreationDialogOpen.value = true
}
// Card activation
function activateCard(id: string) {
activeCardId.value = id
}
// Card version number
function getVersionNumber(id: string) {
const card = cards.value.get(id)
return card?.version || '1.0.0'
}
// Card module short name
function getModuleShortName(id: string, module: 'consciousness' | 'voice') {
const card = cards.value.get(id)
if (!card || !card.extensions?.airi?.modules)
return 'default'
const airiExt = card.extensions.airi.modules
if (module === 'consciousness') {
return airiExt.consciousness?.model ? airiExt.consciousness.model.split('-').pop() || 'default' : 'default'
}
else if (module === 'voice') {
return airiExt.speech?.voice_id || 'default'
}
return 'default'
}
</script>
<template>
<div rounded-xl p-4 flex="~ col gap-4">
<!-- Toolbar with search and filters -->
<div flex="~ row" flex-wrap items-center justify-between gap-4>
<!-- Search bar -->
<div class="relative min-w-[200px] flex-1" inline-flex="~" w-full items-center>
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<div i-solar:magnifer-line-duotone class="text-neutral-500 dark:text-neutral-400" />
</div>
<input
v-model="searchQuery"
type="search"
class="w-full rounded-xl p-2.5 pl-10 text-sm outline-none"
border="focus:primary-100 dark:focus:primary-400/50 2 solid neutral-200 dark:neutral-800"
transition="all duration-200 ease-in-out"
bg="white dark:neutral-900"
:placeholder="t('settings.pages.card.search')"
>
</div>
<!-- Sort options -->
<div class="relative flex flex-row justify-start gap-2 lg:flex-col">
<div class="top-[-32px] whitespace-nowrap text-sm text-neutral-500 leading-10 lg:absolute dark:text-neutral-400">
{{ t('settings.pages.card.sort_by') }}:
</div>
<Select
v-model="sortOption"
:options="[
{ value: 'nameAsc', label: t('settings.pages.card.name_asc') },
{ value: 'nameDesc', label: t('settings.pages.card.name_desc') },
{ value: 'recent', label: t('settings.pages.card.recent') },
]"
placeholder="Select sort option"
class="min-w-[150px]"
/>
</div>
</div>
<!-- Masonry card layout -->
<div
class="mt-4"
:class="{ 'grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 grid-auto-rows-[minmax(min-content,max-content)] grid-auto-flow-dense sm:grid-cols-[repeat(auto-fill,minmax(240px,1fr))] sm:gap-5 md:grid-cols-[repeat(auto-fill,minmax(220px,1fr))] lg:grid-cols-[repeat(auto-fill,minmax(250px,1fr))]': cards.size > 0 }"
>
<!-- Upload card -->
<InputFile v-model="inputFiles" accept="*.json">
<template #default="{ isDragging }">
<template v-if="!isDragging">
<div flex flex-col items-center>
<div i-solar:upload-square-line-duotone mb-4 text-5xl text="neutral-400 dark:neutral-500" />
<p font-medium text="neutral-600 dark:neutral-300">
{{ t('settings.pages.card.upload') }}
</p>
<p text="neutral-500 dark:neutral-400" mt-2 text-sm>
{{ t('settings.pages.card.upload_desc') }}
</p>
</div>
</template>
<template v-else>
<div flex flex-col items-center>
<div i-solar:upload-minimalistic-bold class="mb-2 text-5xl text-primary-500 dark:text-primary-400" />
<p font-medium text="primary-600 dark:primary-300">
{{ t('settings.pages.card.drop_here') }}
</p>
</div>
</template>
</template>
</InputFile>
<!-- Create card -->
<CardCreate @click="handleCardCreationDialog" />
<!-- Card Items -->
<template v-if="cards.size > 0">
<CardListItem
v-for="item in sortedFilteredCards"
:id="item.id"
:key="item.id"
:name="item.name"
:description="item.description"
:is-active="item.id === activeCardId"
:is-selected="item.id === selectedCardId && isCardDialogOpen"
:version="getVersionNumber(item.id)"
:consciousness-model="getModuleShortName(item.id, 'consciousness')"
:voice-model="getModuleShortName(item.id, 'voice')"
@select="handleSelectCard(item.id)"
@activate="activateCard(item.id)"
@delete="confirmDelete(item.id)"
/>
</template>
<!-- No cards message -->
<div
v-if="cards.size === 0"
class="col-span-full rounded-xl p-8 text-center"
border="~ neutral-200/50 dark:neutral-700/30"
bg="neutral-50/50 dark:neutral-900/50"
>
<div i-solar:card-search-broken mx-auto mb-3 text-6xl text-neutral-400 />
<p>{{ t('settings.pages.card.no_cards') }}</p>
</div>
<!-- No search results -->
<Alert
v-if="searchQuery && sortedFilteredCards.length === 0"
type="warning"
class="col-span-full"
>
<template #title>
{{ t('settings.pages.card.no_results') }}
</template>
<template #content>
{{ t('settings.pages.card.try_different_search') }}
</template>
</Alert>
</div>
</div>
<!-- Delete confirmation dialog -->
<DeleteCardDialog
v-model="showDeleteConfirm"
:card-name="cardToDelete ? cardStore.getCard(cardToDelete)?.name : ''"
@confirm="handleDeleteConfirm"
@cancel="cardToDelete = null"
/>
<!-- Card detail dialog -->
<CardDetailDialog
v-model="isCardDialogOpen"
:card-id="selectedCardId"
/>
<!-- Card detail dialog -->
<CardCreationDialog
v-model="isCardCreationDialogOpen"
/>
<!-- Background decoration -->
<div
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-15rem)]" bottom-0 right--5 z--1
:initial="{ scale: 0.9, opacity: 0, x: 20 }"
:enter="{ scale: 1, opacity: 1, x: 0 }"
:duration="500"
size-60
flex items-center justify-center
>
<div text="60" i-solar:emoji-funny-square-bold-duotone />
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,51 +1,15 @@
<script setup lang="ts">
import { IconItem } from '@proj-airi/stage-ui/components'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { computed, nextTick, ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import IconAnimation from '../../components/IconAnimation.vue'
const router = useRouter()
const iconAnimationStarted = ref(false)
const iconAnimation = ref<InstanceType<typeof IconAnimation>>()
const resolveAnimation = ref<() => void>()
const { t } = useI18n()
const animationIcon = ref('')
const animationPosition = ref('')
const showAnimationComponent = ref(false)
const settingsStore = useSettings()
function handleAnimationEnded() {
resolveAnimation.value?.()
}
async function handleIconItemClick(event: MouseEvent, setting: typeof settings.value[0]) {
const target = event.currentTarget as HTMLElement
const iconElement = target.querySelector('.menu-icon-item-icon') as HTMLElement
if (!iconElement)
return
// get the position of the icon element
const rect = iconElement.getBoundingClientRect()
const position = `${rect.left}px, ${rect.top}px`
// set the icon and position
animationIcon.value = setting.icon
animationPosition.value = position
// show the animation component
showAnimationComponent.value = true
// wait for the DOM to update
await nextTick()
// start the animation
iconAnimationStarted.value = true
}
const removeBeforeEach = router.beforeEach(async (_, __, next) => {
if (!settingsStore.usePageSpecificTransitions || settingsStore.disableTransitions) {
next()
@@ -123,22 +87,9 @@ const settings = computed(() => [
:description="setting.description"
:icon="setting.icon"
:to="setting.to"
@click="(e: MouseEvent) => handleIconItemClick(e, setting)"
/>
</div>
<IconAnimation
v-if="showAnimationComponent && !settingsStore.disableTransitions && settingsStore.usePageSpecificTransitions"
ref="iconAnimation"
:icon="animationIcon"
:icon-size="6 * 1.2"
:position="animationPosition"
:duration="1000"
text-color="text-neutral-400/50 dark:text-neutral-600/20"
:started="iconAnimationStarted"
@animation-ended.once="handleAnimationEnded"
/>
<div
v-else
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-12rem)]" bottom-0 right--10 z--1
@@ -1,89 +0,0 @@
<script setup lang="ts">
import type { Live2DCanvas } from '@proj-airi/stage-ui/components/scenes'
import { ModelSettings } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings'
import { Vibrant } from 'node-vibrant/browser'
import { ref } from 'vue'
import IconAnimation from '../../../components/IconAnimation.vue'
import { useIconAnimation } from '../../../composables/icon-animation'
const live2dCanvasRef = ref<InstanceType<typeof Live2DCanvas>>()
const palette = ref<string[]>([])
async function extractColorsFromModel() {
if (!live2dCanvasRef.value)
return
const frame = await live2dCanvasRef.value.captureFrame()
if (!frame) {
console.error('No frame captured')
return
}
const frameUrl = URL.createObjectURL(frame)
try {
const vibrant = new Vibrant(frameUrl)
const paletteFromVibrant = await vibrant.getPalette()
palette.value = Object.values(paletteFromVibrant).map(color => color?.hex).filter(it => typeof it === 'string')
}
finally {
URL.revokeObjectURL(frameUrl)
}
}
const {
iconAnimationStarted,
showIconAnimation,
animationIcon,
} = useIconAnimation('i-solar:people-nearby-bold-duotone')
</script>
<template>
<div flex class="relative h-full flex-col-reverse md:flex-row">
<ModelSettings
settings-class="w-100% md:w-40% lg:w-40% xl:w-25% 2xl:w-30% h-fit sm:max-h-80dvh overflow-y-scroll relative"
live-2d-scene-class="absolute max-h-[calc(100dvh-100px-56px)] w-full h-full"
vrm-scene-class="absolute max-h-[calc(100dvh-100px-56px)] w-full h-full"
:palette="palette" @extract-colors-from-model="extractColorsFromModel"
/>
</div>
<IconAnimation
v-if="showIconAnimation"
:z-index="-1"
:icon="animationIcon"
:icon-size="12"
:duration="1000"
:started="iconAnimationStarted"
:is-reverse="true"
position="calc(100dvw - 9.5rem), calc(100dvh - 9.5rem)"
text-color="text-neutral-200/50 dark:text-neutral-600/20"
/>
<div
v-else
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-15rem)]" bottom-0 right--5 z--1
:initial="{ scale: 0.9, opacity: 0, y: 15 }"
:enter="{ scale: 1, opacity: 1, y: 0 }"
:duration="500"
size-60
flex items-center justify-center
>
<div text="60" i-solar:people-nearby-bold-duotone />
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
pageSpecificAvailable: true
</route>
@@ -1,216 +0,0 @@
<script setup lang="ts">
import { Alert, ErrorContainer, RadioCardManySelect, RadioCardSimple } from '@proj-airi/stage-ui/components'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
const providersStore = useProvidersStore()
const consciousnessStore = useConsciousnessStore()
const { configuredChatProvidersMetadata } = storeToRefs(providersStore)
const {
activeProvider,
activeModel,
customModelName,
modelSearchQuery,
supportsModelListing,
providerModels,
isLoadingActiveProviderModels,
activeProviderModelError,
} = storeToRefs(consciousnessStore)
const { t } = useI18n()
async function loadModelsForProvider() {
if (activeProvider.value) {
await consciousnessStore.loadModelsForProvider(activeProvider.value)
}
}
onMounted(loadModelsForProvider)
watch(activeProvider, loadModelsForProvider)
function updateCustomModelName(value: string) {
customModelName.value = value
}
</script>
<template>
<div bg="neutral-50 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-4">
<div>
<div flex="~ col gap-4">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-500">
{{ t('settings.pages.providers.title') }}
</h2>
<div text="neutral-400 dark:neutral-400">
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.description') }}</span>
</div>
</div>
<div max-w-full>
<!--
fieldset has min-width set to --webkit-min-container, in order to use over flow scroll,
we need to set the min-width to 0.
See also: https://stackoverflow.com/a/33737340
-->
<fieldset
v-if="configuredChatProvidersMetadata.length > 0"
flex="~ row gap-4"
:style="{ 'scrollbar-width': 'none' }"
min-w-0 of-x-scroll scroll-smooth
role="radiogroup"
>
<RadioCardSimple
v-for="metadata in configuredChatProvidersMetadata"
:id="metadata.id"
:key="metadata.id"
v-model="activeProvider"
name="provider"
:value="metadata.id"
:title="metadata.localizedName || 'Unknown'"
:description="metadata.localizedDescription"
/>
</fieldset>
<div v-else>
<RouterLink
class="flex items-center gap-3 rounded-lg p-4"
border="2 dashed neutral-200 dark:neutral-800"
bg="neutral-50 dark:neutral-800"
transition="colors duration-200 ease-in-out"
to="/settings/providers"
>
<div i-solar:warning-circle-line-duotone class="text-2xl text-amber-500 dark:text-amber-400" />
<div class="flex flex-col">
<span class="font-medium">No Providers Configured</span>
<span class="text-sm text-neutral-400 dark:text-neutral-500">Click here to set up your LLM
providers</span>
</div>
<div i-solar:arrow-right-line-duotone class="ml-auto text-xl text-neutral-400 dark:text-neutral-500" />
</RouterLink>
</div>
</div>
</div>
</div>
<!-- Model selection section -->
<div v-if="activeProvider && supportsModelListing">
<div flex="~ col gap-4">
<div>
<h2 class="text-lg md:text-2xl">
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.title') }}
</h2>
<div text="neutral-400 dark:neutral-400">
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.subtitle') }}</span>
</div>
</div>
<!-- Loading state -->
<div v-if="isLoadingActiveProviderModels" class="flex items-center justify-center py-4">
<div class="mr-2 animate-spin">
<div i-solar:spinner-line-duotone text-xl />
</div>
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.loading') }}</span>
</div>
<!-- Error state -->
<ErrorContainer
v-else-if="activeProviderModelError"
:title="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.error')"
:error="activeProviderModelError"
/>
<!-- No models available -->
<Alert
v-else-if="providerModels.length === 0 && !isLoadingActiveProviderModels"
type="warning"
>
<template #title>
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_models') }}
</template>
<template #content>
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_models_description') }}
</template>
</Alert>
<!-- Using the new RadioCardManySelect component -->
<template v-else-if="providerModels.length > 0">
<RadioCardManySelect
v-model="activeModel"
v-model:search-query="modelSearchQuery"
:items="providerModels.sort((a, b) => a.id === activeModel ? -1 : b.id === activeModel ? 1 : 0)"
:searchable="true"
:search-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_placeholder')"
:search-no-results-title="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results')"
:search-no-results-description="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results_description', { query: modelSearchQuery })"
:search-results-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_results', { count: '{count}', total: '{total}' })"
:custom-input-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.custom_model_placeholder')"
:expand-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.expand')"
:collapse-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.collapse')"
@update:custom-value="updateCustomModelName"
/>
</template>
</div>
</div>
<!-- Provider doesn't support model listing -->
<div v-else-if="activeProvider && !supportsModelListing">
<div flex="~ col gap-4">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.title') }}
</h2>
<div text="neutral-400 dark:neutral-500">
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.subtitle') }}</span>
</div>
</div>
<div
class="flex items-center gap-3 border border-primary-200 rounded-lg bg-primary-50 p-4 dark:border-primary-800 dark:bg-primary-900/20"
>
<div i-solar:info-circle-line-duotone class="text-2xl text-primary-500 dark:text-primary-400" />
<div class="flex flex-col">
<span class="font-medium">{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.not_supported')
}}</span>
<span class="text-sm text-primary-600 dark:text-primary-400">{{
t('settings.pages.modules.consciousness.sections.section.provider-model-selection.not_supported_description') }}</span>
</div>
</div>
<!-- Manual model input for providers without model listing -->
<div class="mt-2">
<label class="mb-1 block text-sm font-medium">
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.manual_model_name') }}
</label>
<input
v-model="activeModel" type="text"
class="w-full border border-neutral-300 rounded bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900"
:placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.manual_model_placeholder')"
>
</div>
</div>
</div>
</div>
<div
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-15rem)]" bottom-0 right--5 z--1
:initial="{ scale: 0.9, opacity: 0, x: 20 }"
:enter="{ scale: 1, opacity: 1, x: 0 }"
:duration="500"
size-60
flex items-center justify-center
>
<div text="60" i-solar:ghost-bold-duotone />
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,650 +0,0 @@
<script setup lang="ts">
// import type { TranscriptionProvider } from '@xsai-ext/shared-providers'
import { Alert, Button, ErrorContainer, LevelMeter, RadioCardManySelect, RadioCardSimple, TestDummyMarker, ThresholdMeter, TimeSeriesChart } from '@proj-airi/stage-ui/components'
import { useAudioAnalyzer } from '@proj-airi/stage-ui/composables'
// import { useAudioAnalyzer, useAudioRecorder } from '@proj-airi/stage-ui/composables'
import { useAudioContext } from '@proj-airi/stage-ui/stores/audio'
import { useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { FieldCheckbox, FieldRange, FieldSelect } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
// import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
// import workletUrl from '../../../tauri/vad/process.worklet?worker&url'
// import { createVAD, createVADStates } from '../../../tauri/vad'
const { t } = useI18n()
const hearingStore = useHearingStore()
const {
activeTranscriptionProvider,
activeTranscriptionModel,
providerModels,
activeProviderModelError,
isLoadingActiveProviderModels,
supportsModelListing,
transcriptionModelSearchQuery,
activeCustomModelName,
} = storeToRefs(hearingStore)
const providersStore = useProvidersStore()
const { configuredTranscriptionProvidersMetadata } = storeToRefs(providersStore)
const { stopStream, startStream } = useSettingsAudioDevice()
const { audioInputs, selectedAudioInput, stream } = storeToRefs(useSettingsAudioDevice())
// const { startRecord, stopRecord, onStopRecord } = useAudioRecorder(stream)
const { startAnalyzer, stopAnalyzer, onAnalyzerUpdate, volumeLevel } = useAudioAnalyzer()
const { audioContext } = storeToRefs(useAudioContext())
const error = ref<string>('')
const vadModelError = ref('')
const isMonitoring = ref(false)
const enablePlayback = ref(false)
// Audio processing state
const gainNode = ref<GainNode>()
const animationFrame = ref<number>()
// Audio levels and indicators
const isSpeaking = ref(false)
const speakingThreshold = ref(25) // 0-100 (for volume-based fallback)
const monitorVolume = ref(50) // 0-100
// VAD integration
// const vadManager = ref<ReturnType<typeof createVADStates>>()
const isVADModelLoaded = ref(false)
const isLoadingVADModel = ref(false)
const useVADModel = ref(true) // Toggle between VAD and volume-based detection
const vadProbability = ref(0) // Raw VAD probability
const vadThreshold = ref(0.5) // VAD probability threshold for speech detection
// VAD visualization
const vadHistory = ref<number[]>([]) // History for chart visualization
// const maxVadHistory = 50 // Keep 50 samples (~1.6 seconds at 32ms intervals)
const audios = ref<Blob[]>([])
const audioCleanups = ref<(() => void)[]>([])
const audioURLs = computed(() => {
return audios.value.map((blob) => {
const url = URL.createObjectURL(blob)
audioCleanups.value.push(() => URL.revokeObjectURL(url))
return url
})
})
const transcriptions = ref<string[]>([])
// // VAD functions
// async function loadVADModel() {
// if (isVADModelLoaded.value || isLoadingVADModel.value)
// return
// isLoadingVADModel.value = true
// vadModelError.value = ''
// try {
// // // Create and initialize the VAD
// // const vad = await createVAD({
// // sampleRate: 16000,
// // speechThreshold: vadThreshold.value,
// // exitThreshold: vadThreshold.value * 0.3,
// // minSilenceDurationMs: 400,
// // })
// // // Set up event handlers
// // vad.on('speech-start', () => {
// // isSpeaking.value = true
// // startRecord() // Start recording when speech is detected
// // })
// // vad.on('speech-end', () => {
// // isSpeaking.value = false
// // stopRecord() // Stop recording when speech ends
// // })
// // vad.on('debug', ({ data }) => {
// // if (data?.probability !== undefined) {
// // vadProbability.value = data.probability
// // // Update VAD history for visualization
// // vadHistory.value.push(data.probability)
// // if (vadHistory.value.length > maxVadHistory) {
// // vadHistory.value.shift()
// // }
// // }
// // })
// // vad.on('status', ({ type, message }) => {
// // if (type === 'error') {
// // vadModelError.value = message
// // }
// // })
// // // Create and initialize audio manager
// // const manager = createVADStates(vad, workletUrl, {
// // minChunkSize: 512,
// // // NOTICE: VAD will have it's own audio context since
// // // it needs special sample rate and latency settings
// // audioContextOptions: {
// // sampleRate: 16000,
// // latencyHint: 'interactive',
// // },
// // })
// // await manager.initialize()
// // vadManager.value = manager
// // isVADModelLoaded.value = true
// }
// catch (error) {
// vadModelError.value = error instanceof Error ? error.message : String(error)
// console.error('Failed to load VAD model:', error)
// }
// finally {
// isLoadingVADModel.value = false
// }
// }
// onStopRecord(async (recording) => {
// if (!recording)
// return
// try {
// if (recording && recording.size > 0) {
// audios.value.push(recording)
// const provider = await providersStore.getProviderInstance<TranscriptionProvider<string>>(activeTranscriptionProvider.value)
// if (!provider) {
// throw new Error('Failed to initialize speech provider')
// }
// // Get model from configuration or use default
// const model = activeTranscriptionModel.value
// const res = await hearingStore.transcription(provider, model, new File([recording], 'recording.wav'))
// transcriptions.value.push(res.text)
// }
// }
// catch (err) {
// error.value = err instanceof Error ? err.message : String(err)
// console.error('Error generating transcription:', error.value)
// }
// })
// Audio monitoring
async function setupAudioMonitoring() {
try {
if (!selectedAudioInput.value) {
console.warn('No audio input device selected')
return
}
// Clean up existing connections
await stopAudioMonitoring()
await startStream()
if (!stream.value) {
console.warn('No audio stream available')
return
}
const source = audioContext.value.createMediaStreamSource(stream.value)
const analyzer = startAnalyzer(audioContext.value)
onAnalyzerUpdate((volumeLevel) => {
// Fallback speaking detection (when VAD model is not used)
if (!useVADModel.value || !isVADModelLoaded.value) {
isSpeaking.value = volumeLevel > speakingThreshold.value
}
})
// Create gain node for playback volume control
gainNode.value = audioContext.value.createGain()
gainNode.value.gain.value = enablePlayback.value ? (monitorVolume.value / 100) : 0
// Connect audio graph
if (analyzer)
source.connect(analyzer)
if (enablePlayback.value) {
source.connect(gainNode.value)
gainNode.value.connect(audioContext.value.destination)
}
// // Load VAD model and start VAD processing if enabled
// if (useVADModel.value) {
// await loadVADModel()
// if (vadManager.value) {
// await vadManager.value.start(stream.value)
// }
// }
}
catch (error) {
console.error('Error setting up audio monitoring:', error)
vadModelError.value = error instanceof Error ? error.message : String(error)
}
}
async function stopAudioMonitoring() {
if (animationFrame.value) { // Stop animation frame
cancelAnimationFrame(animationFrame.value)
animationFrame.value = undefined
}
// if (vadManager.value) { // Stop VAD manager
// await vadManager.value.stop()
// }
if (stream.value) { // Stop media stream
stopStream()
}
stopAnalyzer()
gainNode.value = undefined
isSpeaking.value = false
vadProbability.value = 0
vadHistory.value = []
}
// Update playback routing when playback setting changes
async function updatePlayback() {
if (!audioContext.value || !gainNode.value)
return
if (enablePlayback.value) {
gainNode.value.gain.value = monitorVolume.value / 100
gainNode.value.connect(audioContext.value.destination)
}
else {
gainNode.value.gain.value = 0
gainNode.value.disconnect()
}
}
// Watchers
watch(selectedAudioInput, async () => {
if (isMonitoring.value) {
await setupAudioMonitoring()
}
})
watch(enablePlayback, updatePlayback)
watch(monitorVolume, () => {
if (gainNode.value && enablePlayback.value) {
gainNode.value.gain.value = monitorVolume.value / 100
}
})
// watch(vadThreshold, () => {
// // Update VAD threshold if model is loaded
// if (vadManager.value && isVADModelLoaded.value) {
// // TODO: We would need to add an updateConfig method to VADAudioManager
// }
// })
// Monitoring toggle
async function toggleMonitoring() {
if (!isMonitoring.value) {
await setupAudioMonitoring()
isMonitoring.value = true
}
else {
await stopAudioMonitoring()
isMonitoring.value = false
}
}
// Speaking indicator with enhanced VAD visualization
const speakingIndicatorClass = computed(() => {
if (!useVADModel.value || !isVADModelLoaded.value) {
// Volume-based: simple green/white
return isSpeaking.value
? 'bg-green-500 shadow-lg shadow-green-500/50'
: 'bg-white dark:bg-neutral-900 border-2 border-neutral-300 dark:border-neutral-600'
}
// VAD-based: color intensity based on probability
const prob = vadProbability.value
const threshold = vadThreshold.value
if (prob > threshold) {
// Speaking: green (could add intensity in future)
return `bg-green-500 shadow-lg shadow-green-500/50`
}
else if (prob > threshold * 0.5) {
// Close to threshold: yellow
return 'bg-yellow-500 shadow-lg shadow-yellow-500/30'
}
else {
// Low probability: neutral
return 'bg-white dark:bg-neutral-900 border-2 border-neutral-300 dark:border-neutral-600'
}
})
function updateCustomModelName(value: string) {
activeCustomModelName.value = value
}
onMounted(async () => {
await hearingStore.loadModelsForProvider(activeTranscriptionProvider.value)
})
// onUnmounted(() => {
// stopAudioMonitoring()
// if (vadManager.value) {
// vadManager.value.dispose()
// }
// audioCleanups.value.forEach(cleanup => cleanup())
// })
</script>
<template>
<div flex="~ col md:row gap-6">
<div bg="neutral-100 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-4" class="h-fit w-full md:w-[40%]">
<div flex="~ col gap-4">
<!-- Audio Input Selection -->
<div>
<FieldSelect
v-model="selectedAudioInput"
label="Audio Input Device"
description="Select the audio input device for your hearing module."
:options="audioInputs.map(input => ({
label: input.label || input.deviceId,
value: input.deviceId,
}))"
placeholder="Select an audio input device"
layout="vertical"
/>
</div>
<div flex="~ col gap-4">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-500">
{{ t('settings.pages.providers.title') }}
</h2>
<div text="neutral-400 dark:neutral-400">
<span>{{ t('settings.pages.modules.hearing.sections.section.provider-model-selection.description') }}</span>
</div>
</div>
<div max-w-full>
<!--
fieldset has min-width set to --webkit-min-container, in order to use over flow scroll,
we need to set the min-width to 0.
See also: https://stackoverflow.com/a/33737340
-->
<fieldset
v-if="configuredTranscriptionProvidersMetadata.length > 0"
flex="~ row gap-4"
:style="{ 'scrollbar-width': 'none' }"
min-w-0 of-x-scroll scroll-smooth
role="radiogroup"
>
<RadioCardSimple
v-for="metadata in configuredTranscriptionProvidersMetadata"
:id="metadata.id"
:key="metadata.id"
v-model="activeTranscriptionProvider"
name="provider"
:value="metadata.id"
:title="metadata.localizedName || 'Unknown'"
:description="metadata.localizedDescription"
/>
</fieldset>
<div v-else>
<RouterLink
class="flex items-center gap-3 rounded-lg p-4"
border="2 dashed neutral-200 dark:neutral-800"
bg="neutral-50 dark:neutral-800"
transition="colors duration-200 ease-in-out"
to="/settings/providers"
>
<div i-solar:warning-circle-line-duotone class="text-2xl text-amber-500 dark:text-amber-400" />
<div class="flex flex-col">
<span class="font-medium">No Providers Configured</span>
<span class="text-sm text-neutral-400 dark:text-neutral-500">Click here to set up your Transcription providers</span>
</div>
<div i-solar:arrow-right-line-duotone class="ml-auto text-xl text-neutral-400 dark:text-neutral-500" />
</RouterLink>
</div>
</div>
</div>
<!-- Model selection section -->
<div v-if="activeTranscriptionProvider && supportsModelListing">
<div flex="~ col gap-4">
<div>
<h2 class="text-lg md:text-2xl">
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.title') }}
</h2>
<div text="neutral-400 dark:neutral-400">
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.subtitle') }}</span>
</div>
</div>
<!-- Loading state -->
<div v-if="isLoadingActiveProviderModels" class="flex items-center justify-center py-4">
<div class="mr-2 animate-spin">
<div i-solar:spinner-line-duotone text-xl />
</div>
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.loading') }}</span>
</div>
<!-- Error state -->
<ErrorContainer
v-else-if="activeProviderModelError"
:title="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.error')"
:error="activeProviderModelError"
/>
<!-- No models available -->
<Alert
v-else-if="providerModels.length === 0 && !isLoadingActiveProviderModels"
type="warning"
>
<template #title>
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_models') }}
</template>
<template #content>
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_models_description') }}
</template>
</Alert>
<!-- Using the new RadioCardManySelect component -->
<template v-else-if="providerModels.length > 0">
<RadioCardManySelect
v-model="activeTranscriptionModel"
v-model:search-query="transcriptionModelSearchQuery"
:items="providerModels.sort((a, b) => a.id === activeTranscriptionModel ? -1 : b.id === activeTranscriptionModel ? 1 : 0)"
:searchable="true"
:search-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_placeholder')"
:search-no-results-title="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results')"
:search-no-results-description="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results_description', { query: transcriptionModelSearchQuery })"
:search-results-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_results', { count: '{count}', total: '{total}' })"
:custom-input-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.custom_model_placeholder')"
:expand-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.expand')"
:collapse-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.collapse')"
@update:custom-value="updateCustomModelName"
/>
</template>
</div>
</div>
</div>
</div>
<div flex="~ col gap-6" class="w-full md:w-[60%]">
<div w-full rounded-xl>
<h2 class="mb-4 text-lg text-neutral-500 md:text-2xl dark:text-neutral-400" w-full>
<div class="inline-flex items-center gap-4">
<TestDummyMarker />
<div>
{{ t('settings.pages.providers.provider.elevenlabs.playground.title') }}
</div>
</div>
</h2>
<ErrorContainer v-if="error" title="Error occurred" :error="error" mb-4 />
<Button class="mb-4" w-full @click="toggleMonitoring">
{{ isMonitoring ? 'Stop Monitoring' : 'Start Monitoring' }}
</Button>
<div>
<div v-for="(audio, index) in audioURLs" :key="index" class="mb-2">
<audio :src="audio" controls class="w-full" />
<div v-if="transcriptions[index]" class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
{{ transcriptions[index] }}
</div>
</div>
</div>
<div flex="~ col gap-4">
<div class="space-y-4">
<!-- Audio Level Visualization -->
<div class="space-y-3">
<!-- Volume Meter -->
<LevelMeter :level="volumeLevel" label="Input Level" />
<!-- VAD Probability Meter (when VAD model is active) -->
<ThresholdMeter
v-if="useVADModel && isVADModelLoaded"
:value="vadProbability"
:threshold="vadThreshold"
label="Probability of Speech"
below-label="Silence"
above-label="Speech"
threshold-label="Detection threshold"
/>
<!-- Threshold Controls -->
<div v-if="useVADModel && isVADModelLoaded" class="space-y-3">
<FieldRange
v-model="vadThreshold"
label="Sensitivity"
description="Adjust the threshold for speech detection"
:min="0.1"
:max="0.9"
:step="0.05"
:format-value="value => `${(value * 100).toFixed(0)}%`"
/>
</div>
<div v-else class="space-y-3">
<FieldRange
v-model="speakingThreshold"
label="Sensitivity"
description="Adjust the threshold for speech detection"
:min="1"
:max="80"
:step="1"
:format-value="value => `${value}%`"
/>
</div>
<!-- Speaking Indicator -->
<div class="flex items-center gap-3">
<div
class="h-4 w-4 rounded-full transition-all duration-200"
:class="speakingIndicatorClass"
/>
<span class="text-sm font-medium">
{{ isSpeaking ? 'Speaking Detected' : 'Silence' }}
</span>
<span class="ml-auto text-xs text-neutral-500">
{{ useVADModel && isVADModelLoaded ? 'Model Based' : 'Volume Based' }}
</span>
</div>
<!-- VAD Method Selection -->
<div class="border-t border-neutral-200 pt-3 dark:border-neutral-700">
<FieldCheckbox
v-model="useVADModel"
label="Model Based"
description="Use AI models for more accurate speech detection"
/>
<!-- VAD Model Status -->
<div v-if="useVADModel" class="mt-3 space-y-2">
<div v-if="isLoadingVADModel" class="flex items-center gap-2 text-primary-600 dark:text-primary-400">
<div class="animate-spin text-sm" i-solar:spinner-line-duotone />
<span class="text-sm">Loading...</span>
</div>
<ErrorContainer
v-else-if="vadModelError"
title="Inference error"
:error="vadModelError"
/>
<div v-else-if="isVADModelLoaded" class="flex items-center gap-2 text-green-600 dark:text-green-400">
<div class="text-sm" i-solar:check-circle-bold-duotone />
<span class="text-sm">Activated</span>
<span class="ml-auto text-xs text-neutral-500">
Probability: {{ (vadProbability * 100).toFixed(1) }}%
</span>
</div>
</div>
</div>
<!-- Voice Activity Visualization (when VAD model is active) -->
<TimeSeriesChart
v-if="useVADModel && isVADModelLoaded"
:history="vadHistory"
:current-value="vadProbability"
:threshold="vadThreshold"
:is-active="isSpeaking"
title="Voice Activity"
subtitle="Last 2 seconds"
active-label="Speaking"
active-legend-label="Voice detected"
inactive-legend-label="Silence"
threshold-label="Speech threshold"
/>
</div>
<!-- Audio Playback (Monitor) -->
<div v-if="isMonitoring" class="border-t border-neutral-200 pt-4 dark:border-neutral-700">
<FieldCheckbox
v-model="enablePlayback"
label="Monitor Audio (Listen)"
description="Enable audio playback monitoring (like OBS). Be careful of feedback!"
/>
<div v-if="enablePlayback" class="mt-3">
<FieldRange
v-model="monitorVolume"
label="Monitor Volume"
description="Control the volume of audio monitoring playback"
:min="0"
:max="100"
:step="5"
:format-value="value => `${value}%`"
/>
</div>
</div>
<!-- Warning for playback -->
<div v-if="enablePlayback" class="border border-amber-200 rounded-lg bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-900/20">
<div class="flex items-center gap-2 text-amber-700 dark:text-amber-300">
<div class="text-sm" i-solar:warning-circle-bold-duotone />
<span class="text-sm font-medium">Audio feedback warning</span>
</div>
<div class="mt-1 text-xs text-amber-600 dark:text-amber-400">
Use headphones to prevent audio feedback. Lower the monitor volume if you hear echoing.
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,69 +0,0 @@
<script setup lang="ts">
import { IconStatusItem } from '@proj-airi/stage-ui/components'
import { useModulesList } from '@proj-airi/stage-ui/composables/use-modules-list'
import IconAnimation from '../../../components/IconAnimation.vue'
import { useIconAnimation } from '../../../composables/icon-animation'
const { modulesList } = useModulesList()
const {
iconAnimationStarted,
showIconAnimation,
animationIcon,
} = useIconAnimation('i-solar:layers-bold-duotone')
</script>
<template>
<div grid="~ cols-1 sm:cols-2 gap-4">
<IconStatusItem
v-for="(module, index) of modulesList"
:key="module.id"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + index * 10"
:delay="index * 50"
:title="module.name"
:description="module.description"
:icon="module.icon"
:icon-color="module.iconColor"
:icon-image="module.iconImage"
:to="module.to"
:configured="module.configured"
/>
</div>
<IconAnimation
v-if="showIconAnimation"
:icon="animationIcon"
:icon-size="12"
:duration="1000"
:started="iconAnimationStarted"
:is-reverse="true"
:z-index="-1"
text-color="text-neutral-200/50 dark:text-neutral-600/20"
position="calc(100dvw - 9.5rem), calc(100dvh - 9.5rem)"
/>
<div
v-else
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-15rem)]" bottom-0 right--5 z--1
:initial="{ scale: 0.9, opacity: 0, y: 20 }"
:enter="{ scale: 1, opacity: 1, y: 0 }"
:duration="500"
size-60
flex items-center justify-center
>
<div text="60" i-solar:layers-bold-duotone />
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
pageSpecificAvailable: true
</route>
@@ -1,152 +0,0 @@
<script setup lang="ts">
// import type { Tool } from '@proj-airi/tauri-plugin-mcp'
import { useMcpStore } from '@proj-airi/stage-ui/stores/mcp'
// import { connectServer, disconnectServer, listTools } from '@proj-airi/tauri-plugin-mcp'
import {
FieldInput,
} from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
const mcpStore = useMcpStore()
const connecting = ref(false)
const {
serverCmd,
serverArgs,
connected,
} = storeToRefs(mcpStore)
interface Tool {
name: string
description: string
}
const tools = ref<Tool[]>([])
async function connect() {
connecting.value = true
try {
// await connectServer(serverCmd.value, serverArgs.value.split(' '))
connected.value = true
}
catch (e) {
const error = e as string
console.error(error)
if (error.includes('already connected')) {
connected.value = true
}
}
finally {
connecting.value = false
}
}
async function disconnect() {
// await disconnectServer()
connected.value = false
tools.value = []
}
async function getTools() {
// tools.value = await listTools()
}
</script>
<template>
<div flex="~ col md:row gap-6">
<div flex="~ col gap-6" class="w-full md:w-[60%]">
<div w-full rounded-xl>
<div flex="~ col gap-4">
<FieldInput
v-model="serverCmd"
type="text"
label="Server Command"
description="Enter the server command to run"
placeholder="docker"
:disabled="connecting || connected"
/>
<FieldInput
v-model="serverArgs"
type="text"
label="Server Arguments"
description="Enter the server command arguments"
placeholder="run -i --rm -e ADB_HOST=host.docker.internal ghcr.io/lemonnekogh/airi-android:v0.1.0"
:disabled="connecting || connected"
/>
<div flex="~ row" gap-4>
<button
v-if="!connected"
border="neutral-800 dark:neutral-200 solid 2" transition="border duration-250 ease-in-out"
rounded-lg px-4 text="neutral-100 dark:neutral-900" py-2 text-sm
:disabled="connecting || !serverCmd || !serverArgs"
:class="{ 'opacity-50 cursor-not-allowed': connecting || !serverCmd || !serverArgs }"
bg="neutral-700 dark:neutral-300" @click="connect"
>
<div flex="~ row" items-center gap-2>
<div i-solar:play-circle-bold-duotone />
<span>Connect</span>
</div>
</button>
<button
v-else border="primary-300 dark:primary-800 solid 2"
transition="border duration-250 ease-in-out" rounded-lg px-4 py-2 text-sm @click="disconnect"
>
<div flex="~ row" items-center gap-2>
<div i-solar:stop-circle-bold-duotone />
<span>Disconnect</span>
</div>
</button>
<button
v-if="connected"
border="neutral-800 dark:neutral-200 solid 2" transition="border duration-250 ease-in-out"
rounded-lg px-4 text="neutral-100 dark:neutral-900" py-2 text-sm
:disabled="connecting"
:class="{ 'opacity-50 cursor-not-allowed': connecting }"
bg="neutral-700 dark:neutral-300" @click="getTools"
>
<div flex="~ row" items-center gap-2>
<div solar:list-arrow-down-line-duotone />
<span>List Tools</span>
</div>
</button>
</div>
<div v-if="tools.length > 0" flex="~ col gap-4">
<div v-for="tool in tools" :key="tool.name" border="neutral-200 dark:neutral-800 solid 2" rounded-lg p-4>
<div text="neutral-900 dark:neutral-100" text-sm>
{{ tool.name }}
</div>
<div text="neutral-500 dark:neutral-400" text-xs>
{{ tool.description }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-15rem)]" bottom-0 right--5 z--1
:initial="{ scale: 0.9, opacity: 0, x: 20 }"
:enter="{ scale: 1, opacity: 1, x: 0 }"
:duration="500"
size-60
flex items-center justify-center
>
<div text="60" i-solar:user-speak-rounded-bold-duotone />
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,16 +0,0 @@
<script setup lang="ts">
// import { useServerStore } from '@proj-airi/stage-ui/stores/server'
// const serverStore = useServerStore()
</script>
<template>
<div />
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,502 +0,0 @@
<script setup lang="ts">
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
import {
Alert,
ErrorContainer,
RadioCardManySelect,
RadioCardSimple,
Skeleton,
TestDummyMarker,
VoiceCardManySelect,
} from '@proj-airi/stage-ui/components'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import {
FieldCheckbox,
FieldInput,
FieldRange,
Textarea,
} from '@proj-airi/ui'
import { watchDebounced } from '@vueuse/core'
import { generateSpeech } from '@xsai/generate-speech'
import { storeToRefs } from 'pinia'
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
const { t } = useI18n()
const providersStore = useProvidersStore()
const speechStore = useSpeechStore()
const { configuredSpeechProvidersMetadata } = storeToRefs(providersStore)
const {
activeSpeechProvider,
activeSpeechModel,
activeSpeechVoice,
activeSpeechVoiceId,
pitch,
isLoadingSpeechProviderVoices,
supportsModelListing,
providerModels,
isLoadingActiveProviderModels,
activeProviderModelError,
modelSearchQuery,
speechProviderError,
ssmlEnabled,
availableVoices,
} = storeToRefs(speechStore)
const voiceSearchQuery = ref('')
const useSSML = ref(false)
const testText = ref('Hello, my name is AI Assistant')
const ssmlText = ref('')
const isGenerating = ref(false)
const audioUrl = ref('')
const audioPlayer = ref<HTMLAudioElement | null>(null)
const errorMessage = ref('')
onMounted(async () => {
await providersStore.loadModelsForConfiguredProviders()
await speechStore.loadVoicesForProvider(activeSpeechProvider.value)
})
watchDebounced(activeSpeechProvider, async () => {
await providersStore.loadModelsForConfiguredProviders()
await speechStore.loadVoicesForProvider(activeSpeechProvider.value)
}, { debounce: 100 })
// Function to generate speech
async function generateTestSpeech() {
if (!testText.value.trim() && !useSSML.value)
return
if (useSSML.value && !ssmlText.value.trim())
return
if (!activeSpeechModel.value) {
console.error('No model selected')
return
}
if (!activeSpeechVoice.value) {
console.error('No voice selected')
return
}
const provider = await providersStore.getProviderInstance(activeSpeechProvider.value) as SpeechProviderWithExtraOptions<string, any>
if (!provider) {
console.error('Failed to initialize speech provider')
return
}
const providerConfig = providersStore.getProviderConfig(activeSpeechProvider.value)
isGenerating.value = true
errorMessage.value = ''
try {
// Stop any currently playing audio
if (audioUrl.value) {
stopTestAudio()
}
const input = useSSML.value
? ssmlText.value
: speechStore.supportsSSML ? speechStore.generateSSML(testText.value, activeSpeechVoice.value, { ...providerConfig, pitch: pitch.value }) : testText.value
const response = await generateSpeech({
...provider.speech(activeSpeechModel.value, providerConfig),
input,
voice: activeSpeechVoice.value.id,
})
// Convert the response to a blob and create an object URL
audioUrl.value = URL.createObjectURL(new Blob([response]))
// Play the audio
setTimeout(() => {
if (audioPlayer.value) {
audioPlayer.value.play()
}
}, 100)
}
catch (error) {
console.error('Error generating speech:', error)
errorMessage.value = error instanceof Error ? error.message : 'An unknown error occurred'
}
finally {
isGenerating.value = false
}
}
// Function to stop audio playback
function stopTestAudio() {
if (audioPlayer.value) {
audioPlayer.value.pause()
audioPlayer.value.currentTime = 0
}
// Clean up the object URL to prevent memory leaks
if (audioUrl.value) {
URL.revokeObjectURL(audioUrl.value)
audioUrl.value = ''
}
}
// Clean up when component is unmounted
onUnmounted(() => {
if (audioUrl.value) {
URL.revokeObjectURL(audioUrl.value)
}
})
function updateCustomVoiceName(value: string | undefined) {
if (!value) {
activeSpeechVoice.value = undefined
return
}
activeSpeechVoice.value = {
id: value,
name: value,
description: value,
previewURL: value,
languages: [{ code: 'en', title: 'English' }],
provider: activeSpeechProvider.value,
gender: 'male',
}
}
function updateCustomModelName(value: string) {
activeSpeechModel.value = value
}
</script>
<template>
<div flex="~ col md:row gap-6">
<div bg="neutral-100 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-4" class="h-fit w-full md:w-[40%]">
<div>
<div flex="~ col gap-4">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
{{ t('settings.pages.modules.speech.sections.section.provider-voice-selection.title') }}
</h2>
<div text="neutral-400 dark:neutral-500">
<span>{{ t('settings.pages.modules.speech.sections.section.provider-voice-selection.description') }}</span>
</div>
</div>
<div max-w-full>
<fieldset
v-if="configuredSpeechProvidersMetadata.length > 0" flex="~ row gap-4" :style="{ 'scrollbar-width': 'none' }"
min-w-0 of-x-scroll scroll-smooth role="radiogroup"
>
<RadioCardSimple
v-for="metadata in configuredSpeechProvidersMetadata"
:id="metadata.id"
:key="metadata.id"
v-model="activeSpeechProvider"
name="speech-provider"
:value="metadata.id"
:title="metadata.localizedName || 'Unknown'"
:description="metadata.localizedDescription"
/>
</fieldset>
<div v-else>
<RouterLink
class="flex items-center gap-3 rounded-lg p-4" border="2 dashed neutral-200 dark:neutral-800"
bg="neutral-50 dark:neutral-800" transition="colors duration-200 ease-in-out" to="/settings/providers"
>
<div i-solar:warning-circle-line-duotone class="text-2xl text-amber-500 dark:text-amber-400" />
<div class="flex flex-col">
<span class="font-medium">No Speech Providers Configured</span>
<span class="text-sm text-neutral-400 dark:text-neutral-500">Click here to set up your speech
providers</span>
</div>
<div i-solar:arrow-right-line-duotone class="ml-auto text-xl text-neutral-400 dark:text-neutral-500" />
</RouterLink>
</div>
</div>
</div>
<div>
<!-- Model selection section -->
<div v-if="activeSpeechProvider && supportsModelListing">
<div flex="~ col gap-4">
<div>
<h2 class="text-lg md:text-2xl">
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.title') }}
</h2>
<div text="neutral-400 dark:neutral-400">
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.subtitle') }}</span>
</div>
</div>
<!-- Loading state -->
<div v-if="isLoadingActiveProviderModels" class="flex items-center justify-center py-4">
<div class="mr-2 animate-spin">
<div i-solar:spinner-line-duotone text-xl />
</div>
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.loading') }}</span>
</div>
<!-- Error state -->
<ErrorContainer
v-else-if="activeProviderModelError"
:title="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.error')"
:error="activeProviderModelError"
/>
<!-- No models available -->
<Alert
v-else-if="providerModels.length === 0 && !isLoadingActiveProviderModels"
type="warning"
>
<template #title>
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_models') }}
</template>
<template #content>
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_models_description') }}
</template>
</Alert>
<!-- Using the new RadioCardManySelect component -->
<template v-else-if="providerModels.length > 0">
<RadioCardManySelect
v-model="activeSpeechModel"
v-model:search-query="modelSearchQuery"
:items="providerModels"
:searchable="true"
:search-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_placeholder')"
:search-no-results-title="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results')"
:search-no-results-description="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results_description', { query: modelSearchQuery })"
:search-results-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_results', { count: '{count}', total: '{total}' })"
:custom-input-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.custom_model_placeholder')"
:expand-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.expand')"
:collapse-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.collapse')"
@update:custom-value="updateCustomModelName"
/>
</template>
</div>
</div>
</div>
</div>
<!-- Voice Configuration Section -->
<div v-if="activeSpeechProvider">
<div flex="~ col gap-4">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
Voice Configuration
</h2>
<div text="neutral-400 dark:neutral-500">
<span>Customize how your AI assistant speaks</span>
</div>
</div>
<!-- Loading state -->
<div v-if="isLoadingSpeechProviderVoices">
<div class="flex flex-col gap-4">
<Skeleton class="w-full rounded-lg p-2.5 text-sm">
<div class="h-1lh" />
</Skeleton>
<div flex="~ row gap-4">
<Skeleton class="w-full rounded-lg p-4 text-sm">
<div class="h-1lh" />
</Skeleton>
<Skeleton class="w-full rounded-lg p-4 text-sm">
<div class="h-1lh" />
</Skeleton>
<Skeleton class="w-full rounded-lg p-4 text-sm">
<div class="h-1lh" />
</Skeleton>
</div>
<Skeleton class="w-full rounded-lg p-3 text-sm">
<div class="h-1lh" />
</Skeleton>
</div>
</div>
<!-- Error state -->
<!-- Voice selection with RadioCardManySelect -->
<div
v-else-if="availableVoices[activeSpeechProvider] && availableVoices[activeSpeechProvider].length > 0"
class="space-y-6"
>
<VoiceCardManySelect
v-model:search-query="voiceSearchQuery"
v-model:voice-id="activeSpeechVoiceId"
:voices="availableVoices[activeSpeechProvider]?.map(voice => ({
id: voice.id,
name: voice.name,
description: voice.description,
previewURL: voice.previewURL,
customizable: false,
}))"
:searchable="true"
:search-placeholder="t('settings.pages.modules.speech.sections.section.provider-voice-selection.search_voices_placeholder')"
:search-no-results-title="t('settings.pages.modules.speech.sections.section.provider-voice-selection.no_voices')"
:search-no-results-description="t('settings.pages.modules.speech.sections.section.provider-voice-selection.no_voices_description')"
:search-results-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.search_voices_results', { count: 0, total: 0 })"
:custom-input-placeholder="t('settings.pages.modules.speech.sections.section.provider-voice-selection.custom_voice_placeholder')"
:expand-button-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.show_more')"
:collapse-button-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.show_less')"
:play-button-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.play_sample')"
:pause-button-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.pause')"
@update:custom-value="updateCustomVoiceName"
/>
</div>
<ErrorContainer
v-else-if="speechProviderError"
title="Error loading voices"
:error="speechProviderError"
/>
<!-- No voices available -->
<Alert v-else type="warning">
<template #title>
{{ t('settings.pages.modules.speech.sections.section.provider-voice-selection.no_voices') }}
</template>
<template #content>
{{ t('settings.pages.modules.speech.sections.section.provider-voice-selection.no_voices_description') }}.
{{ t('settings.pages.modules.speech.sections.section.provider-voice-selection.no_voices_hint') }}
</template>
</Alert>
<!-- Voice parameters -->
<div flex="~ col gap-4">
<FieldRange
v-model="pitch"
label="Pitch"
description="Tune the pitch of the voice"
:min="-100" :max="100" :step="1"
:format-value="value => `${value}%`"
/>
<!-- SSML Support -->
<FieldCheckbox
v-model="ssmlEnabled"
label="Enable SSML"
description="Enable Speech Synthesis Markup Language for more control over speech output"
/>
</div>
<!-- Manual voice input when no voices are available -->
<div
v-if="!availableVoices[activeSpeechProvider] || availableVoices[activeSpeechProvider].length === 0"
class="mt-2 space-y-6"
>
<FieldInput
type="text"
label="Voice Name"
description="Enter the voice name for your custom voice"
placeholder="Enter voice name (e.g., 'Rachel', 'Josh')"
@update:model-value="updateCustomVoiceName"
/>
<!-- Model selection for ElevenLabs -->
<div v-if="activeSpeechProvider === 'elevenlabs'">
<label class="mb-1 block text-sm font-medium">
Model
</label>
<select
v-model="activeSpeechModel"
class="w-full border border-neutral-300 rounded bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900"
>
<option value="eleven_monolingual_v1">
Monolingual v1
</option>
<option value="eleven_multilingual_v1">
Multilingual v1
</option>
<option value="eleven_multilingual_v2">
Multilingual v2
</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div flex="~ col gap-6" class="w-full md:w-[60%]">
<div w-full rounded-xl>
<h2 class="mb-4 text-lg text-neutral-500 md:text-2xl dark:text-neutral-400" w-full>
<div class="inline-flex items-center gap-4">
<TestDummyMarker />
<div>
{{ t('settings.pages.providers.provider.elevenlabs.playground.title') }}
</div>
</div>
</h2>
<div flex="~ col gap-4">
<FieldCheckbox
v-model="useSSML"
label="Use Custom SSML"
description="Enable to input raw SSML instead of plain text"
/>
<template v-if="!useSSML">
<Textarea
v-model="testText" h-24
w-full
:placeholder="t('settings.pages.providers.provider.elevenlabs.playground.fields.field.input.placeholder')"
/>
</template>
<template v-else>
<textarea
v-model="ssmlText"
placeholder="Enter SSML text..."
border="neutral-100 dark:neutral-800 solid 2 focus:neutral-200 dark:focus:neutral-700"
transition="all duration-250 ease-in-out"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
h-48 w-full rounded-lg px-3 py-2 text-sm font-mono outline-none
/>
</template>
<div flex="~ row" gap-4>
<button
border="neutral-800 dark:neutral-200 solid 2" transition="border duration-250 ease-in-out"
rounded-lg px-4 text="neutral-100 dark:neutral-900" py-2 text-sm
:disabled="isGenerating || (!testText.trim() && !useSSML) || (useSSML && !ssmlText.trim()) || !activeSpeechVoice"
:class="{ 'opacity-50 cursor-not-allowed': isGenerating || (!testText.trim() && !useSSML) || (useSSML && !ssmlText.trim()) || !activeSpeechVoice }"
bg="neutral-700 dark:neutral-300" @click="generateTestSpeech"
>
<div flex="~ row" items-center gap-2>
<div i-solar:play-circle-bold-duotone />
<span>{{ isGenerating ? t('settings.pages.providers.provider.elevenlabs.playground.buttons.button.test-voice.generating') : t('settings.pages.providers.provider.elevenlabs.playground.buttons.button.test-voice.label') }}</span>
</div>
</button>
<button
v-if="audioUrl" border="primary-300 dark:primary-800 solid 2"
transition="border duration-250 ease-in-out" rounded-lg px-4 py-2 text-sm @click="stopTestAudio"
>
<div flex="~ row" items-center gap-2>
<div i-solar:stop-circle-bold-duotone />
<span>Stop</span>
</div>
</button>
</div>
<audio v-if="audioUrl" ref="audioPlayer" :src="audioUrl" controls class="mt-2 w-full" />
</div>
</div>
</div>
</div>
<div
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-15rem)]" bottom-0 right--5 z--1
:initial="{ scale: 0.9, opacity: 0, x: 20 }"
:enter="{ scale: 1, opacity: 1, x: 0 }"
:duration="500"
size-60
flex items-center justify-center
>
<div text="60" i-solar:user-speak-rounded-bold-duotone />
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,163 +0,0 @@
<script setup lang="ts">
import { IconStatusItem } from '@proj-airi/stage-ui/components'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import IconAnimation from '../../../components/IconAnimation.vue'
import { useIconAnimation } from '../../../composables/icon-animation'
const providersStore = useProvidersStore()
const {
allChatProvidersMetadata,
allAudioSpeechProvidersMetadata,
allAudioTranscriptionProvidersMetadata,
} = storeToRefs(providersStore)
const {
iconAnimationStarted,
showIconAnimation,
animationIcon,
} = useIconAnimation('i-solar:box-minimalistic-bold-duotone')
</script>
<template>
<div mb-6 flex flex-col gap-5>
<div bg="primary-500/10 dark:primary-800/25" rounded-lg p-4>
<div mb-2 text-xl font-normal text="primary-800 dark:primary-100">
First time here?
</div>
<div text="primary-700 dark:primary-300">
AIRI requires at least one <div bg="primary-500/10 dark:primary-800/25" inline-flex items-center gap-1 rounded-lg px-2 py-0.5 translate-y="[0.25lh]">
<div i-solar:chat-square-like-bold-duotone /><strong font-normal>Chat</strong>
</div> provider to be configured to think, and behave properly. You could think of
it as the brain of the characters living in AIRI system.
</div>
</div>
<div flex="~ row items-center gap-2">
<div i-solar:chat-square-like-bold-duotone text="neutral-500 dark:neutral-400 4xl" />
<div>
<div>
<span text="neutral-300 dark:neutral-500 sm sm:base">Text generation model providers. e.g. OpenRouter, OpenAI, Ollama.</span>
</div>
<div flex text-nowrap text="2xl sm:3xl" font-normal>
<div>
Chat
</div>
</div>
</div>
</div>
<div grid="~ cols-1 sm:cols-2 xl:cols-3 gap-4">
<IconStatusItem
v-for="(provider, index) of allChatProvidersMetadata"
:key="provider.id"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + index * 10"
:delay="index * 50"
:title="provider.localizedName || 'Unknown'"
:description="provider.localizedDescription"
:icon="provider.icon"
:icon-color="provider.iconColor"
:icon-image="provider.iconImage"
:to="`/settings/providers/${provider.id}`"
:configured="provider.configured"
/>
</div>
<div flex="~ row items-center gap-2" my-5>
<div i-solar:user-speak-rounded-bold-duotone text="neutral-500 dark:neutral-400 4xl" />
<div>
<div>
<span text="neutral-300 dark:neutral-500 sm sm:base">Speech (text-to-speech) model providers. e.g. ElevenLabs, Azure Speech.</span>
</div>
<div flex text-nowrap text="2xl sm:3xl" font-normal>
<div>
Speech
</div>
</div>
</div>
</div>
<div grid="~ cols-1 sm:cols-2 xl:cols-3 gap-4">
<IconStatusItem
v-for="(provider, index) of allAudioSpeechProvidersMetadata"
:key="provider.id"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + index * 10"
:delay="(allChatProvidersMetadata.length + index) * 50"
:title="provider.localizedName || 'Unknown'"
:description="provider.localizedDescription"
:icon="provider.icon"
:icon-color="provider.iconColor"
:icon-image="provider.iconImage"
:to="`/settings/providers/${provider.id}`"
:configured="provider.configured"
/>
</div>
<div flex="~ row items-center gap-2" my-5>
<div i-solar:microphone-3-bold-duotone text="neutral-500 dark:neutral-400 4xl" />
<div>
<div>
<span text="neutral-300 dark:neutral-500 sm sm:base">Transcription (speech-to-text) model providers. e.g. Whisper.cpp, OpenAI, Azure Speech</span>
</div>
<div flex text-nowrap text="2xl sm:3xl" font-normal>
<div>
Transcription
</div>
</div>
</div>
</div>
<div grid="~ cols-1 sm:cols-2 xl:cols-3 gap-4">
<IconStatusItem
v-for="(provider, index) of allAudioTranscriptionProvidersMetadata"
:key="provider.id"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + index * 10"
:delay="(allChatProvidersMetadata.length + allAudioSpeechProvidersMetadata.length + index) * 50"
:title="provider.localizedName || 'Unknown'"
:description="provider.localizedDescription"
:icon="provider.icon"
:icon-color="provider.iconColor"
:icon-image="provider.iconImage"
:to="`/settings/providers/${provider.id}`"
:configured="provider.configured"
/>
</div>
</div>
<IconAnimation
v-if="showIconAnimation"
:z-index="-1"
:icon="animationIcon"
:icon-size="12"
:duration="1000"
:started="iconAnimationStarted"
:is-reverse="true"
position="calc(100dvw - 9.5rem), calc(100dvh - 9.5rem)"
text-color="text-neutral-200/50 dark:text-neutral-600/20"
/>
<div
v-else
v-motion
text="neutral-500/5 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-15rem)]" bottom-0 right--5 z--1
:initial="{ scale: 0.9, opacity: 0, y: 20 }"
:enter="{ scale: 1, opacity: 1, y: 0 }"
:duration="500"
size-60
flex items-center justify-center
>
<div text="60" i-solar:box-minimalistic-bold-duotone />
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
pageSpecificAvailable: true
</route>
@@ -1,57 +0,0 @@
<script setup lang="ts">
import IconAnimation from '../../../components/IconAnimation.vue'
import { useIconAnimation } from '../../../composables/icon-animation'
const {
iconAnimationStarted,
showIconAnimation,
animationIcon,
} = useIconAnimation('i-solar:armchair-2-bold-duotone')
</script>
<template>
<div>
<Callout
label="In development, needs your help!"
theme="orange"
>
<div>
This functionality is still under development. If you have any suggestions or would like to contribute, please reach out to us on our <a underline decoration-dotted href="https://github.com/moeru-ai/airi/issues">GitHub issues page</a>.
The source code of this page is located at <a underline decoration-dotted href="https://github.com/moeru-ai/airi/tree/main/apps/stage-tamagotchi/src/pages/settings/scene/index.vue">here</a>.
</div>
</Callout>
</div>
<IconAnimation
v-if="showIconAnimation"
:z-index="-1"
:icon="animationIcon"
:icon-size="12"
:duration="1000"
:started="iconAnimationStarted"
:is-reverse="true"
position="calc(100dvw - 9.5rem), calc(100dvh - 9.5rem)"
text-color="text-neutral-200/50 dark:text-neutral-600/20"
/>
<div
v-else
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-15rem)]" bottom-0 right--5 z--1
:initial="{ scale: 0.9, opacity: 0, y: 20 }"
:enter="{ scale: 1, opacity: 1, y: 0 }"
:duration="500"
size-60
flex items-center justify-center
>
<div text="60" i-solar:armchair-2-bold-duotone />
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
pageSpecificAvailable: true
</route>
@@ -1,80 +0,0 @@
<script setup lang="ts">
import { all } from '@proj-airi/i18n'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { FieldCheckbox, FieldSelect } from '@proj-airi/ui'
import { useDark } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const settings = useSettings()
const { t } = useI18n()
const { allowVisibleOnAllWorkspaces } = storeToRefs(settings)
const dark = useDark()
const languages = computed(() => {
return Object.entries(all).map(([value, label]) => ({ label, value }))
})
</script>
<template>
<div rounded-lg bg-neutral-50 p-4 dark:bg-neutral-800 flex="~ col gap-4">
<FieldCheckbox
v-model="dark"
v-motion
mb-2
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (2 * 10)"
:delay="2 * 50"
:label="t('settings.theme.title')"
:description="t('settings.theme.description')"
/>
<FieldCheckbox
v-model="allowVisibleOnAllWorkspaces"
v-motion
mb-2
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (2 * 10)"
:delay="2 * 50"
:label="t('tamagotchi.settings.allow-visible-on-all-workspaces.title')"
:description="t('tamagotchi.settings.allow-visible-on-all-workspaces.description')"
/>
<!-- Language Setting -->
<FieldSelect
v-model="settings.language"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (3 * 10)"
:delay="3 * 50"
transition="all ease-in-out duration-250"
:label="t('settings.language.title')"
:description="t('settings.language.description')"
:options="languages"
/>
<div
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[65dvh]" right--15 z--1
:initial="{ scale: 0.9, opacity: 0, rotate: 30 }"
:enter="{ scale: 1, opacity: 1, rotate: 0 }"
:duration="250"
flex items-center justify-center
>
<div text="60" i-solar:emoji-funny-square-bold-duotone />
</div>
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -109,7 +109,7 @@ const { t } = useI18n()
<Section
v-motion
mb-2 :title="t('settings.pages.themes.sections.section.theme-presets.title')"
mb-2 :title="t('settings.pages.system.sections.section.theme-presets.title')"
icon="i-solar:magic-stick-2-bold-duotone"
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
@@ -86,8 +86,8 @@ useEventListener('click', (e) => {
const pressKeysMessage = computed(() => {
if (recordingKeys.value.modifier.length === 0)
return t('tamagotchi.settings.pages.themes.window-shortcuts.press-keys')
return `${t('tamagotchi.settings.pages.themes.window-shortcuts.press-keys')}: ${recordingKeys.value.modifier.join('+')}+${recordingKeys.value.key}`
return t('tamagotchi.settings.pages.system.window-shortcuts.press-keys')
return `${t('tamagotchi.settings.pages.system.window-shortcuts.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)
@@ -71,7 +71,7 @@ export const useShortcutsStore = defineStore('shortcuts', () => {
const shortcuts = ref([
{
name: 'tamagotchi.settings.pages.themes.window-shortcuts.toggle-move.label',
name: 'tamagotchi.settings.pages.system.window-shortcuts.toggle-move.label',
shortcut: useVersionedLocalStorage('shortcuts/window/move', 'Shift+Alt+N', { defaultVersion: '1.0.2', satisfiesVersionBy: v => v === '1.0.2', onVersionMismatch: () => ({ action: 'reset' }) }), // Shift + Alt + N
group: 'window',
type: 'move',
@@ -80,7 +80,7 @@ export const useShortcutsStore = defineStore('shortcuts', () => {
},
},
{
name: 'tamagotchi.settings.pages.themes.window-shortcuts.toggle-resize.label',
name: 'tamagotchi.settings.pages.system.window-shortcuts.toggle-resize.label',
shortcut: useVersionedLocalStorage('shortcuts/window/resize', 'Shift+Alt+A', { defaultVersion: '1.0.2', satisfiesVersionBy: v => v === '1.0.2', onVersionMismatch: () => ({ action: 'reset' }) }), // Shift + Alt + A
group: 'window',
type: 'resize',
@@ -89,7 +89,7 @@ export const useShortcutsStore = defineStore('shortcuts', () => {
},
},
{
name: 'tamagotchi.settings.pages.themes.window-shortcuts.toggle-ignore-mouse-event.label',
name: 'tamagotchi.settings.pages.system.window-shortcuts.toggle-ignore-mouse-event.label',
shortcut: useVersionedLocalStorage('shortcuts/window/debug', 'Shift+Alt+I', { defaultVersion: '1.0.2', satisfiesVersionBy: v => v === '1.0.2', onVersionMismatch: () => ({ action: 'reset' }) }), // Shift + Alt + I
group: 'window',
type: 'ignore-mouse-event',
-1
View File
@@ -20,7 +20,6 @@
"@date-fns/utc": "^2.1.1",
"@formkit/auto-animate": "^0.9.0",
"@huggingface/transformers": "^3.7.3",
"@llama-flow/core": "^0.4.4",
"@moeru/std": "catalog:",
"@nekopaw/tempora": "0.3.1-alpha.1",
"@proj-airi/audio": "workspace:^",
-36
View File
@@ -1,36 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
const containerRef = ref<HTMLDivElement>()
const fileInputRef = ref<HTMLInputElement>()
function handleFileUpload(e: Event) {
if (!e)
return
const file = fileInputRef.value?.files?.[0]
if (!file)
return
const audioElem = document.createElement('audio')
containerRef.value?.appendChild(audioElem)
audioElem.src = URL.createObjectURL(file)
audioElem.controls = true
audioElem.load()
audioElem.play()
}
</script>
<template>
<div>
<div>
<div ref="containerRef" />
<input
ref="fileInputRef"
type="file"
@change="handleFileUpload"
>
</div>
</div>
</template>
-184
View File
@@ -1,184 +0,0 @@
<script setup lang="ts">
import type { AssistantMessage, Message } from '@xsai/shared-chat'
import { createWorkflow, workflowEvent } from '@llama-flow/core'
import { withValidation } from '@llama-flow/core/middleware/validation'
import { runWorkflow } from '@llama-flow/core/stream/run'
import { useLocalStorage } from '@vueuse/core'
import { streamText } from '@xsai/stream-text'
import { ref, toRaw } from 'vue'
const baseUrl = useLocalStorage('settings/llm/baseUrl', 'https://openrouter.ai/api/v1/')
const apiKey = useLocalStorage('settings/llm/apiKey', '')
const model = useLocalStorage('settings/llm/model', 'openai/gpt-4o-mini')
const sendingMessage = ref('')
const messages = ref<Message[]>([])
const streamingMessage = ref<AssistantMessage>({ role: 'assistant', content: '' })
const loading = ref(false)
const sendingEvent = workflowEvent<void, 'sending'>()
const tokenEvent = workflowEvent<string, 'token'>()
const textEvent = workflowEvent<string, 'text'>()
const sentenceEvent = workflowEvent<string, 'sentence'>()
const doneEvent = workflowEvent<void, 'done'>()
async function handleChatSendMessage() {
loading.value = true
try {
const streamWorkflow = withValidation(createWorkflow(), [
[[sendingEvent], [tokenEvent, doneEvent]],
[[tokenEvent], [textEvent]],
[[textEvent], [sentenceEvent]],
])
streamWorkflow.handle([sendingEvent], async () => {
const { sendEvent } = streamWorkflow.createContext()
streamingMessage.value = { role: 'assistant', content: '' }
messages.value.push({ role: 'user', content: sendingMessage.value })
messages.value.push(streamingMessage.value)
const response = await streamText({
baseURL: baseUrl.value,
apiKey: apiKey.value,
model: model.value,
messages: messages.value.slice(0, messages.value.length - 1).map(msg => toRaw(msg)),
})
for await (const chunk of response.fullStream) {
if (chunk.type === 'text-delta')
sendEvent(tokenEvent.with(chunk.text || ''))
}
return doneEvent.with()
})
streamWorkflow.handle([tokenEvent], async (token) => {
if (!streamingMessage.value.content)
streamingMessage.value.content = token.data
else
streamingMessage.value.content += token.data
})
await runWorkflow(streamWorkflow, sendingEvent.with(), doneEvent)
}
catch (err) {
console.error(err)
}
finally {
loading.value = false
}
}
// function useMessageTerminationWorkflow(parentWorkflow: WithValidationWorkflow<[[[typeof textEvent], [typeof sentenceEvent]]]>) {
// let processed = ''
// parentWorkflow.handle([textEvent], async (sendEvent, text) => {
// const endMarker = /[.?!]/
// processed += text.data
// while (processed) {
// const endMarkerExecArray = endMarker.exec(processed)
// if (!endMarkerExecArray || typeof endMarkerExecArray.index === 'undefined')
// break
// const before = processed.slice(0, endMarkerExecArray.index + 1)
// const after = processed.slice(endMarkerExecArray.index + 1)
// sendEvent(sentenceEvent.with(before))
// processed = after
// }
// })
// parentWorkflow.handle([doneEvent], async () => {
// const { sendEvent } = getContext()
// const content = processed.trim()
// if (content)
// sendEvent(sentenceEvent.with(content))
// processed = ''
// })
// }
</script>
<template>
<div flex flex-col gap-2>
<!-- <h2 text-xl>
Storage
</h2> -->
<div flex="~ col" gap-2>
<div flex flex-col gap-2>
<div>
<span text-neutral-500 dark:text-neutral-400>LLM</span>
</div>
<div grid grid-cols-2 gap-2>
<label flex items-center gap-2>
<span text-nowrap>
Base URL
</span>
<input
v-model="baseUrl"
border="focus:primary-100 dark:focus:primary-400/50 2 solid neutral-200 dark:neutral-800"
transition="all duration-200 ease-in-out" text="disabled:neutral-400 dark:disabled:neutral-600"
cursor="disabled:not-allowed" w-full rounded-lg px-2 py-1 text-nowrap text-sm outline-none shadow="sm"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
>
</label>
<label flex items-center gap-2>
<span text-nowrap>
API Key
</span>
<input
v-model="apiKey"
type="password"
border="focus:primary-100 dark:focus:primary-400/50 2 solid neutral-200 dark:neutral-800"
transition="all duration-200 ease-in-out" text="disabled:neutral-400 dark:disabled:neutral-600"
cursor="disabled:not-allowed" w-full rounded-lg px-2 py-1 text-nowrap text-sm outline-none shadow="sm"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
>
</label>
<label flex items-center gap-2>
<span text-nowrap>
Model
</span>
<input
v-model="model"
border="focus:primary-100 dark:focus:primary-400/50 2 solid neutral-200 dark:neutral-800"
transition="all duration-200 ease-in-out" text="disabled:neutral-400 dark:disabled:neutral-600"
cursor="disabled:not-allowed" w-full rounded-lg px-2 py-1 text-nowrap text-sm outline-none shadow="sm"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
>
</label>
</div>
</div>
<div>
<textarea
v-model="sendingMessage"
border="focus:primary-100 dark:focus:primary-400/50 2 solid neutral-200 dark:neutral-800"
transition="all duration-200 ease-in-out" text="disabled:neutral-400 dark:disabled:neutral-600"
cursor="disabled:not-allowed" w-full rounded-lg px-2 py-1 text-nowrap text-sm outline-none shadow="sm"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
/>
</div>
<button rounded-lg bg="blue-100 dark:blue-900" px-4 py-2 @click="handleChatSendMessage">
Send
</button>
<div>
<div v-for="(message, index) of messages" :key="index">
<div v-if="message.role === 'user'">
<span>
{{ message.content }}
</span>
</div>
<div v-if="message.role === 'assistant'">
<span>
{{ message.content }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
-149
View File
@@ -1,149 +0,0 @@
<script setup lang="ts">
import { sleep } from '@moeru/std'
import { createQueue } from '@proj-airi/stage-ui/utils/queue'
import { onMounted, ref } from 'vue'
const temp = ref<string>('')
const audioQueue = createQueue<string>({
handlers: [
async (text) => {
// eslint-disable-next-line no-console
console.log('ready to play speech audio for', text)
},
],
})
const ttsQueue = createQueue<string>({
handlers: [
async (ctx) => {
// eslint-disable-next-line no-console
console.log('ready to stream speech audio for', ctx)
audioQueue.enqueue(ctx.data)
},
],
})
const textQueue = createQueue<string>({
handlers: [
async (ctx) => {
const endMarker = ['.', '?', '!']
let newEndPartDiscovered = false
for (const marker of endMarker) {
if (!ctx.data.includes(marker))
continue
// find the end of the sentence and push it to the queue with temp
const periodIndex = ctx.data.indexOf(marker)
// split
const beforePeriod = ctx.data.slice(0, periodIndex + 1)
const afterPeriod = ctx.data.slice(periodIndex + 1)
temp.value += beforePeriod
ttsQueue.enqueue(temp.value.trim())
temp.value = afterPeriod
newEndPartDiscovered = true
}
if (!newEndPartDiscovered)
temp.value += ctx.data
},
],
})
const textParts = [
'Hello',
' N',
'eko',
'! I',
' am',
' an',
' AI',
' assistant',
' trained',
' to',
' help',
' with',
' a',
' variety',
' of',
' tasks',
' such',
' as',
' answering',
' questions',
',',
' providing',
' information',
',',
' giving',
' recommendations',
',',
' and',
' more',
'. How',
' can',
' I',
' assist',
' you',
' today',
'?',
'Hello',
' N',
'eko',
',',
' I',
' am',
' an',
' AI',
' assistant',
'.',
' I',
' can',
' help',
' answer',
' questions',
',',
' provide',
' information',
',',
' assist',
' with',
' tasks',
',',
' and',
' engage',
' in',
' conversation',
'.',
' How',
' can',
' I',
' assist',
' you',
' today',
'?',
]
async function mockTextPartsStreamHandler() {
for (const part of textParts) {
await sleep(100)
textQueue.enqueue(part)
}
}
async function handler() {
mockTextPartsStreamHandler()
}
onMounted(() => {
handler()
})
</script>
<template>
<div>
<div />
</div>
</template>
@@ -1,22 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<!-- Style from ui/InputFile component, may be centralized later -->
<div
relative
class="min-h-[120px] flex flex-col cursor-pointer items-center justify-center border-neutral-200 rounded-xl bg-white/60 p-6 dark:border-neutral-700 hover:border-primary-300 dark:bg-black/30 hover:bg-white/80 dark:hover:border-primary-700 dark:hover:bg-black/40"
border="solid 2"
transition="all duration-300"
cursor-pointer opacity-95
hover="scale-100 opacity-100 shadow-md dark:shadow-lg"
>
<div i-solar:add-square-line-duotone mb-4 text-5xl text="neutral-400 dark:neutral-500" />
<p font-medium text="neutral-600 dark:neutral-300">
{{ t('settings.pages.card.create_card') }}
</p>
</div>
</template>
@@ -1,281 +0,0 @@
<script setup lang="ts">
import type { Card } from '@proj-airi/ccc'
import kebabcase from '@stdlib/string-base-kebabcase'
import { Button } from '@proj-airi/stage-ui/components'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
import { FieldInput, FieldValues } from '@proj-airi/ui'
import {
DialogContent,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
} from 'reka-ui'
import { computed, ref, toRaw } from 'vue'
import { useI18n } from 'vue-i18n'
interface Props {
modelValue: boolean
}
defineProps<Props>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const modelValue = defineModel<boolean>()
const { t } = useI18n()
const cardStore = useAiriCardStore()
// Tab type definition
interface Tab {
id: string
label: string
icon: string
}
// Active tab ID state
const activeTabId = ref('')
// Tabs for card details
const tabs: Tab[] = [
{ id: 'identity', label: t('settings.pages.card.creation.identity'), icon: 'i-solar:emoji-funny-square-bold-duotone' },
{ id: 'behavior', label: t('settings.pages.card.creation.behavior'), icon: 'i-solar:chat-round-line-bold-duotone' },
{ id: 'settings', label: t('settings.pages.card.creation.settings'), icon: 'i-solar:settings-bold-duotone' },
]
// Active tab state - set to first available tab by default
const activeTab = computed({
get: () => {
// If current active tab is not in available tabs, reset to first tab
if (!tabs.find(tab => tab.id === activeTabId.value))
return tabs[0]?.id || ''
return activeTabId.value
},
set: (value: string) => {
activeTabId.value = value
},
})
// Check for errors, and save built Cards :
const showError = ref<boolean>(false)
const errorMessage = ref<string>('')
function saveCard(card: Card): boolean {
// Before saving, let's validate what the user entered :
const rawCard: Card = toRaw(card)
if (!(rawCard.name!.length > 0)) { // ! is used, since a default value is provided, and computed values passed to v-model should never be undefined
// No name
showError.value = true
errorMessage.value = t('settings.pages.card.creation.errors.name')
return false
}
else if (!/^(?:\d+\.)+\d+$/.test(rawCard.version)) {
// Invalid version
showError.value = true
errorMessage.value = t('settings.pages.card.creation.errors.version')
return false
}
else if (!(rawCard.description!.length > 0)) {
// No description
showError.value = true
errorMessage.value = t('settings.pages.card.creation.errors.description')
return false
}
else if (!(rawCard.personality!.length > 0)) {
// No personality
showError.value = true
errorMessage.value = t('settings.pages.card.creation.errors.personality')
return false
}
else if (!(rawCard.scenario!.length > 0)) {
// No Scenario
showError.value = true
errorMessage.value = t('settings.pages.card.creation.errors.scenario')
return false
}
else if (!(rawCard.systemPrompt!.length > 0)) {
// No sys prompt
showError.value = true
errorMessage.value = t('settings.pages.card.creation.errors.systemprompt')
return false
}
else if (!(rawCard.postHistoryInstructions!.length > 0)) {
// No post history prompt
showError.value = true
errorMessage.value = t('settings.pages.card.creation.errors.posthistoryinstructions')
return false
}
showError.value = false
cardStore.addCard(rawCard)
modelValue.value = false // Close this
return true
}
// Cards data holders :
const card = ref<Card>({
name: t('settings.pages.card.creation.defaults.name'),
nickname: undefined,
version: '1.0',
description: '',
notes: undefined,
personality: t('settings.pages.card.creation.defaults.personality'),
scenario: t('settings.pages.card.creation.defaults.scenario'),
systemPrompt: t('settings.pages.card.creation.defaults.systemprompt'),
postHistoryInstructions: t('settings.pages.card.creation.defaults.posthistoryinstructions'),
greetings: [],
messageExample: [],
})
function makeComputed<T extends keyof Card>(
/*
Function used to generate Computed values, with an optional sanitize function
*/
key: T,
transform?: (input: string) => string,
) {
return computed({
get: () => {
return card.value[key] ?? ''
},
set: (val: string) => { // Set,
const input = val.trim() // We first trim the value
card.value[key] = (input.length > 0
? (transform ? transform(input) : input) // then potentially transform it
: '') as Card[T]// or default to empty string value if nothing was given
},
})
}
const cardName = makeComputed('name', input => kebabcase(input))
const cardNickname = makeComputed('nickname')
const cardDescription = makeComputed('description')
const cardNotes = makeComputed('notes')
const cardPersonality = makeComputed('personality')
const cardScenario = makeComputed('scenario')
const cardGreetings = computed({
get: () => card.value.greetings ?? [],
set: (val: string[]) => {
card.value.greetings = val || []
},
})
const cardVersion = makeComputed('version')
const cardSystemPrompt = makeComputed('systemPrompt')
const cardPostHistoryInstructions = makeComputed('postHistoryInstructions')
</script>
<template>
<DialogRoot :open="modelValue" @update:open="emit('update:modelValue', $event)">
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-100 bg-black/50 backdrop-blur-sm data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn" />
<DialogContent class="fixed left-1/2 top-1/2 z-100 m-0 max-h-[90vh] max-w-6xl w-[92vw] flex flex-col overflow-auto border border-neutral-200 rounded-xl bg-white p-5 shadow-xl 2xl:w-[60vw] lg:w-[80vw] md:w-[85vw] xl:w-[70vw] -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow dark:border-neutral-700 dark:bg-neutral-800 sm:p-6">
<div class="w-full flex flex-col gap-5">
<DialogTitle text-2xl font-normal class="from-primary-500 to-primary-400 bg-gradient-to-r bg-clip-text text-transparent">
{{ t("settings.pages.card.create_card") }}
</DialogTitle>
<!-- Dialog tabs -->
<div class="mt-4">
<div class="border-b border-neutral-200 dark:border-neutral-700">
<div class="flex justify-center -mb-px sm:justify-start space-x-1">
<button
v-for="tab in tabs"
:key="tab.id"
class="px-4 py-2 text-sm font-medium"
:class="[
activeTab === tab.id
? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-500 dark:border-primary-400'
: 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300',
]"
@click="activeTab = tab.id"
>
<div class="flex items-center gap-1">
<div :class="tab.icon" />
{{ tab.label }}
</div>
</button>
</div>
</div>
</div>
<!-- Error div -->
<div v-if="showError" class="w-full rounded-xl bg-red900">
<p class="w-full p-4">
{{ errorMessage }}
</p>
</div>
<!-- Actual content -->
<!-- Identity details -->
<div v-if="activeTab === 'identity'" class="tab-content ml-auto mr-auto w-95%">
<p class="mb-3">
{{ t('settings.pages.card.creation.fields_info.subtitle') }}
</p>
<div class="input-list ml-auto mr-auto w-90% flex flex-row flex-wrap justify-center gap-8">
<FieldInput v-model="cardName" :label="t('settings.pages.card.creation.name')" :description="t('settings.pages.card.creation.fields_info.name')" :required="true" />
<FieldInput v-model="cardNickname" :label="t('settings.pages.card.creation.nickname')" :description="t('settings.pages.card.creation.fields_info.nickname')" />
<FieldInput v-model="cardDescription" :label="t('settings.pages.card.creation.description')" :single-line="false" :required="true" :description="t('settings.pages.card.creation.fields_info.description')" />
<FieldInput v-model="cardNotes" :label="t('settings.pages.card.creator_notes')" :single-line="false" :description="t('settings.pages.card.creation.fields_info.notes')" />
</div>
</div>
<!-- Behavior -->
<div v-else-if="activeTab === 'behavior'" class="tab-content ml-auto mr-auto w-95%">
<div class="input-list ml-auto mr-auto w-90% flex flex-row flex-wrap justify-center gap-8">
<FieldInput v-model="cardPersonality" :label="t('settings.pages.card.personality')" :single-line="false" :required="true" :description="t('settings.pages.card.creation.fields_info.personality')" />
<FieldInput v-model="cardScenario" :label="t('settings.pages.card.scenario')" :single-line="false" :required="true" :description="t('settings.pages.card.creation.fields_info.scenario')" />
<FieldValues v-model="cardGreetings" :label="t('settings.pages.card.creation.greetings')" :description="t('settings.pages.card.creation.fields_info.greetings')" />
</div>
</div>
<!-- Settings -->
<div v-else-if="activeTab === 'settings'" class="tab-content ml-auto mr-auto w-95%">
<div class="input-list ml-auto mr-auto w-90% flex flex-row flex-wrap justify-center gap-8">
<FieldInput v-model="cardSystemPrompt" :label="t('settings.pages.card.systemprompt')" :single-line="false" :required="true" :description="t('settings.pages.card.creation.fields_info.systemprompt')" />
<FieldInput v-model="cardPostHistoryInstructions" :label="t('settings.pages.card.posthistoryinstructions')" :single-line="false" :required="true" :description="t('settings.pages.card.creation.fields_info.posthistoryinstructions')" />
<FieldInput v-model="cardVersion" :label="t('settings.pages.card.creation.version')" :required="true" :description="t('settings.pages.card.creation.fields_info.version')" />
</div>
</div>
<div class="ml-auto mr-1 flex flex-row gap-2">
<Button
variant="secondary"
icon="i-solar:undo-left-bold-duotone"
:label="t('settings.pages.card.cancel')"
:disabled="false"
@click="modelValue = false"
/>
<Button
variant="primary"
icon="i-solar:check-circle-bold-duotone"
:label="t('settings.pages.card.creation.create')"
:disabled="false"
@click="saveCard(card)"
/>
</div>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
<style scoped>
.input-list > * {
min-width: 45%;
}
@media (max-width: 641px) {
.input-list * {
min-width: unset;
width: 100%;
}
}
</style>
@@ -1,354 +0,0 @@
<script setup lang="ts">
import type { AiriCard } from '@proj-airi/stage-ui/stores/modules/airi-card'
import DOMPurify from 'dompurify'
import { Button } from '@proj-airi/stage-ui/components'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
import { storeToRefs } from 'pinia'
import {
DialogContent,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
} from 'reka-ui'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import DeleteCardDialog from './DeleteCardDialog.vue'
interface Props {
modelValue: boolean
cardId: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const { t } = useI18n()
const cardStore = useAiriCardStore()
const { removeCard } = cardStore
const { activeCardId } = storeToRefs(cardStore)
// Get selected card data
const selectedCard = computed<AiriCard | undefined>(() => {
if (!props.cardId)
return undefined
return cardStore.getCard(props.cardId)
})
// Get module settings
const moduleSettings = computed(() => {
if (!selectedCard.value || !selectedCard.value.extensions?.airi?.modules) {
return {
consciousness: '',
speech: '',
voice: '',
}
}
const airiExt = selectedCard.value.extensions.airi.modules
return {
consciousness: airiExt.consciousness?.model || '',
speech: airiExt.speech?.model || '',
voice: airiExt.speech?.voice_id || '',
}
})
// Get character settings
const characterSettings = computed(() => {
if (!selectedCard.value)
return {}
return {
personality: selectedCard.value.personality,
scenario: selectedCard.value.scenario,
systemPrompt: selectedCard.value.systemPrompt,
postHistoryInstructions: selectedCard.value.postHistoryInstructions,
}
})
// Check if card is active
const isActive = computed(() => props.cardId === activeCardId.value)
// Animation control for card activation
const isActivating = ref(false)
function handleActivate() {
isActivating.value = true
setTimeout(() => {
activeCardId.value = props.cardId
isActivating.value = false
}, 300)
}
function highlightTagToHtml(text: string) {
return DOMPurify.sanitize(text?.replace(/\{\{(.*?)\}\}/g, '<span class="bg-primary-500/20 inline-block">{{ $1 }}</span>').trim())
}
// Delete confirmation
const showDeleteConfirm = ref(false)
function handleDeleteConfirm() {
if (selectedCard.value) {
removeCard(props.cardId)
emit('update:modelValue', false)
}
showDeleteConfirm.value = false
}
// Tab type definition
interface Tab {
id: string
label: string
icon: string
}
// Active tab ID state
const activeTabId = ref('')
// Tabs for card details
const tabs = computed<Tab[]>(() => {
const availableTabs: Tab[] = []
// Description tab - always show if there's description
if (selectedCard.value?.description) {
availableTabs.push({
id: 'description',
label: t('settings.pages.card.description_label'),
icon: 'i-solar:document-text-linear',
})
}
// Notes tab - only show if there are creator notes
if (selectedCard.value?.notes) {
availableTabs.push({
id: 'notes',
label: t('settings.pages.card.creator_notes'),
icon: 'i-solar:notes-linear',
})
}
// Character tab - only show if there are character settings
if (Object.values(characterSettings.value).some(value => !!value)) {
availableTabs.push({
id: 'character',
label: t('settings.pages.card.character'),
icon: 'i-solar:user-rounded-linear',
})
}
// Modules tab - always show
availableTabs.push({
id: 'modules',
label: t('settings.pages.card.modules'),
icon: 'i-solar:tuning-square-linear',
})
return availableTabs
})
// Active tab state - set to first available tab by default
const activeTab = computed({
get: () => {
// If current active tab is not in available tabs, reset to first tab
if (!tabs.value.find(tab => tab.id === activeTabId.value))
return tabs.value[0]?.id || ''
return activeTabId.value
},
set: (value: string) => {
activeTabId.value = value
},
})
</script>
<template>
<DialogRoot :open="modelValue" @update:open="emit('update:modelValue', $event)">
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-100 bg-black/50 backdrop-blur-sm data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn" />
<DialogContent class="fixed left-1/2 top-1/2 z-100 m-0 max-h-[90vh] max-w-6xl w-[92vw] flex flex-col overflow-auto border border-neutral-200 rounded-xl bg-white p-5 shadow-xl 2xl:w-[60vw] lg:w-[80vw] md:w-[85vw] xl:w-[70vw] -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow dark:border-neutral-700 dark:bg-neutral-800 sm:p-6">
<div v-if="selectedCard" class="w-full flex flex-col gap-5">
<!-- Header with status indicator -->
<div flex="~ col" gap-3>
<div flex="~ row" items-center justify-between>
<div>
<div flex="~ row" items-center gap-2>
<DialogTitle text-2xl font-normal class="from-primary-500 to-primary-400 bg-gradient-to-r bg-clip-text text-transparent">
{{ selectedCard.name }}
</DialogTitle>
<div v-if="isActive" class="flex items-center gap-1 rounded-full bg-primary-100 px-2 py-0.5 text-xs text-primary-600 font-medium dark:bg-primary-900/40 dark:text-primary-400">
<div i-solar:check-circle-bold-duotone text-xs />
{{ t('settings.pages.card.active_badge') }}
</div>
</div>
<div mt-1 text-sm text-neutral-500 dark:text-neutral-400>
v{{ selectedCard.version }}
<template v-if="selectedCard.creator">
· {{ t('settings.pages.card.created_by') }} <span font-medium>{{ selectedCard.creator }}</span>
</template>
</div>
</div>
<!-- Action buttons -->
<div flex="~ row" gap-2>
<!-- Activation button -->
<Button
variant="primary"
:icon="isActive ? 'i-solar:check-circle-bold-duotone' : 'i-solar:play-circle-broken'"
:label="isActive ? t('settings.pages.card.active') : t('settings.pages.card.activate')"
:disabled="isActive"
:class="{ 'animate-pulse': isActivating }"
@click="handleActivate"
/>
</div>
</div>
<!-- Card content tabs -->
<div class="mt-4">
<div class="border-b border-neutral-200 dark:border-neutral-700">
<div class="flex justify-center -mb-px sm:justify-start space-x-1">
<button
v-for="tab in tabs"
:key="tab.id"
class="px-4 py-2 text-sm font-medium"
:class="[
activeTab === tab.id
? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-500 dark:border-primary-400'
: 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300',
]"
@click="activeTab = tab.id"
>
<div class="flex items-center gap-1">
<div :class="tab.icon" />
{{ tab.label }}
</div>
</button>
</div>
</div>
</div>
<!-- Creator notes -->
<div v-if="activeTab === 'notes' && selectedCard.notes">
<div
bg="white/60 dark:black/30"
border="~ neutral-200/50 dark:neutral-700/30"
max-h-60 overflow-auto whitespace-pre-line rounded-lg p-4 text-neutral-700 sm:max-h-80 dark:text-neutral-300 transition="all duration-200"
hover="bg-white/80 dark:bg-black/40"
v-html="highlightTagToHtml(selectedCard.notes)"
/>
</div>
<!-- Description section -->
<div v-if="activeTab === 'description' && selectedCard.description">
<div
bg="white/60 dark:black/30"
max-h-60 overflow-auto whitespace-pre-line rounded-lg p-4 sm:max-h-80
text="neutral-600 dark:neutral-300"
border="~ neutral-200/50 dark:neutral-700/30"
v-html="highlightTagToHtml(selectedCard.description)"
/>
</div>
<!-- Character -->
<div v-if="activeTab === 'character' && Object.values(characterSettings).some(value => !!value)">
<div flex="~ col" max-h-60 gap-4 overflow-auto pr-1 sm:max-h-80>
<template v-for="(value, key) in characterSettings" :key="key">
<div v-if="value" flex="~ col" gap-2>
<h2 text-lg text-neutral-500 font-medium dark:text-neutral-400>
{{ t(`settings.pages.card.${key.toLowerCase()}`) }}
</h2>
<div
bg="white/60 dark:black/30"
border="~ neutral-200/50 dark:neutral-700/30"
transition="all duration-200"
hover="bg-white/80 dark:bg-black/40"
max-h-none overflow-auto whitespace-pre-line rounded-lg p-3 text-neutral-700 dark:text-neutral-300
v-html="highlightTagToHtml(value)"
/>
</div>
</template>
</div>
</div>
<!-- Modules -->
<div v-if="activeTab === 'modules'">
<div grid="~ cols-1 sm:cols-3" gap-4>
<div
flex="~ col"
bg="white/60 dark:black/30"
gap-1 rounded-lg p-3
border="~ neutral-200/50 dark:neutral-700/30"
transition="all duration-200"
hover="bg-white/80 dark:bg-black/40"
>
<span flex="~ row" items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400>
<div i-lucide:ghost />
{{ t('settings.pages.card.consciousness.model') }}
</span>
<div truncate font-medium>
{{ moduleSettings.consciousness ?? 'default' }}
</div>
</div>
<div
flex="~ col"
bg="white/60 dark:black/30"
gap-2 rounded-lg p-3
border="~ neutral-200/50 dark:neutral-700/30"
transition="all duration-200"
hover="bg-white/80 dark:bg-black/40"
>
<span flex="~ row" items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400>
<div i-lucide:mic />
{{ t('settings.pages.card.speech.model') }}
</span>
<div truncate font-medium>
{{ moduleSettings.speech ?? 'default' }}
</div>
</div>
<div
flex="~ col"
bg="white/60 dark:black/30"
gap-2 rounded-lg p-3
border="~ neutral-200/50 dark:neutral-700/30"
transition="all duration-200"
hover="bg-white/80 dark:bg-black/40"
>
<span flex="~ row" items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400>
<div i-lucide:music />
{{ t('settings.pages.card.speech.voice') }}
</span>
<div truncate font-medium>
{{ moduleSettings.voice ?? 'default' }}
</div>
</div>
</div>
</div>
</div>
</div>
<div
v-else
bg="neutral-50/50 dark:neutral-900/50"
rounded-xl p-8 text-center
border="~ neutral-200/50 dark:neutral-700/30"
shadow="sm"
>
<div i-solar:card-search-broken mx-auto mb-3 text-6xl text-neutral-400 />
{{ t('settings.pages.card.card_not_found') }}
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
<!-- Delete confirmation dialog -->
<DeleteCardDialog
v-model="showDeleteConfirm"
:card-name="selectedCard?.name"
@confirm="handleDeleteConfirm"
@cancel="showDeleteConfirm = false"
/>
</template>
@@ -1,101 +0,0 @@
<script setup lang="ts">
import { CursorFloating } from '@proj-airi/stage-ui/components'
interface Props {
id: string
name: string
description?: string
isActive: boolean
isSelected: boolean
version: string
consciousnessModel: string
voiceModel: string
}
defineProps<Props>()
const emit = defineEmits<{
(e: 'select'): void
(e: 'activate'): void
(e: 'delete'): void
}>()
</script>
<template>
<CursorFloating
relative min-h-120px flex="~ col" cursor-pointer overflow-hidden rounded-xl
:class="[
isSelected
? 'border-2 border-primary-400 dark:border-primary-600'
: 'border-2 border-neutral-100 dark:border-neutral-800/25',
]"
bg="neutral-200/50 dark:neutral-800/50"
drop-shadow="none hover:[0px_4px_4px_rgba(220,220,220,0.4)] active:[0px_0px_0px_rgba(220,220,220,0.25)] dark:hover:none"
transition="all ease-in-out duration-400"
before="content-empty absolute inset-0 z-0 w-25% h-full transition-all duration-400 ease-in-out bg-gradient-to-r from-primary-500/0 to-primary-500/0 dark:from-primary-400/0 dark:to-primary-400/0 mask-image-[linear-gradient(120deg,white_100%)] opacity-0"
hover="before:(opacity-100 bg-gradient-to-r from-primary-500/20 via-primary-500/10 to-transparent dark:from-primary-400/20 dark:via-primary-400/10 dark:to-transparent)"
@click="emit('select')"
>
<!-- Card content -->
<div
relative flex="~ col 1" justify-between gap-3 overflow-hidden rounded-lg bg="white dark:neutral-900" p-5
transition="all ease-in-out duration-400"
after="content-empty absolute inset-0 z--2 w-full h-full bg-dotted-[neutral-200/80] bg-size-10px mask-image-[linear-gradient(165deg,white_30%,transparent_50%)] transition-all duration-400 ease-in-out"
hover="after:bg-dotted-[primary-300/50] dark:after:bg-dotted-[primary-200/20] text-primary-600/80 dark:text-primary-300/80"
>
<!-- Card header (name and badge) -->
<div z-1 flex items-start justify-between gap-2>
<h3 flex-1 truncate text-lg font-normal>
{{ name }}
</h3>
<div v-if="isActive" shrink-0 rounded-md p-1 bg="primary-100 dark:primary-900/40" text="primary-600 dark:primary-400">
<div i-solar:check-circle-bold-duotone text-sm />
</div>
</div>
<!-- Card description -->
<p v-if="description" line-clamp-3 min-h-40px flex-1 text-sm text="neutral-500 dark:neutral-400">
{{ description }}
</p>
<!-- Card stats -->
<div z-1 flex items-center justify-between text-xs text="neutral-500 dark:neutral-400">
<div>v{{ version }}</div>
<div flex items-center gap-1.5>
<div flex items-center gap-0.5>
<div i-lucide:ghost text-xs />
<span>{{ consciousnessModel }}</span>
</div>
<div flex items-center gap-0.5>
<div i-lucide:mic text-xs />
<span>{{ voiceModel }}</span>
</div>
</div>
</div>
</div>
<!-- Card actions -->
<div flex items-center justify-end px-2 py-1.5>
<button
rounded-lg p-1.5 transition-colors hover="bg-neutral-200 dark:bg-neutral-700/50"
:disabled="isActive"
@click.stop="emit('activate')"
>
<div
:class="[
isActive
? 'i-solar:check-circle-bold-duotone text-primary-500 dark:text-primary-400'
: 'i-solar:play-circle-broken text-neutral-500 dark:text-neutral-400',
]"
/>
</button>
<button
v-if="id !== 'default'"
rounded-lg p-1.5 transition-colors hover="bg-neutral-200 dark:bg-neutral-700/50"
@click.stop="emit('delete')"
>
<div i-solar:trash-bin-trash-linear text="neutral-500 dark:neutral-400" />
</button>
</div>
</CursorFloating>
</template>
@@ -1,73 +0,0 @@
<script setup lang="ts">
import { Button } from '@proj-airi/stage-ui/components'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
interface Props {
modelValue: boolean
cardName?: string
}
defineProps<Props>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
(e: 'cancel'): void
}>()
const { t } = useI18n()
function handleCancel() {
emit('update:modelValue', false)
emit('cancel')
}
function handleConfirm() {
emit('update:modelValue', false)
emit('confirm')
}
</script>
<template>
<AlertDialogRoot :open="modelValue" @update:open="emit('update:modelValue', $event)">
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-100 bg-black/50 data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn" />
<AlertDialogContent
class="fixed left-1/2 top-1/2 z-100 max-w-md w-full border border-neutral-200 rounded-xl bg-white p-6 shadow-xl -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow dark:border-neutral-700 dark:bg-neutral-800"
>
<AlertDialogTitle class="mb-4 text-xl font-normal">
{{ t('settings.pages.card.delete_card') }}
</AlertDialogTitle>
<AlertDialogDescription class="mb-6">
{{ t('settings.pages.card.delete_confirmation') }} <b>"{{ cardName || '' }}"</b>
</AlertDialogDescription>
<div class="flex flex-row justify-end gap-3">
<AlertDialogCancel as-child>
<Button
variant="secondary"
:label="t('settings.pages.card.cancel')"
@click="handleCancel"
/>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button
variant="danger"
:label="t('settings.pages.card.delete')"
@click="handleConfirm"
/>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</template>
@@ -1,15 +0,0 @@
<script setup lang="ts">
import { GamingFactorio } from '@proj-airi/stage-ui/components'
</script>
<template>
<GamingFactorio />
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
pageSpecificAvailable: true
</route>
@@ -1,15 +0,0 @@
<script setup lang="ts">
import { GamingMinecraft } from '@proj-airi/stage-ui/components'
</script>
<template>
<GamingMinecraft />
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
pageSpecificAvailable: true
</route>
@@ -1,16 +0,0 @@
<script setup lang="ts">
// import { useServerStore } from '@proj-airi/stage-ui/stores/server'
// const serverStore = useServerStore()
</script>
<template>
<div />
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,15 +0,0 @@
<script setup lang="ts">
import { MessagingDiscord } from '@proj-airi/stage-ui/components'
</script>
<template>
<MessagingDiscord />
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
pageSpecificAvailable: true
</route>
@@ -1,15 +0,0 @@
<script setup lang="ts">
import { X } from '@proj-airi/stage-ui/components'
</script>
<template>
<X />
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
pageSpecificAvailable: true
</route>
@@ -1,104 +0,0 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = '302-ai'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.302.ai/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,162 +0,0 @@
<script setup lang="ts">
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
import type { UnElevenLabsOptions } from 'unspeech'
import {
SpeechPlayground,
SpeechProviderSettings,
} from '@proj-airi/stage-ui/components'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldRange } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const providerId = 'alibaba-cloud-model-studio'
const defaultModel = 'cosyvoice-v1'
// Default voice settings specific to ElevenLabs
const defaultVoiceSettings = {
speed: 1.0,
}
const pitch = ref<number>(0)
const speed = ref<number>(1.0)
const volume = ref<number>(0)
const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore)
const { t } = useI18n()
// Check if API key is configured
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
// Get available voices for ElevenLabs
const availableVoices = computed(() => {
return speechStore.availableVoices[providerId] || []
})
// Generate speech with ElevenLabs-specific parameters
async function handleGenerateSpeech(input: string, voiceId: string, _useSSML: boolean) {
const provider = await providersStore.getProviderInstance(providerId) as SpeechProviderWithExtraOptions<string, UnElevenLabsOptions>
if (!provider) {
throw new Error('Failed to initialize speech provider')
}
// Get provider configuration
const providerConfig = providersStore.getProviderConfig(providerId)
// Get model from configuration or use default
const model = providerConfig.model as string | undefined || defaultModel
// ElevenLabs doesn't need SSML conversion, but if SSML is provided, use it directly
return await speechStore.speech(
provider,
model,
input,
voiceId,
{
...providerConfig,
...defaultVoiceSettings,
},
)
}
onMounted(async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
const providerMetadata = providersStore.getProviderMetadata(providerId)
if (await providerMetadata.validators.validateProviderConfig(providerConfig)) {
await speechStore.loadVoicesForProvider(providerId)
}
else {
console.error('Failed to validate provider config', providerConfig)
}
})
watch(pitch, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
providerConfig.pitch = pitch.value
})
watch(speed, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
providerConfig.speed = speed.value
})
watch(volume, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
providerConfig.volume = volume.value
})
watch(providers, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
const providerMetadata = providersStore.getProviderMetadata(providerId)
if (await providerMetadata.validators.validateProviderConfig(providerConfig)) {
await speechStore.loadVoicesForProvider(providerId)
}
else {
console.error('Failed to validate provider config', providerConfig)
}
}, {
immediate: true,
})
</script>
<template>
<SpeechProviderSettings
:provider-id="providerId"
:default-model="defaultModel"
:additional-settings="defaultVoiceSettings"
>
<!-- Voice settings specific to ElevenLabs -->
<template #voice-settings>
<div flex="~ col gap-4">
<!-- Pitch control - common to most providers -->
<FieldRange
v-model="pitch"
:label="t('settings.pages.providers.provider.common.fields.field.pitch.label')"
:description="t('settings.pages.providers.provider.common.fields.field.pitch.description')"
:min="-100"
:max="100" :step="1" :format-value="value => `${value}%`"
/>
<!-- Speed control - common to most providers -->
<FieldRange
v-model="speed"
:label="t('settings.pages.providers.provider.common.fields.field.speed.label')"
:description="t('settings.pages.providers.provider.common.fields.field.speed.description')"
:min="0.5"
:max="2.0" :step="0.01"
/>
<!-- Volume control - available in some providers -->
<FieldRange
v-model="volume"
:label="t('settings.pages.providers.provider.common.fields.field.volume.label')"
:description="t('settings.pages.providers.provider.common.fields.field.volume.description')"
:min="-100"
:max="100" :step="1" :format-value="value => `${value}%`"
/>
</div>
</template>
<!-- Replace the default playground with our standalone component -->
<template #playground>
<SpeechPlayground
:available-voices="availableVoices"
:generate-speech="handleGenerateSpeech"
:api-key-configured="apiKeyConfigured"
default-text="Hello! This is a test of the ElevenLabs voice synthesis."
/>
</template>
</SpeechProviderSettings>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,104 +0,0 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'anthropic'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-ant-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.anthropic.com/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,139 +0,0 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAccountIdInput,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'azure-ai-foundry'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const resourceName = computed({
get: () => providers.value[providerId]?.resourceName || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].resourceName = value
},
})
const apiVersion = computed({
get: () => providers.value[providerId]?.apiVersion || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiVersion = value
},
})
const modelId = computed({
get: () => providers.value[providerId]?.modelId || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].modelId = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="..."
required
/>
<ProviderAccountIdInput
v-model="resourceName"
label="Resouce name"
placeholder="..."
description="Prefix used in https://<prefix>.services.ai.azure.com"
required
/>
<ProviderAccountIdInput
v-model="modelId"
label="Model id"
placeholder="..."
description="Model ID on Azure AI Foundry"
required
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderAccountIdInput
v-model="apiVersion"
label="API version"
placeholder="e.g. 2025-04-01-preview"
description="API version for snapshot of the models"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,103 +0,0 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAccountIdInput,
ProviderApiKeyInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'cloudflare-workers-ai'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const accountId = computed({
get: () => providers.value[providerId]?.accountId || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].accountId = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
:placeholder="t('settings.pages.providers.provider.cloudflare-workers-ai.fields.field.api-key.placeholder')"
/>
<ProviderAccountIdInput
v-model="accountId"
:label="t('settings.pages.providers.provider.cloudflare-workers-ai.fields.field.account-id.label')"
:description="t('settings.pages.providers.provider.cloudflare-workers-ai.fields.field.account-id.description')"
:placeholder="t('settings.pages.providers.provider.cloudflare-workers-ai.fields.field.account-id.placeholder')"
/>
</ProviderBasicSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,104 +0,0 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'deepseek'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="ds-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.deepseek.com/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,223 +0,0 @@
<script setup lang="ts">
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
import type { UnElevenLabsOptions } from 'unspeech'
import {
SpeechPlayground,
SpeechProviderSettings,
} from '@proj-airi/stage-ui/components'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldCheckbox, FieldRange } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const providerId = 'elevenlabs'
const defaultModel = 'eleven_multilingual_v2'
// Default voice settings specific to ElevenLabs
const defaultVoiceSettings = {
similarityBoost: 0.75,
stability: 0.5,
speed: 1.0,
style: 0,
useSpeakerBoost: true,
}
const pitch = ref<number>(0)
const speed = ref<number>(1.0)
const volume = ref<number>(0)
const style = ref<number>(0)
const stability = ref<number>(0.5)
const similarityBoost = ref<number>(0.75)
const useSpeakerBoost = ref<boolean>(false)
const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore)
const { t } = useI18n()
// Check if API key is configured
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
// Get available voices for ElevenLabs
const availableVoices = computed(() => {
return speechStore.availableVoices[providerId] || []
})
// Generate speech with ElevenLabs-specific parameters
async function handleGenerateSpeech(input: string, voiceId: string, _useSSML: boolean) {
const provider = await providersStore.getProviderInstance(providerId) as SpeechProviderWithExtraOptions<string, UnElevenLabsOptions>
if (!provider) {
throw new Error('Failed to initialize speech provider')
}
// Get provider configuration
const providerConfig = providersStore.getProviderConfig(providerId)
// Get model from configuration or use default
const model = providerConfig.model as string | undefined || defaultModel
// ElevenLabs doesn't need SSML conversion, but if SSML is provided, use it directly
return await speechStore.speech(
provider,
model,
input,
voiceId,
{
...providerConfig,
...defaultVoiceSettings,
},
)
}
onMounted(async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
const providerMetadata = providersStore.getProviderMetadata(providerId)
if (await providerMetadata.validators.validateProviderConfig(providerConfig)) {
await speechStore.loadVoicesForProvider(providerId)
}
else {
console.error('Failed to validate provider config', providerConfig)
}
})
watch(pitch, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
providerConfig.pitch = pitch.value
})
watch(speed, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
providerConfig.speed = speed.value
})
watch(volume, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
providerConfig.volume = volume.value
})
watch(style, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
providerConfig.style = style.value
})
watch(stability, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
providerConfig.stability = stability.value
})
watch(similarityBoost, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
providerConfig.similarityBoost = similarityBoost.value
})
watch(useSpeakerBoost, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
providerConfig.useSpeakerBoost = useSpeakerBoost.value
})
watch(providers, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
const providerMetadata = providersStore.getProviderMetadata(providerId)
if (await providerMetadata.validators.validateProviderConfig(providerConfig)) {
await speechStore.loadVoicesForProvider(providerId)
}
else {
console.error('Failed to validate provider config', providerConfig)
}
}, {
immediate: true,
})
</script>
<template>
<SpeechProviderSettings
:provider-id="providerId"
:default-model="defaultModel"
:additional-settings="defaultVoiceSettings"
>
<!-- Voice settings specific to ElevenLabs -->
<template #voice-settings>
<div flex="~ col gap-4">
<!-- Pitch control - common to most providers -->
<FieldRange
v-model="pitch"
:label="t('settings.pages.providers.provider.common.fields.field.pitch.label')"
:description="t('settings.pages.providers.provider.common.fields.field.pitch.description')"
:min="-100"
:max="100" :step="1" :format-value="value => `${value}%`"
/>
<!-- Speed control - common to most providers -->
<FieldRange
v-model="speed"
:label="t('settings.pages.providers.provider.common.fields.field.speed.label')"
:description="t('settings.pages.providers.provider.common.fields.field.speed.description')"
:min="0.5"
:max="2.0" :step="0.01"
/>
<!-- Volume control - available in some providers -->
<FieldRange
v-model="volume"
:label="t('settings.pages.providers.provider.common.fields.field.volume.label')"
:description="t('settings.pages.providers.provider.common.fields.field.volume.description')"
:min="-100"
:max="100" :step="1" :format-value="value => `${value}%`"
/>
<!-- Style control - specific to ElevenLabs -->
<FieldRange
v-model="style"
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.style.label')"
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.style.description')"
:min="0"
:max="1" :step="0.01"
/>
<!-- Stability control - specific to ElevenLabs -->
<FieldRange
v-model="stability"
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.stability.label')"
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.stability.description')"
:min="0"
:max="1" :step="0.01"
/>
<!-- Similarity Boost control - specific to ElevenLabs -->
<FieldRange
v-model="similarityBoost"
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.simularity-boost.label')"
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.simularity-boost.description')"
:min="0"
:max="1" :step="0.01"
/>
<!-- Speaker Boost checkbox - specific to ElevenLabs -->
<FieldCheckbox
v-model="useSpeakerBoost"
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.speaker-boost.label')"
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.speaker-boost.description')"
/>
</div>
</template>
<template #playground>
<SpeechPlayground
:available-voices="availableVoices"
:generate-speech="handleGenerateSpeech"
:api-key-configured="apiKeyConfigured"
default-text="Hello! This is a test of the ElevenLabs voice synthesis."
/>
</template>
</SpeechProviderSettings>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,104 +0,0 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'featherless-ai'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.featherless.ai/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,104 +0,0 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'fireworks-ai'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="fw-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.fireworks.ai/inference/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,104 +0,0 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'google-generative-ai'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || 'https://generativelanguage.googleapis.com/v1beta/openai/',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="AIza..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://generativelanguage.googleapis.com/v1beta/openai/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,83 +0,0 @@
<script setup lang="ts">
import type { SpeechProvider } from '@xsai-ext/shared-providers'
import {
SpeechPlayground,
SpeechProviderSettings,
} from '@proj-airi/stage-ui/components'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { computed, onMounted, watch } from 'vue'
// import { useI18n } from 'vue-i18n'
// const { t } = useI18n()
const providerId = 'index-tts-vllm'
const defaultModel = 'IndexTTS-1.5'
const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
// const { providers } = storeToRefs(providersStore)
// Check if API key is configured
// const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
const apiKeyConfigured = true // Assuming API key is always configured as its not required
// Get available voices for Index TTS provider
const availableVoices = computed(() => {
return speechStore.availableVoices[providerId] || []
})
onMounted(async () => {
await speechStore.loadVoicesForProvider(providerId)
})
watch([apiKeyConfigured], async () => {
await speechStore.loadVoicesForProvider(providerId)
})
async function handleGenerateSpeech(input: string, voiceId: string) {
const provider = await providersStore.getProviderInstance(providerId) as SpeechProvider
if (!provider) {
throw new Error('Failed to initialize speech provider')
}
// Get provider configuration
const providerConfig = providersStore.getProviderConfig(providerId)
// Get model from configuration or use default
const model = providerConfig.model as string | undefined || defaultModel
const options = {
...providerConfig,
}
return await speechStore.speech(
provider,
model,
input,
voiceId,
options,
)
}
</script>
<template>
<SpeechProviderSettings :provider-id="providerId" :default-model="defaultModel">
<!-- Replace the default playground with our standalone component -->
<template #playground>
<SpeechPlayground
:available-voices="availableVoices" :generate-speech="handleGenerateSpeech"
:api-key-configured="apiKeyConfigured" :use-ssml="false"
default-text="Hello! This is a test of the Index TTS Speech synthesis?."
/>
</template>
</SpeechProviderSettings>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,105 +0,0 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'lm-studio'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="lm-studio"
:is-required="false"
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="http://localhost:1234/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,190 +0,0 @@
<script setup lang="ts">
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
import type { UnMicrosoftOptions } from 'unspeech'
import {
SpeechPlayground,
SpeechProviderSettings,
} from '@proj-airi/stage-ui/components'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldInput, FieldRange } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const providerId = 'microsoft-speech'
const defaultModel = 'v1'
// Default voice settings specific to Microsoft Speech
const defaultVoiceSettings = {
pitch: 0,
speed: 1.0,
volume: 0,
}
const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore)
const pitch = ref(0)
const speed = ref(1.0)
const volume = ref(0)
// Additional settings specific to Microsoft Speech (region)
const region = computed({
get: () => providers.value[providerId]?.region as string | undefined || 'eastasia',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = { region: 'eastasia' }
providers.value[providerId].region = value
},
})
// Check if API key is configured
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
// Get available voices for Microsoft Speech
const availableVoices = computed(() => {
return speechStore.availableVoices[providerId] || []
})
onMounted(async () => {
if (!region.value) {
region.value = 'eastasia' // Default region
}
if (!providers.value[providerId]?.region) {
if (!providers.value[providerId])
providers.value[providerId] = { region: region.value }
else
providers.value[providerId].region = region.value
}
await speechStore.loadVoicesForProvider(providerId)
})
watch([apiKeyConfigured, region], async () => {
await speechStore.loadVoicesForProvider(providerId)
})
// Generate speech with Microsoft-specific parameters
async function handleGenerateSpeech(input: string, voiceId: string, useSSML: boolean) {
const provider = await providersStore.getProviderInstance(providerId) as SpeechProviderWithExtraOptions<string, UnMicrosoftOptions>
if (!provider) {
throw new Error('Failed to initialize speech provider')
}
// Get provider configuration
const providerConfig = providersStore.getProviderConfig(providerId)
// Get model from configuration or use default
const model = providerConfig.model as string | undefined || defaultModel
// For Microsoft Speech, we need to ensure we're using the right region
const options = {
...providerConfig,
region: region.value,
disableSsml: !useSSML, // If useSSML is true, we don't disable SSML
}
// If not using SSML and we have a voice, generate SSML
if (!useSSML && voiceId) {
const voice = availableVoices.value.find(v => v.id === voiceId)
if (voice) {
const ssml = speechStore.generateSSML(
input,
voice,
{ ...providerConfig, pitch: pitch.value },
)
return await speechStore.speech(
provider,
model,
ssml,
voiceId,
options,
)
}
}
// Either using direct SSML or no voice found
return await speechStore.speech(
provider,
model,
input,
voiceId,
options,
)
}
</script>
<template>
<SpeechProviderSettings
:provider-id="providerId"
:default-model="defaultModel"
:additional-settings="defaultVoiceSettings"
>
<!-- Basic settings specific to Microsoft Speech -->
<template #basic-settings>
<FieldInput
v-model="region"
:label="t('settings.pages.providers.provider.microsoft-speech.fields.field.region.label')"
:description="t('settings.pages.providers.provider.microsoft-speech.fields.field.region.description')"
placeholder="eastasia"
required
type="text"
/>
</template>
<!-- Voice settings specific to ElevenLabs -->
<template #voice-settings>
<div flex="~ col gap-4">
<!-- Pitch control - common to most providers -->
<FieldRange
v-model="pitch"
:label="t('settings.pages.providers.provider.common.fields.field.pitch.label')"
:description="t('settings.pages.providers.provider.common.fields.field.pitch.description')"
:min="-100"
:max="100" :step="1" :format-value="value => `${value}%`"
/>
<!-- Speed control - common to most providers -->
<FieldRange
v-model="speed"
:label="t('settings.pages.providers.provider.common.fields.field.speed.label')"
:description="t('settings.pages.providers.provider.common.fields.field.speed.description')"
:min="0.5"
:max="2.0" :step="0.01"
/>
<!-- Volume control - available in some providers -->
<FieldRange
v-model="volume"
:label="t('settings.pages.providers.provider.common.fields.field.volume.label')"
:description="t('settings.pages.providers.provider.common.fields.field.volume.description')"
:min="-100"
:max="100" :step="1" :format-value="value => `${value}%`"
/>
</div>
</template>
<!-- Replace the default playground with our standalone component -->
<template #playground>
<SpeechPlayground
:available-voices="availableVoices"
:generate-speech="handleGenerateSpeech"
:api-key-configured="apiKeyConfigured"
default-text="Hello! This is a test of the Microsoft Speech synthesis."
/>
</template>
</SpeechProviderSettings>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,104 +0,0 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'mistral-ai'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.mistral.ai/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,125 +0,0 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed, onMounted, watch } from 'vue'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Get provider metadata
const providerId = 'modelscope'
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
// Use computed properties for settings
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
onMounted(() => {
// Initialize provider if it doesn't exist
providersStore.initializeProvider(providerId)
// Initialize refs with current values
apiKey.value = providers.value[providerId]?.apiKey || ''
baseUrl.value = providers.value[providerId]?.baseUrl || ''
})
// Watch settings and update the provider configuration
watch([apiKey, baseUrl], () => {
providers.value[providerId] = {
...providers.value[providerId],
apiKey: apiKey.value,
baseUrl: baseUrl.value || '',
}
})
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon="providerMetadata?.icon"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="ms-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api-inference.modelscope.cn/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,104 +0,0 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'moonshot-ai'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.moonshot.cn/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,104 +0,0 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'novita-ai'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.novita.ai/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,176 +0,0 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldKeyValues } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
const providerId = 'ollama'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || 'http://localhost:11434/v1/',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
const headers = ref<{ key: string, value: string }[]>(Object.entries(providers.value[providerId]?.headers || {}).map(([key, value]) => ({ key, value } as { key: string, value: string })) || [{ key: '', value: '' }])
function addKeyValue(headers: { key: string, value: string }[], key: string, value: string) {
if (!headers)
return
headers.push({ key, value })
}
function removeKeyValue(index: number, headers: { key: string, value: string }[]) {
if (!headers)
return
if (headers.length === 1) {
headers[0].key = ''
headers[0].value = ''
}
else {
headers.splice(index, 1)
}
}
watch(headers, (headers) => {
if (headers.length > 0 && (headers[headers.length - 1].key !== '' || headers[headers.length - 1].value !== '')) {
headers.push({ key: '', value: '' })
}
providers.value[providerId].headers = headers.filter(header => header.key !== '').reduce((acc, header) => {
acc[header.key] = header.value
return acc
}, {} as Record<string, string>)
}, {
deep: true,
immediate: true,
})
async function refetch() {
try {
const validationResult = await providerMetadata.value.validators.validateProviderConfig({
baseUrl: baseUrl.value,
headers: headers.value.filter(header => header.key !== '').reduce((acc, header) => {
acc[header.key] = header.value
return acc
}, {} as Record<string, string>),
})
if (!validationResult.valid) {
validationMessage.value = t('settings.dialogs.onboarding.validationError', {
error: validationResult.reason,
})
}
}
catch (error) {
validationMessage.value = t('settings.dialogs.onboarding.validationError', {
error: error instanceof Error ? error.message : String(error),
})
}
}
watch([baseUrl, headers], refetch, { immediate: true })
watch(headers, refetch, { deep: true })
onMounted(() => {
providersStore.initializeProvider(providerId)
// Initialize refs with current values
baseUrl.value = providers.value[providerId]?.baseUrl || providerMetadata.value?.defaultOptions?.().baseUrl || ''
// Initialize headers if not already set
if (!providers.value[providerId]?.headers) {
providers.value[providerId].headers = {}
}
if (headers.value.length === 0) {
headers.value = [{ key: '', value: '' }]
}
})
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="http://localhost:11434/v1/"
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<FieldKeyValues
v-model="headers"
:label="t('settings.pages.providers.common.section.advanced.fields.field.headers.label')"
:description="t('settings.pages.providers.common.section.advanced.fields.field.headers.description')"
:key-placeholder="t('settings.pages.providers.common.section.advanced.fields.field.headers.key.placeholder')"
:value-placeholder="t('settings.pages.providers.common.section.advanced.fields.field.headers.value.placeholder')"
@add="(key: string, value: string) => addKeyValue(headers, key, value)"
@remove="(index: number) => removeKeyValue(index, headers)"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,103 +0,0 @@
<script setup lang="ts">
import type { SpeechProvider } from '@xsai-ext/shared-providers'
import {
SpeechPlayground,
SpeechProviderSettings,
} from '@proj-airi/stage-ui/components'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldRange } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore)
const { t } = useI18n()
const defaultVoiceSettings = {
speed: 1.0,
}
// Get provider metadata
const providerId = 'openai-audio-speech'
const defaultModel = 'gpt-4o-mini-tts'
const speed = ref<number>(1.0)
// Check if API key is configured
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
const availableVoices = computed(() => {
return speechStore.availableVoices[providerId] || []
})
// Generate speech with ElevenLabs-specific parameters
async function handleGenerateSpeech(input: string, voiceId: string, _useSSML: boolean) {
const provider = await providersStore.getProviderInstance<SpeechProvider<string>>(providerId)
if (!provider) {
throw new Error('Failed to initialize speech provider')
}
// Get provider configuration
const providerConfig = providersStore.getProviderConfig(providerId)
// Get model from configuration or use default
const model = providerConfig.model as string | undefined || defaultModel
// ElevenLabs doesn't need SSML conversion, but if SSML is provided, use it directly
return await speechStore.speech(
provider,
model,
input,
voiceId,
{
...providerConfig,
...defaultVoiceSettings,
},
)
}
watch(speed, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
providerConfig.speed = speed.value
})
</script>
<template>
<SpeechProviderSettings
:provider-id="providerId"
:default-model="defaultModel"
:additional-settings="defaultVoiceSettings"
>
<!-- Voice settings specific to ElevenLabs -->
<template #voice-settings>
<!-- Speed control - common to most providers -->
<FieldRange
v-model="speed"
:label="t('settings.pages.providers.provider.common.fields.field.speed.label')"
:description="t('settings.pages.providers.provider.common.fields.field.speed.description')"
:min="0.5"
:max="2.0" :step="0.01"
/>
</template>
<template #playground>
<SpeechPlayground
:available-voices="availableVoices"
:generate-speech="handleGenerateSpeech"
:api-key-configured="apiKeyConfigured"
default-text="Hello! This is a test of the OpenAI Speech."
/>
</template>
</SpeechProviderSettings>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,66 +0,0 @@
<script setup lang="ts">
import type { TranscriptionProvider } from '@xsai-ext/shared-providers'
import {
TranscriptionPlayground,
TranscriptionProviderSettings,
} from '@proj-airi/stage-ui/components'
import { useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const hearingStore = useHearingStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore)
// Get provider metadata
const providerId = 'openai-audio-transcription'
const defaultModel = 'whisper-1'
// Check if API key is configured
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
// Generate speech with ElevenLabs-specific parameters
async function handleGenerateTranscription(file: File) {
const provider = await providersStore.getProviderInstance<TranscriptionProvider<string>>(providerId)
if (!provider) {
throw new Error('Failed to initialize speech provider')
}
// Get provider configuration
const providerConfig = providersStore.getProviderConfig(providerId)
// Get model from configuration or use default
const model = providerConfig.model as string | undefined || defaultModel
// ElevenLabs doesn't need SSML conversion, but if SSML is provided, use it directly
return await hearingStore.transcription(
provider,
model,
file,
'json',
)
}
</script>
<template>
<TranscriptionProviderSettings
:provider-id="providerId"
:default-model="defaultModel"
>
<template #playground>
<TranscriptionPlayground
:generate-transcription="handleGenerateTranscription"
:api-key-configured="apiKeyConfigured"
/>
</template>
</TranscriptionProviderSettings>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,171 +0,0 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import type { SpeechProvider } from '@xsai-ext/shared-providers'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
SpeechPlaygroundOpenAICompatible,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldRange } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
const defaultVoiceSettings = {
speed: 1.0,
}
// Get provider metadata
const providerId = 'openai-compatible-audio-speech'
// Settings refs
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (providers.value[providerId])
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (providers.value[providerId])
providers.value[providerId].baseUrl = value
},
})
const model = computed({
get: () => providers.value[providerId]?.model || 'tts-1',
set: (value) => {
if (providers.value[providerId])
providers.value[providerId].model = value
},
})
const voice = computed({
get: () => providers.value[providerId]?.voice || 'alloy',
set: (value) => {
if (providers.value[providerId])
providers.value[providerId].voice = value
},
})
const speed = ref<number>(1.0)
// Check if API key is configured
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
// Generate speech with specific parameters
async function handleGenerateSpeech(input: string, voiceId: string, _useSSML: boolean, modelId?: string) {
const provider = await providersStore.getProviderInstance<SpeechProvider<string>>(providerId)
if (!provider)
throw new Error('Failed to initialize speech provider')
const providerConfig = providersStore.getProviderConfig(providerId)
return await speechStore.speech(
provider,
modelId || model.value,
input,
voiceId || voice.value,
{
...providerConfig,
...defaultVoiceSettings,
speed: speed.value,
},
)
}
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:required="false"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.openai.com/v1/"
/>
<FieldRange
v-model="speed"
:label="t('settings.pages.providers.provider.common.fields.field.speed.label')"
:description="t('settings.pages.providers.provider.common.fields.field.speed.description')"
:min="0.5"
:max="2.0" :step="0.01"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
<SpeechPlaygroundOpenAICompatible
v-model:model-value="model"
v-model:voice="voice"
:generate-speech="handleGenerateSpeech"
:api-key-configured="apiKeyConfigured"
default-text="Hello! This is a test of the OpenAI Compatible Speech."
/>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,145 +0,0 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import type { TranscriptionProvider } from '@xsai-ext/shared-providers'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
TranscriptionPlayground,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldInput } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'openai-compatible-audio-transcription'
const hearingStore = useHearingStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
const model = computed({
get: () => providers.value[providerId]?.model || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].model = value
},
})
// Check if API key is configured
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
// Generate transcription
async function handleGenerateTranscription(file: File) {
const provider = await providersStore.getProviderInstance<TranscriptionProvider<string>>(providerId)
if (!provider)
throw new Error('Failed to initialize transcription provider')
return await hearingStore.transcription(
provider,
model.value,
file,
'json',
)
}
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-..."
/>
<FieldInput
v-model="model"
:label="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.manual_model_name')"
:placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.manual_model_placeholder')"
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.openai.com/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
<TranscriptionPlayground
:generate-transcription="handleGenerateTranscription"
:api-key-configured="apiKeyConfigured"
/>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,104 +0,0 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'openai-compatible'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.openai.com/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,104 +0,0 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'openai'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.openai.com/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,104 +0,0 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'openrouter-ai'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-or-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://openrouter.ai/api/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,133 +0,0 @@
<script setup lang="ts">
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
import type { UnElevenLabsOptions } from 'unspeech'
import {
Alert,
SpeechPlayground,
SpeechProviderSettings,
} from '@proj-airi/stage-ui/components'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldRange } from '@proj-airi/ui'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const providerId = 'player2-speech'
const defaultModel = 'v1'
const speedRatio = ref<number>(1.0)
const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
const { t } = useI18n()
// Get available voices for Player2
const availableVoices = computed(() => {
return speechStore.availableVoices[providerId] || []
})
// Generate speech with Player2-specific parameters
async function handleGenerateSpeech(input: string, voiceId: string, _useSSML: boolean) {
const provider = await providersStore.getProviderInstance(providerId) as SpeechProviderWithExtraOptions<string, UnElevenLabsOptions>
if (!provider) {
throw new Error('Failed to initialize speech provider')
}
// Get provider configuration
const providerConfig = providersStore.getProviderConfig(providerId)
// Get model from configuration or use default
const model = providerConfig.model as string | undefined || defaultModel
// Player2 doesn't need SSML conversion, but if SSML is provided, use it directly
return await speechStore.speech(
provider,
model,
input,
voiceId,
{
...providerConfig,
},
)
}
const hasPlayer2 = ref(true)
onMounted(async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
const providerMetadata = providersStore.getProviderMetadata(providerId)
if (await providerMetadata.validators.validateProviderConfig(providerConfig)) {
await speechStore.loadVoicesForProvider(providerId)
}
else {
console.error('Failed to validate provider config', providerConfig)
}
try {
const baseUrl = (providerConfig.baseUrl as string | undefined) ?? ''
const res = await fetch(`${baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl}/health`, {
method: 'GET',
headers: {
'player2-game-key': 'airi',
},
})
hasPlayer2.value = res.status === 200
}
catch (e) {
console.error(e)
hasPlayer2.value = false
}
})
watch(speedRatio, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
providerConfig.speed = speedRatio.value
})
</script>
<template>
<SpeechProviderSettings
:provider-id="providerId"
:default-model="defaultModel"
>
<template #voice-settings>
<!-- Speed control - common to most providers -->
<FieldRange
v-model="speedRatio"
:label="t('settings.pages.providers.provider.common.fields.field.speed.label')"
:description="t('settings.pages.providers.provider.common.fields.field.speed.description')"
:min="0.5"
:max="5.0" :step="0.01"
/>
</template>
<!-- Replace the default playground with our standalone component -->
<template #playground>
<SpeechPlayground
:available-voices="availableVoices"
:generate-speech="handleGenerateSpeech"
:api-key-configured="true"
default-text="Hello! This is a test of the Player 2 voice synthesis."
/>
</template>
</SpeechProviderSettings>
<Alert v-if="!hasPlayer2" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template #content>
<div class="whitespace-pre-wrap break-all">
<div>
Please download and run the Player2 App:
<a href="https://player2.game" target="_blank" rel="noopener noreferrer">
https://player2.game
</a>
<div>
After downloading, if you still are having trouble, please reach out to us on Discord:
<a href="https://player2.game/discord" target="_blank" rel="noopener noreferrer">
https://player2.game/discord
</a>.
</div>
</div>
</div>
</template>
</Alert>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,115 +0,0 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/shared'
import {
Alert,
ProviderBaseUrlInput,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Get provider metadata
const providerId = 'player2'
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
const hasPlayer2 = ref(true)
onMounted(async () => {
providersStore.initializeProvider(providerId)
baseUrl.value = providers.value[providerId]?.baseUrl || ''
try {
const res = await fetch(`${baseUrl.value}health`, {
method: 'GET',
headers: {
'player2-game-key': 'airi',
},
})
hasPlayer2.value = res.status === 200
}
catch (e) {
console.error(e)
hasPlayer2.value = false
}
})
// Watch settings and update the provider configuration
watch([baseUrl], () => {
providers.value[providerId] = {
...providers.value[providerId],
baseUrl: baseUrl.value || '',
}
})
function handleResetSettings() {
providers.value[providerId] = {
...(providerMetadata.value?.defaultOptions as any),
}
}
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon="providerMetadata?.icon"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderBaseUrlInput v-model="baseUrl" placeholder="http://localhost:4315/v1/" />
</ProviderBasicSettings>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
<Alert v-if="!hasPlayer2" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template #content>
<div class="whitespace-pre-wrap break-all">
<div>
Please download and run the Player2 App:
<a href="https://player2.game" target="_blank" rel="noopener noreferrer">
https://player2.game
</a>
<div>
After downloading, if you still are having trouble, please reach out to us on Discord:
<a href="https://player2.game/discord" target="_blank" rel="noopener noreferrer">
https://player2.game/discord
</a>.
</div>
</div>
</div>
</template>
</Alert>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,79 +0,0 @@
<script setup lang="ts">
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
const providerId = 'together-ai'
const {
t,
router,
providerMetadata,
apiKey,
baseUrl,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.together.xyz/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,79 +0,0 @@
<script setup lang="ts">
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
const providerId = 'vllm'
const {
t,
router,
providerMetadata,
apiKey,
baseUrl,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="token-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="http://localhost:8000/v1"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,151 +0,0 @@
<script setup lang="ts">
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
import type { UnElevenLabsOptions } from 'unspeech'
import {
SpeechPlayground,
SpeechProviderSettings,
} from '@proj-airi/stage-ui/components'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldInput, FieldRange } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const providerId = 'volcengine'
const defaultModel = 'v1'
const speedRatio = ref<number>(1.0)
const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore)
const { t } = useI18n()
// Additional settings specific to Volcengine (appId)
const appId = computed({
get: () => (providers.value[providerId]?.app as any)?.appId as string | undefined || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].app = {
appId: value,
}
},
})
// Check if API key is configured
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
// Get available voices for ElevenLabs
const availableVoices = computed(() => {
return speechStore.availableVoices[providerId] || []
})
// Generate speech with ElevenLabs-specific parameters
async function handleGenerateSpeech(input: string, voiceId: string, _useSSML: boolean) {
const provider = await providersStore.getProviderInstance(providerId) as SpeechProviderWithExtraOptions<string, UnElevenLabsOptions>
if (!provider) {
throw new Error('Failed to initialize speech provider')
}
// Get provider configuration
const providerConfig = providersStore.getProviderConfig(providerId)
// Get model from configuration or use default
const model = providerConfig.model as string | undefined || defaultModel
// ElevenLabs doesn't need SSML conversion, but if SSML is provided, use it directly
return await speechStore.speech(
provider,
model,
input,
voiceId,
{
...providerConfig,
},
)
}
onMounted(async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
const providerMetadata = providersStore.getProviderMetadata(providerId)
if (await providerMetadata.validators.validateProviderConfig(providerConfig)) {
await speechStore.loadVoicesForProvider(providerId)
}
else {
console.error('Failed to validate provider config', providerConfig)
}
})
watch(speedRatio, async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
if (!providerConfig.audio) {
providerConfig.audio = {}
}
(providerConfig.audio as any).speedRatio = speedRatio.value
})
watch([providers, appId], async () => {
const providerConfig = providersStore.getProviderConfig(providerId)
const providerMetadata = providersStore.getProviderMetadata(providerId)
if (await providerMetadata.validators.validateProviderConfig(providerConfig)) {
await speechStore.loadVoicesForProvider(providerId)
}
else {
console.error('Failed to validate provider config', providerConfig)
}
}, {
immediate: true,
})
</script>
<template>
<SpeechProviderSettings
:provider-id="providerId"
:default-model="defaultModel"
>
<!-- Voice settings specific to ElevenLabs -->
<template #basic-settings>
<div flex="~ col gap-4">
<FieldInput
v-model="appId"
:label="t('settings.pages.providers.provider.volcengine.fields.field.appId.label')"
:description="t('settings.pages.providers.provider.volcengine.fields.field.appId.description')"
required
/>
</div>
</template>
<template #voice-settings>
<!-- Speed control - common to most providers -->
<FieldRange
v-model="speedRatio"
:label="t('settings.pages.providers.provider.common.fields.field.speed.label')"
:description="t('settings.pages.providers.provider.common.fields.field.speed.description')"
:min="0.5"
:max="2.0" :step="0.01"
/>
</template>
<!-- Replace the default playground with our standalone component -->
<template #playground>
<SpeechPlayground
:available-voices="availableVoices"
:generate-speech="handleGenerateSpeech"
:api-key-configured="apiKeyConfigured"
default-text="Hello! This is a test of the ElevenLabs voice synthesis."
/>
</template>
</SpeechProviderSettings>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,79 +0,0 @@
<script setup lang="ts">
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
const providerId = 'xai'
const {
t,
router,
providerMetadata,
apiKey,
baseUrl,
isValidating,
isValid,
validationMessage,
handleResetSettings,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="xai-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.x.ai/v1/"
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,59 +0,0 @@
<script setup lang="ts">
import { Callout } from '@proj-airi/stage-ui/components'
import IconAnimation from '../../../components/IconAnimation.vue'
import { useIconAnimation } from '../../../composables/icon-animation'
const {
iconAnimationStarted,
showIconAnimation,
animationIcon,
} = useIconAnimation('i-solar:armchair-2-bold-duotone')
</script>
<template>
<div>
<Callout
label="In development, needs your help!"
theme="orange"
>
<div>
This functionality is still under development. If you have any suggestions or would like to contribute, please reach out to us on our <a underline decoration-dotted href="https://github.com/moeru-ai/airi/issues">GitHub issues page</a>.
The source code of this page is located at <a underline decoration-dotted href="https://github.com/moeru-ai/airi/tree/main/apps/stage-web/src/pages/settings/scene/index.vue">here</a>.
</div>
</Callout>
</div>
<IconAnimation
v-if="showIconAnimation"
:z-index="-1"
:icon="animationIcon"
:icon-size="12"
:duration="1000"
:started="iconAnimationStarted"
:is-reverse="true"
position="calc(100dvw - 9.5rem), calc(100dvh - 9.5rem)"
text-color="text-neutral-200/50 dark:text-neutral-600/20"
/>
<div
v-else
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-15rem)]" bottom-0 right--5 z--1
:initial="{ scale: 0.9, opacity: 0, y: 20 }"
:enter="{ scale: 1, opacity: 1, y: 0 }"
:duration="500"
size-60
flex items-center justify-center
>
<div text="60" i-solar:armchair-2-bold-duotone />
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
pageSpecificAvailable: true
</route>
@@ -1,8 +0,0 @@
[
[],
["#A5978B", "#D8CAAF", "#B8B4A7", "#C4BCB1", "#E5DED8", "#9A8F7D", "#BEB5A7", "#C9C0B6"],
["#7A9EAF", "#B8C7CC", "#D4B79C", "#8B9D77", "#C7D5CB", "#E6D0B1", "#94A7B1", "#B4C8C3"],
["#D9B48F", "#B5917A", "#8C7A6B", "#A17F5F", "#B98C46", "#C7A252", "#DAB300", "#D19826"],
["#9BA7B0", "#C1CBD4", "#A5ADB6", "#8B959E", "#D4DCE4", "#7F8A94", "#B3BCC6", "#98A4AE"],
["#E4C6D0", "#A61B29", "#5D513C", "#789262", "#1C0D1A", "#F7C242", "#62A9DD", "#8C4B3C"]
]
@@ -1,187 +0,0 @@
<script setup lang="ts">
import { ColorPalette, Section } from '@proj-airi/stage-ui/components'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { ColorHueRange } from '@proj-airi/ui'
import { useI18n } from 'vue-i18n'
import COLOR_PRESETS from './color-presets.json'
const settings = useSettings()
const { t } = useI18n()
</script>
<template>
<Section
v-motion
mb-2
:title="t('settings.pages.themes.sections.section.custom-color.title')"
icon="i-solar:pallete-2-bold-duotone"
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (4 * 10)"
:delay="4 * 50"
transition="all ease-in-out duration-250"
>
<div
v-motion flex items-center
justify-between
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (5 * 10)"
:delay="5 * 50"
transition="all ease-in-out duration-250"
>
<span text-lg font-normal>{{ $t('settings.pages.themes.sections.section.custom-color.fields.field.primary-color.label') }}</span>
<label relative flex cursor-pointer items-center gap-2>
<input
v-model="settings.themeColorsHueDynamic"
type="checkbox"
class="peer sr-only"
>
<div
class="h-6 w-11 rounded-full bg-neutral-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white dark:bg-neutral-600 peer-checked:bg-primary-500 after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
/>
{{ $t('settings.pages.themes.sections.section.custom-color.fields.field.primary-color.rgb-on.title') }}
</label>
</div>
<ColorHueRange
v-model="settings.themeColorsHue"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (6 * 10)"
:delay="6 * 50"
:disabled="settings.themeColorsHueDynamic"
/>
<div
v-motion
class="color-bar text-[10px] md:text-base sm:text-xs"
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (7 * 10)"
:delay="7 * 50"
transition="all ease-in-out duration-250"
>
<span bg-primary-50>50</span>
<span bg-primary-100>100</span>
<span bg-primary-200>200</span>
<span bg-primary-300>300</span>
<span bg-primary-400>400</span>
<span bg-primary-500>500</span>
<div
v-motion
text-white
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (8 * 10)"
:delay="8 * 50"
transition="all ease-in-out duration-250"
>
<span bg-primary-600>600</span>
<span bg-primary-700>700</span>
<span bg-primary-800>800</span>
<span bg-primary-900>900</span>
<span bg-primary-950>950</span>
</div>
</div>
<div
v-motion
class="color-bar transparency-grid text-[10px] md:text-base sm:text-xs"
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (9 * 10)"
:delay="9 * 50"
transition="all ease-in-out duration-250"
>
<span bg="primary-500/5">500/5</span>
<span bg="primary-500/10">500/10</span>
<span bg="primary-500/20">500/20</span>
<span bg="primary-500/30">500/30</span>
<span bg="primary-500/40">500/40</span>
<span bg="primary-500/50">500/50</span>
<span bg="primary-500/60">500/60</span>
<span bg="primary-500/70">500/70</span>
<span bg="primary-500/80">500/80</span>
<span bg="primary-500/90">500/90</span>
<span bg="primary-500">500</span>
</div>
</Section>
<Section
v-motion
mb-2 :title="t('settings.pages.themes.sections.section.theme-presets.title')"
icon="i-solar:magic-stick-2-bold-duotone"
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (10 * 10)"
:delay="10 * 50"
transition="all ease-in-out duration-250"
>
<div
v-for="({ title, description, colors }, i) in $tm('settings.pages.themes.sections.section.theme-presets.presets')" :key="i"
v-motion
class="w-full flex flex-col items-start justify-between gap-2 rounded-lg px-4 py-3 outline-none transition-all duration-250 ease-in-out md:flex-row md:items-center md:gap-0"
bg="neutral-100 dark:neutral-800"
hover="bg-neutral-200 dark:bg-neutral-700"
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (11 * 10) + (i * 10)"
:delay="11 * 50 + (i * 50)"
transition="all ease-in-out duration-250"
>
<div>
<span font-medium>{{ $rt(title) }}</span>
<div text="sm neutral-500">
{{ $rt(description) }}
</div>
</div>
<ColorPalette :colors="(colors as any[]).map((name, j) => ({ hex: COLOR_PRESETS[i][j], name: $rt(name) }))" />
</div>
</Section>
<div
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[65dvh]" right--15 z--1
:initial="{ scale: 0.9, opacity: 0, rotate: 30 }"
:enter="{ scale: 1, opacity: 1, rotate: 0 }"
:duration="250"
flex items-center justify-center
>
<div text="60" i-solar:pallete-2-bold-duotone />
</div>
</template>
<style scoped>
.color-bar {
--at-apply: flex of-hidden rounded-lg lh-10 text-center text-black;
* {
flex: 1;
}
div {
display: contents;
}
}
.transparency-grid {
background-image: linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%);
background-size: 20px 20px;
background-position:
0 0,
0 10px,
10px -10px,
-10px 0px;
background-color: #fff;
}
</style>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
-75
View File
@@ -1,75 +0,0 @@
<script setup lang="ts">
import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
import { useLive2d } from '@proj-airi/stage-ui/stores/live2d'
import { breakpointsTailwind, useBreakpoints, useDark, useMouse } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
import Cross from '../../components/Backgrounds/Cross.vue'
import Header from '../../components/Layouts/Header.vue'
import InteractiveArea from '../../components/Layouts/InteractiveArea.vue'
import MobileHeader from '../../components/Layouts/MobileHeader.vue'
import MobileInteractiveArea from '../../components/Layouts/MobileInteractiveArea.vue'
import AnimatedWave from '../../components/Widgets/AnimatedWave.vue'
import { themeColorFromPropertyOf, useThemeColor } from '../../composables/theme-color'
const dark = useDark()
const paused = ref(false)
function handleSettingsOpen(open: boolean) {
paused.value = open
}
const positionCursor = useMouse()
const { scale, position, positionInPercentageString } = storeToRefs(useLive2d())
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('md')
const { updateThemeColor } = useThemeColor(themeColorFromPropertyOf('.widgets.top-widgets .colored-area', 'background-color'))
watch(dark, () => updateThemeColor(), { immediate: true })
onMounted(() => updateThemeColor())
</script>
<template>
<Cross>
<AnimatedWave
class="widgets top-widgets"
:fill-color="dark
? 'oklch(35% calc(var(--chromatic-chroma) * 0.6) var(--chromatic-hue))'
: 'color-mix(in srgb, oklch(95% calc(var(--chromatic-chroma-50) * 0.5) var(--chromatic-hue)) 80%, oklch(100% 0 360))'"
>
<div relative flex="~ col" z-2 h-100dvh w-100vw of-hidden>
<!-- header -->
<div class="px-0 py-1 md:px-3 md:py-3" w-full gap-2>
<Header class="hidden md:flex" />
<MobileHeader class="flex md:hidden" />
</div>
<!-- page -->
<div relative flex="~ 1 row gap-y-0 gap-x-2 <md:col">
<WidgetStage
flex-1 min-w="1/2"
:paused="paused"
:focus-at="{
x: positionCursor.x.value,
y: positionCursor.y.value,
}"
:x-offset="`${isMobile ? position.x : position.x - 10}%`"
:y-offset="positionInPercentageString.y"
:scale="scale"
/>
<InteractiveArea v-if="!isMobile" h="85dvh" absolute right-4 flex flex-1 flex-col max-w="500px" min-w="30%" />
<MobileInteractiveArea v-if="isMobile" @settings-open="handleSettingsOpen" />
</div>
</div>
</AnimatedWave>
</Cross>
</template>
<route lang="yaml">
name: StageScenePage
meta:
layout: stage
stageTransition:
name: bubble-wave-out
</route>
@@ -1,77 +0,0 @@
<script setup lang="ts">
import { sleep } from '@moeru/std'
import { Textarea } from '@proj-airi/ui'
import { ref } from 'vue'
const messageInput = ref<string>('')
const processing = ref<boolean>(false)
const streamingMessage = ref({ content: '' })
async function onSendMessage() {
processing.value = true
const tokens = messageInput.value.split('')
enum States {
Literal = 'literal',
Special = 'special',
}
let state = States.Literal
let buffer = ''
for (const textPart of tokens) {
await sleep(50)
let newState: States = state
if (textPart === '<')
newState = States.Special
else if (textPart === '>')
newState = States.Literal
if (state === States.Literal && newState === States.Special) {
streamingMessage.value.content += buffer
buffer = ''
}
if (state === States.Special && newState === States.Literal)
buffer = '' // Clear buffer when exiting Special state
if (state === States.Literal && newState === States.Literal) {
streamingMessage.value.content += textPart
buffer = ''
}
state = newState
}
if (buffer)
streamingMessage.value.content += buffer
messageInput.value = ''
processing.value = false
}
</script>
<template>
<div flex flex-col gap-2 p-2>
<div flex flex-row gap-2>
<Textarea
v-model="messageInput"
placeholder="Message"
p="2" bg="neutral-100 dark:neutral-700"
w-full rounded-lg outline-none
@submit="onSendMessage"
/>
<button rounded-lg bg="neutral-100 dark:neutral-700" p-4>
{{ processing ? 'Processing...' : 'Send' }}
</button>
</div>
<div w-full rounded-lg bg="neutral-100 dark:neutral-700" p-2>
<h3 font-normal>
Streaming Message
</h3>
<div>{{ streamingMessage.content }}</div>
</div>
</div>
</template>
@@ -1,70 +0,0 @@
<script setup lang="ts">
import { useDelayMessageQueue } from '@proj-airi/stage-ui/composables'
import { llmInferenceEndToken } from '@proj-airi/stage-ui/constants'
import { Textarea } from '@proj-airi/ui'
import { ref } from 'vue'
const messageInput = ref<string>('')
const emotionMessageContentProcessed = ref<string[]>([])
const delaysProcessed = ref<number[]>([])
const processing = ref<boolean>(false)
// const emotionMessageContentQueue = useQueue<string>({
// handlers: [
// async (ctx) => {
// emotionMessageContentProcessed.value.push(ctx.data)
// },
// ],
// })
const delaysQueue = useDelayMessageQueue()
delaysQueue.onHandlerEvent('delay', (delay) => {
delaysProcessed.value.push(delay)
})
function onSendMessage() {
processing.value = true
const tokens = messageInput.value.split('')
for (const token of tokens)
delaysQueue.enqueue(token)
delaysQueue.enqueue(llmInferenceEndToken)
messageInput.value = ''
processing.value = false
}
</script>
<template>
<div flex flex-col gap-2 p-2>
<div flex flex-row gap-2>
<Textarea
v-model="messageInput"
placeholder="Message"
p="2" bg="neutral-100 dark:neutral-700"
w-full rounded-lg outline-none
@submit="onSendMessage"
/>
<button rounded-lg bg="neutral-100 dark:neutral-700" p-4>
{{ processing ? 'Processing...' : 'Send' }}
</button>
</div>
<div w-full flex flex-row gap-4>
<div w-full rounded-lg bg="neutral-100 dark:neutral-700" p-2>
<h3 font-normal>
Emotion Message
</h3>
<div v-for="message in emotionMessageContentProcessed" :key="message">
<div>{{ message }}</div>
</div>
</div>
<div w-full rounded-lg bg="neutral-100 dark:neutral-700" p-2>
<h3 font-normal>
Delays
</h3>
<div v-for="message in delaysProcessed" :key="message">
<div>{{ message }}s</div>
</div>
</div>
</div>
</div>
</template>
@@ -1,70 +0,0 @@
<script setup lang="ts">
import type { Emotion } from '@proj-airi/stage-ui/constants/emotions'
import { useEmotionsMessageQueue } from '@proj-airi/stage-ui/composables/queues'
import { llmInferenceEndToken } from '@proj-airi/stage-ui/constants'
import { createQueue } from '@proj-airi/stage-ui/utils/queue'
import { Textarea } from '@proj-airi/ui'
import { ref } from 'vue'
const messageInput = ref<string>('')
const messagesProcessed = ref<string[]>([])
const emotionsProcessed = ref<string[]>([])
const processing = ref<boolean>(false)
const emotionsQueue = createQueue<Emotion>({
handlers: [
async (ctx) => {
emotionsProcessed.value.push(ctx.data)
},
],
})
const emotionMessageContentQueue = useEmotionsMessageQueue(emotionsQueue)
function onSendMessage() {
processing.value = true
const tokens = messageInput.value.split('')
for (const token of tokens)
emotionMessageContentQueue.enqueue(token)
emotionMessageContentQueue.enqueue(llmInferenceEndToken)
messageInput.value = ''
processing.value = false
}
</script>
<template>
<div flex flex-col gap-2 p-2>
<div flex flex-row gap-2>
<Textarea
v-model="messageInput"
placeholder="Message"
p="2" bg="neutral-100 dark:neutral-700"
w-full rounded-lg outline-none
@submit="onSendMessage"
/>
<button rounded-lg bg="neutral-100 dark:neutral-700" p-4>
{{ processing ? 'Processing...' : 'Send' }}
</button>
</div>
<div w-full flex flex-row gap-4>
<div w-full rounded-lg bg="neutral-100 dark:neutral-700" p-2>
<h3 font-normal>
Messages
</h3>
<div v-for="message in messagesProcessed" :key="message">
<div>{{ message }}</div>
</div>
</div>
<div w-full rounded-lg bg="neutral-100 dark:neutral-700" p-2>
<h3 font-normal>
Emotions
</h3>
<div v-for="message in emotionsProcessed" :key="message">
<div>{{ message }}</div>
</div>
</div>
</div>
</div>
</template>
@@ -1,68 +0,0 @@
<script setup lang="ts">
import { usePipelineWorkflowTextSegmentationStore } from '@proj-airi/stage-ui/composables/queues'
import { llmInferenceEndToken } from '@proj-airi/stage-ui/constants'
import { createQueue } from '@proj-airi/stage-ui/utils/queue'
import { Textarea } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
const messageInput = ref<string>('')
const ttsProcessed = ref<string[]>([])
const processing = ref<boolean>(false)
const textSegmentationStore = usePipelineWorkflowTextSegmentationStore()
const { onTextSegmented } = textSegmentationStore
const { textSegmentationQueue } = storeToRefs(textSegmentationStore)
const ttsQueue = createQueue<string>({
handlers: [
async (ctx) => {
ttsProcessed.value.push(ctx.data)
},
],
})
onTextSegmented((text) => {
ttsQueue.enqueue(text)
})
async function onSendMessage() {
processing.value = true
// const tokens = messageInput.value.split('')
// for (const token of tokens) {
// await sleep(100)
// messageContentQueue.add(token)
// }
textSegmentationQueue.value.enqueue(messageInput.value)
textSegmentationQueue.value.enqueue(llmInferenceEndToken)
messageInput.value = ''
processing.value = false
}
</script>
<template>
<div flex flex-col gap-2 p-2>
<div flex flex-row gap-2>
<Textarea
v-model="messageInput"
placeholder="Message"
p="2" bg="neutral-100 dark:neutral-700"
w-full rounded-lg outline-none
@submit="onSendMessage"
/>
<button rounded-lg bg="neutral-100 dark:neutral-700" p-4>
{{ processing ? 'Processing...' : 'Send' }}
</button>
</div>
<div w-full flex flex-row gap-4>
<div w-full rounded-lg bg="neutral-100 dark:neutral-700" p-2>
<h3 font-normal>
TTS Message
</h3>
<div v-for="message in ttsProcessed" :key="message">
<div>{{ message }}</div>
</div>
</div>
</div>
</div>
</template>
-74
View File
@@ -1,74 +0,0 @@
import type { CommonRequestOptions } from '@xsai/shared'
import type { Message } from '@xsai/shared-chat'
import type { Infer, Schema } from 'xsschema'
import { generateText } from '@xsai/generate-text'
import { message } from '@xsai/utils-chat'
import { toJsonSchema, validate } from 'xsschema'
type SchemaOrString<S extends Schema | undefined | unknown> = S extends unknown ? string : S extends Schema ? Infer<S> : never
async function parseJSONFormat<S extends Schema, R extends SchemaOrString<S>>(content: string, options: { messages: Message[], apiKey?: string, baseURL: string, model: string } & Partial<CommonRequestOptions>, schema?: S, erroredValue?: string, errorMessage?: string): Promise<R> {
if (!schema)
return content as unknown as R
try {
let parsedContent: Infer<S>
let correctionPrompt = ''
if (erroredValue && errorMessage) {
correctionPrompt = `Previous response "${JSON.stringify(erroredValue)}" was invalid due to: ${JSON.stringify(errorMessage)}\n\n`
}
try {
parsedContent = JSON.parse(content)
}
catch (parseError) {
console.error('Error parsing JSON:', parseError, content)
options.messages.push(message.user(`
${correctionPrompt}The response was not valid JSON:
${JSON.stringify(content)}
Error: ${String(parseError)}
Please provide a corrected JSON response that matches the schema:
${JSON.stringify(await toJsonSchema(schema))}`))
const response = await call(options, schema)
return parseJSONFormat(response, options, schema, content, String(parseError))
}
// TODO: print validation issues
return await validate(schema, parsedContent) as R
}
catch (error) {
console.error('Error processing response:', error)
throw error
}
}
/**
* Processes user input and generates LLM response along with thought nodes.
*/
async function call<S extends Schema, R extends SchemaOrString<S>>(options: { messages: Message[], apiKey?: string, baseURL: string, model: string } & Partial<CommonRequestOptions>, schema?: S): Promise<R> {
if (schema != null) {
options.messages.push(message.user(`Your response must follow the following schema:
${JSON.stringify(await toJsonSchema(schema))}
Without any extra markups such as \`\`\` in markdown, or descriptions.`))
}
const response = await generateText({
baseURL: options.baseURL,
apiKey: options.apiKey,
model: options.model,
messages: options.messages,
})
return await parseJSONFormat<S, R>(response.text || '', options, schema)
}
export async function generateObject<S extends Schema, R extends SchemaOrString<S>>(options: { messages: Message[], model: string, apiKey?: string, baseURL: string } & Partial<CommonRequestOptions>, schema?: S): Promise<R> {
return await call(options, schema)
}
-3
View File
@@ -1,3 +0,0 @@
export function isPlatformTamagotchi() {
return import.meta.env.MODE === 'tamagotchi'
}
+14 -3
View File
@@ -46,15 +46,22 @@ export default defineConfig({
'@framework/model/cubismmoc',
],
},
resolve: {
alias: {
'@proj-airi/server-sdk': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-sdk', 'src')),
'@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')),
'@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')),
'@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')),
'@proj-airi/stage-pages': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src')),
},
},
server: {
warmup: {
clientFiles: [
`${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src'))}/*.vue`,
`${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src'))}/*.vue`,
],
},
},
plugins: [
Info(),
@@ -76,6 +83,10 @@ export default defineConfig({
extensions: ['.vue', '.md'],
dts: resolve(import.meta.dirname, 'src/typed-router.d.ts'),
importMode: 'async',
routesFolder: [
resolve(import.meta.dirname, 'src', 'pages'),
resolve(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src', 'pages'),
],
}),
// https://github.com/JohnCampionJr/vite-plugin-vue-layouts
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@proj-airi/stage-pages",
"type": "module",
"private": true,
"description": "Shared Pages",
"author": {
"name": "Moeru AI Project AIRI Team",
"email": "airi@moeru.ai",
"url": "https://github.com/moeru-ai"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/moeru-ai/airi.git",
"directory": "packages/stage-pages"
},
"exports": {
"./*": "./src/*"
},
"scripts": {
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@nekopaw/tempora": "0.3.1-alpha.1",
"@proj-airi/ccc": "workspace:*",
"@proj-airi/i18n": "workspace:*",
"@proj-airi/stage-ui": "workspace:*",
"@proj-airi/ui": "workspace:*",
"@stdlib/string-base-kebabcase": "^0.2.2",
"@vueuse/core": "^13.9.0",
"@vueuse/shared": "^13.9.0",
"@xsai-ext/shared-providers": "catalog:",
"@xsai/generate-speech": "catalog:",
"animejs": "^4.2.0",
"dompurify": "^3.2.7",
"nanoid": "^5.1.6",
"node-vibrant": "^4.0.3",
"pinia": "^3.0.3",
"reka-ui": "^2.5.1",
"unspeech": "^0.1.7",
"vue": "^3.5.22",
"vue-i18n": "^11.1.12",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@types/three": "^0.180.0",
"vue-tsc": "^3.0.8"
}
}
@@ -1,51 +1,16 @@
<script setup lang="ts">
import { IconItem } from '@proj-airi/stage-ui/components'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { computed, nextTick, ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import IconAnimation from '../../components/IconAnimation.vue'
const router = useRouter()
const iconAnimationStarted = ref(false)
const iconAnimation = ref<InstanceType<typeof IconAnimation>>()
const resolveAnimation = ref<() => void>()
const { t } = useI18n()
const animationIcon = ref('')
const animationPosition = ref('')
const showAnimationComponent = ref(false)
const settingsStore = useSettings()
function handleAnimationEnded() {
resolveAnimation.value?.()
}
async function handleIconItemClick(event: MouseEvent, setting: typeof settings.value[0]) {
const target = event.currentTarget as HTMLElement
const iconElement = target.querySelector('.menu-icon-item-icon') as HTMLElement
if (!iconElement)
return
// get the position of the icon element
const rect = iconElement.getBoundingClientRect()
const position = `${rect.left}px, ${rect.top}px`
// set the icon and position
animationIcon.value = setting.icon
animationPosition.value = position
// show the animation component
showAnimationComponent.value = true
// wait for the DOM to update
await nextTick()
// start the animation
iconAnimationStarted.value = true
}
const removeBeforeEach = router.beforeEach(async (_, __, next) => {
if (!settingsStore.usePageSpecificTransitions || settingsStore.disableTransitions) {
next()
@@ -123,22 +88,9 @@ const settings = computed(() => [
:description="setting.description"
:icon="setting.icon"
:to="setting.to"
@click="(e: MouseEvent) => handleIconItemClick(e, setting)"
/>
</div>
<IconAnimation
v-if="showAnimationComponent && !settingsStore.disableTransitions && settingsStore.usePageSpecificTransitions"
ref="iconAnimation"
:icon="animationIcon"
:icon-size="6 * 1.2"
:position="animationPosition"
:duration="1000"
text-color="text-neutral-400/50 dark:text-neutral-600/20"
:started="iconAnimationStarted"
@animation-ended.once="handleAnimationEnded"
/>
<div
v-else
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-12rem)]" bottom-0 right--10 z--1
@@ -5,10 +5,6 @@ import { ModelSettings } from '@proj-airi/stage-ui/components/scenarios/settings
import { Vibrant } from 'node-vibrant/browser'
import { ref } from 'vue'
import IconAnimation from '../../../components/IconAnimation.vue'
import { useIconAnimation } from '../../../composables/icon-animation'
const live2dCanvasRef = ref<InstanceType<typeof Live2DCanvas>>()
const palette = ref<string[]>([])
@@ -34,12 +30,6 @@ async function extractColorsFromModel() {
URL.revokeObjectURL(frameUrl)
}
}
const {
iconAnimationStarted,
showIconAnimation,
animationIcon,
} = useIconAnimation('i-solar:people-nearby-bold-duotone')
</script>
<template>
@@ -52,20 +42,7 @@ const {
/>
</div>
<IconAnimation
v-if="showIconAnimation"
:z-index="-1"
:icon="animationIcon"
:icon-size="12"
:duration="1000"
:started="iconAnimationStarted"
:is-reverse="true"
position="calc(100dvw - 9.5rem), calc(100dvh - 9.5rem)"
text-color="text-neutral-200/50 dark:text-neutral-600/20"
/>
<div
v-else
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-15rem)]" bottom-0 right--5 z--1
@@ -1,21 +1,20 @@
<script setup lang="ts">
import type { TranscriptionProvider } from '@xsai-ext/shared-providers'
import workletUrl from '@proj-airi/stage-ui/workers/vad/process.worklet?worker&url'
import { Alert, Button, ErrorContainer, LevelMeter, RadioCardManySelect, RadioCardSimple, TestDummyMarker, ThresholdMeter, TimeSeriesChart } from '@proj-airi/stage-ui/components'
import { useAudioAnalyzer, useAudioRecorder } from '@proj-airi/stage-ui/composables'
import { useAudioContext } from '@proj-airi/stage-ui/stores/audio'
import { useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { createVAD, createVADStates } from '@proj-airi/stage-ui/workers/vad'
import { FieldCheckbox, FieldRange, FieldSelect } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import workletUrl from '../../../workers/vad/process.worklet?worker&url'
import { createVAD, createVADStates } from '../../../workers/vad'
const { t } = useI18n()
const hearingStore = useHearingStore()
@@ -2,17 +2,7 @@
import { IconStatusItem } from '@proj-airi/stage-ui/components'
import { useModulesList } from '@proj-airi/stage-ui/composables/use-modules-list'
import IconAnimation from '../../../components/IconAnimation.vue'
import { useIconAnimation } from '../../../composables/icon-animation'
const { modulesList } = useModulesList()
const {
iconAnimationStarted,
showIconAnimation,
animationIcon,
} = useIconAnimation('i-solar:layers-bold-duotone')
</script>
<template>
@@ -34,19 +24,7 @@ const {
:configured="module.configured"
/>
</div>
<IconAnimation
v-if="showIconAnimation"
:icon="animationIcon"
:icon-size="12"
:duration="1000"
:started="iconAnimationStarted"
:is-reverse="true"
:z-index="-1"
text-color="text-neutral-200/50 dark:text-neutral-600/20"
position="calc(100dvw - 9.5rem), calc(100dvh - 9.5rem)"
/>
<div
v-else
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-15rem)]" bottom-0 right--5 z--1

Some files were not shown because too many files have changed in this diff Show More