fix(stage-ui,stage-web,stage-tamagotchi): not properly validated

This commit is contained in:
Neko Ayaka
2025-06-17 16:23:09 +08:00
parent cc449b1d0c
commit ed9d929446
12 changed files with 441 additions and 25 deletions
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<!-- Style from ui/InputFile component, may be centralized later -->
<div
relative
class="min-h-[120px] flex flex-col cursor-pointer items-center justify-center border-neutral-200 rounded-xl bg-white/60 p-6 dark:border-neutral-700 hover:border-primary-300 dark:bg-black/30 hover:bg-white/80 dark:hover:border-primary-700 dark:hover:bg-black/40"
border="solid 2"
transition="all duration-300"
cursor-pointer opacity-95
hover="scale-100 opacity-100 shadow-md dark:shadow-lg"
>
<div i-solar:add-square-line-duotone mb-4 text-5xl text="neutral-400 dark:neutral-500" />
<p font-medium text="neutral-600 dark:neutral-300">
{{ t('settings.pages.card.create_card') }}
</p>
</div>
</template>
@@ -0,0 +1,280 @@
<script setup lang="ts">
import type { Card } from '@proj-airi/ccc'
import { Button } from '@proj-airi/stage-ui/components'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores'
import { FieldInput, FieldValues } from '@proj-airi/ui'
import kebabcase from '@stdlib/string-base-kebabcase'
import {
DialogContent,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
} from 'radix-vue'
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="data-[state=open]:animate-fadeIn data-[state=closed]:animate-fadeOut fixed inset-0 z-100 bg-black/50 backdrop-blur-sm" />
<DialogContent class="data-[state=open]:animate-contentShow data-[state=closed]:animate-contentHide 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 dark:border-neutral-700 dark:bg-neutral-800 sm:p-6">
<div class="w-full flex flex-col gap-5">
<DialogTitle text-2xl font-bold 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>
@@ -8,6 +8,8 @@ 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'
@@ -21,6 +23,7 @@ const { cards, activeCardId } = storeToRefs(cardStore)
const selectedCardId = ref<string>('')
// Dialog state
const isCardDialogOpen = ref(false)
const isCardCreationDialogOpen = ref(false)
// Search query
const searchQuery = ref('')
@@ -116,6 +119,10 @@ function handleSelectCard(cardId: string) {
isCardDialogOpen.value = true
}
function handleCardCreationDialog() {
isCardCreationDialogOpen.value = true
}
// Card activation
function activateCard(id: string) {
activeCardId.value = id
@@ -147,7 +154,7 @@ function getModuleShortName(id: string, module: 'consciousness' | 'voice') {
</script>
<template>
<div rounded-xl py-4 flex="~ col gap-4">
<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 -->
@@ -214,6 +221,9 @@ function getModuleShortName(id: string, module: 'consciousness' | 'voice') {
</template>
</InputFile>
<!-- Create card -->
<CardCreate @click="handleCardCreationDialog" />
<!-- Card Items -->
<template v-if="cards.size > 0">
<CardListItem
@@ -274,6 +284,11 @@ function getModuleShortName(id: string, module: 'consciousness' | 'voice') {
:card-id="selectedCardId"
/>
<!-- Card detail dialog -->
<CardCreationDialog
v-model="isCardCreationDialogOpen"
/>
<!-- Background decoration -->
<div
v-motion
@@ -8,7 +8,7 @@ import { RouterLink } from 'vue-router'
const providersStore = useProvidersStore()
const consciousnessStore = useConsciousnessStore()
const { availableProviders, allChatProvidersMetadata } = storeToRefs(providersStore)
const { configuredChatProvidersMetadata } = storeToRefs(providersStore)
const {
activeProvider,
activeModel,
@@ -50,14 +50,14 @@ function updateCustomModelName(value: string) {
See also: https://stackoverflow.com/a/33737340
-->
<fieldset
v-if="availableProviders.length > 0"
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 allChatProvidersMetadata"
v-for="metadata in configuredChatProvidersMetadata"
:id="metadata.id"
:key="metadata.id"
v-model="activeProvider"
@@ -24,10 +24,7 @@ import { RouterLink } from 'vue-router'
const { t } = useI18n()
const providersStore = useProvidersStore()
const speechStore = useSpeechStore()
const {
availableProviders,
allAudioSpeechProvidersMetadata,
} = storeToRefs(providersStore)
const { configuredSpeechProvidersMetadata } = storeToRefs(providersStore)
const {
activeSpeechProvider,
activeSpeechModel,
@@ -181,11 +178,11 @@ function updateCustomModelName(value: string) {
</div>
<div max-w-full>
<fieldset
v-if="availableProviders.length > 0" flex="~ row gap-4" :style="{ 'scrollbar-width': 'none' }"
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 allAudioSpeechProvidersMetadata"
v-for="metadata in configuredSpeechProvidersMetadata"
:id="metadata.id"
:key="metadata.id"
v-model="activeSpeechProvider"
@@ -112,8 +112,8 @@ watch(headers, (headers) => {
: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, value) => addKeyValue(headers, key, value)"
@remove="(index) => removeKeyValue(index, headers)"
@add="(key: string, value: string) => addKeyValue(headers, key, value)"
@remove="(index: number) => removeKeyValue(index, headers)"
/>
</ProviderAdvancedSettings>
</ProviderSettingsContainer>
@@ -0,0 +1,77 @@
<script setup lang="ts">
import {
ProviderBaseUrlInput,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProvidersStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { computed, onMounted, 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)
// Get provider metadata
const providerId = 'player2-api'
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl as string || '',
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
baseUrl.value = providers.value[providerId]?.baseUrl as string || ''
})
// 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>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -8,7 +8,7 @@ import { RouterLink } from 'vue-router'
const providersStore = useProvidersStore()
const consciousnessStore = useConsciousnessStore()
const { availableProviders, allChatProvidersMetadata } = storeToRefs(providersStore)
const { configuredChatProvidersMetadata } = storeToRefs(providersStore)
const {
activeProvider,
activeModel,
@@ -50,14 +50,14 @@ function updateCustomModelName(value: string) {
See also: https://stackoverflow.com/a/33737340
-->
<fieldset
v-if="availableProviders.length > 0"
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 allChatProvidersMetadata"
v-for="metadata in configuredChatProvidersMetadata"
:id="metadata.id"
:key="metadata.id"
v-model="activeProvider"
@@ -24,10 +24,7 @@ import { RouterLink } from 'vue-router'
const { t } = useI18n()
const providersStore = useProvidersStore()
const speechStore = useSpeechStore()
const {
availableProviders,
allAudioSpeechProvidersMetadata,
} = storeToRefs(providersStore)
const { configuredSpeechProvidersMetadata } = storeToRefs(providersStore)
const {
activeSpeechProvider,
activeSpeechModel,
@@ -181,11 +178,11 @@ function updateCustomModelName(value: string) {
</div>
<div max-w-full>
<fieldset
v-if="availableProviders.length > 0" flex="~ row gap-4" :style="{ 'scrollbar-width': 'none' }"
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 allAudioSpeechProvidersMetadata"
v-for="metadata in configuredSpeechProvidersMetadata"
:id="metadata.id"
:key="metadata.id"
v-model="activeSpeechProvider"
@@ -3,6 +3,7 @@ import { IconStatusItem } from '@proj-airi/stage-ui/components'
import { useProvidersStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import IconAnimation from '../../../components/IconAnimation.vue'
import { useIconAnimation } from '../../../composables/useIconAnimation'
const providersStore = useProvidersStore()
@@ -1,4 +1,5 @@
<script setup lang="ts">
import IconAnimation from '../../../components/IconAnimation.vue'
import { useIconAnimation } from '../../../composables/useIconAnimation'
const {
+30 -4
View File
@@ -66,6 +66,7 @@ export interface ProviderMetadata {
validators: {
validateProviderConfig: (config: Record<string, unknown>) => Promise<boolean> | boolean
}
configured?: boolean
}
export interface ModelInfo {
@@ -183,7 +184,13 @@ export const useProvidersStore = defineStore('providers', () => {
},
validators: {
validateProviderConfig: (config) => {
return !!config.baseUrl
if (!config.baseUrl)
return false
// Check if the Ollama server is reachable
return fetch(`${(config.baseUrl as string).trim()}models`)
.then(response => response.ok)
.catch(() => false)
},
},
},
@@ -916,9 +923,13 @@ export const useProvidersStore = defineStore('providers', () => {
},
validators: {
validateProviderConfig: (config) => {
const url: string = config.baseUrl ? config.baseUrl as string : 'http://localhost:4315/v1/'
// checks if health status is there, so it green if and only if you actually have the player2 app running
return (fetch(`${url}health`).then(r => r.status === 200).catch(() => false))
if (!config.baseUrl)
return false
// Check if the Player2 API server is reachable
return fetch(`${(config.baseUrl as string).trim()}health`)
.then(response => response.ok)
.catch(() => false)
},
},
},
@@ -1241,6 +1252,18 @@ export const useProvidersStore = defineStore('providers', () => {
return allProvidersMetadata.value.filter(metadata => metadata.category === 'transcription')
})
const configuredChatProvidersMetadata = computed(() => {
return allChatProvidersMetadata.value.filter(metadata => configuredProviders.value[metadata.id])
})
const configuredSpeechProvidersMetadata = computed(() => {
return allAudioSpeechProvidersMetadata.value.filter(metadata => configuredProviders.value[metadata.id])
})
const configuredTranscriptionProvidersMetadata = computed(() => {
return allAudioTranscriptionProvidersMetadata.value.filter(metadata => configuredProviders.value[metadata.id])
})
function getProviderConfig(providerId: string) {
return providerCredentials.value[providerId]
}
@@ -1267,5 +1290,8 @@ export const useProvidersStore = defineStore('providers', () => {
allChatProvidersMetadata,
allAudioSpeechProvidersMetadata,
allAudioTranscriptionProvidersMetadata,
configuredChatProvidersMetadata,
configuredSpeechProvidersMetadata,
configuredTranscriptionProvidersMetadata,
}
})