mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 08:52:42 +00:00
fix(stage-pages): validate and preserve AIRI Card drafts (#2109)
Signed-off-by: RainbowBird <git@luoling.moe>
This commit is contained in:
@@ -34,7 +34,6 @@
|
||||
"@proj-airi/stage-ui-three": "workspace:*",
|
||||
"@proj-airi/ui": "workspace:*",
|
||||
"@shopify/draggable": "catalog:",
|
||||
"@stdlib/string-base-kebabcase": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
"@vueuse/shared": "catalog:",
|
||||
"@xsai-ext/providers": "catalog:",
|
||||
|
||||
+22
-91
@@ -2,11 +2,10 @@
|
||||
import type { Card } from '@proj-airi/ccc'
|
||||
import type { AiriExtension } from '@proj-airi/stage-ui/stores/modules/airi-card'
|
||||
|
||||
import kebabcase from '@stdlib/string-base-kebabcase'
|
||||
|
||||
import { isCustomProvidersDisabled } from '@proj-airi/stage-shared'
|
||||
import { useAnalytics } from '@proj-airi/stage-ui/composables'
|
||||
import { DEFAULT_ARTISTRY_WIDGET_INSTRUCTION } from '@proj-airi/stage-ui/constants/prompts/artistry-instruction'
|
||||
import { safeParseAiriCardDraft } from '@proj-airi/stage-ui/services/airi-card-editor'
|
||||
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
|
||||
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
|
||||
import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
|
||||
@@ -305,80 +304,15 @@ 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) > 0)) {
|
||||
// No name
|
||||
const draftResult = safeParseAiriCardDraft(toRaw(card), selectedArtistryConfigStr.value)
|
||||
if (!draftResult.success) {
|
||||
showError.value = true
|
||||
errorMessage.value = t('settings.pages.card.creation.errors.name')
|
||||
errorMessage.value = t(`settings.pages.card.creation.errors.${draftResult.error}`)
|
||||
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) > 0)) {
|
||||
// No description
|
||||
showError.value = true
|
||||
errorMessage.value = t('settings.pages.card.creation.errors.description')
|
||||
return false
|
||||
}
|
||||
else if (!((rawCard.personality?.length ?? 0) > 0)) {
|
||||
// No personality
|
||||
showError.value = true
|
||||
errorMessage.value = t('settings.pages.card.creation.errors.personality')
|
||||
return false
|
||||
}
|
||||
else if (!((rawCard.scenario?.length ?? 0) > 0)) {
|
||||
// No Scenario
|
||||
showError.value = true
|
||||
errorMessage.value = t('settings.pages.card.creation.errors.scenario')
|
||||
return false
|
||||
}
|
||||
else if (!((rawCard.systemPrompt?.length ?? 0) > 0)) {
|
||||
// No sys prompt
|
||||
showError.value = true
|
||||
errorMessage.value = t('settings.pages.card.creation.errors.systemprompt')
|
||||
return false
|
||||
}
|
||||
else if (!((rawCard.postHistoryInstructions?.length ?? 0) > 0)) {
|
||||
// No post history prompt
|
||||
showError.value = true
|
||||
errorMessage.value = t('settings.pages.card.creation.errors.posthistoryinstructions')
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate Artistry JSON if provided
|
||||
if (selectedArtistryConfigStr.value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(selectedArtistryConfigStr.value)
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error('Not an object')
|
||||
}
|
||||
}
|
||||
catch {
|
||||
showError.value = true
|
||||
errorMessage.value = t('settings.pages.card.creation.errors.invalid_artistry_json')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
showError.value = false
|
||||
|
||||
// Build options with final safety parse
|
||||
let artistryOptions: Record<string, any> | undefined
|
||||
if (selectedArtistryConfigStr.value.trim()) {
|
||||
try {
|
||||
artistryOptions = JSON.parse(selectedArtistryConfigStr.value)
|
||||
}
|
||||
catch {
|
||||
// Should not happen due to validation above
|
||||
artistryOptions = undefined
|
||||
}
|
||||
}
|
||||
const { card: rawCard, artistryOptions } = draftResult.output
|
||||
|
||||
// Build card with modules extension
|
||||
const cardWithModules = {
|
||||
@@ -422,8 +356,10 @@ function saveCard(card: Card): boolean {
|
||||
trackCardEdited({ card_id: props.cardId })
|
||||
}
|
||||
else {
|
||||
// Create mode: add new card
|
||||
cardStore.addCard(cardWithModules, 'scratch')
|
||||
const newCardId = cardStore.addCard(cardWithModules, 'scratch')
|
||||
// A new card becomes the runtime profile immediately so Create does not
|
||||
// appear to succeed while conversations continue using the previous card.
|
||||
cardStore.activeCardId = newCardId
|
||||
}
|
||||
|
||||
modelValue.value = false // Close this
|
||||
@@ -490,31 +426,26 @@ const card = ref<Card>(initializeCard())
|
||||
// Reinitialize when cardId changes or dialog opens
|
||||
watch(() => [props.modelValue, props.cardId], () => {
|
||||
if (props.modelValue) {
|
||||
showError.value = false
|
||||
errorMessage.value = ''
|
||||
card.value = initializeCard()
|
||||
}
|
||||
})
|
||||
|
||||
function makeComputed<T extends keyof Card>(
|
||||
/*
|
||||
Function used to generate Computed values, with an optional sanitize function
|
||||
*/
|
||||
key: T,
|
||||
transform?: (input: string) => string,
|
||||
) {
|
||||
function makeComputed<T extends keyof Card>(key: T) {
|
||||
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
|
||||
set: (value: string) => {
|
||||
// Preserve in-progress whitespace. Trimming on every input event makes
|
||||
// multi-word names and prompts collapse while the user is typing.
|
||||
card.value[key] = value as Card[T]
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const cardName = makeComputed('name', input => kebabcase(input))
|
||||
const cardName = makeComputed('name')
|
||||
const cardNickname = makeComputed('nickname')
|
||||
const cardDescription = makeComputed('description')
|
||||
const cardNotes = makeComputed('notes')
|
||||
@@ -591,15 +522,15 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string {
|
||||
<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="cardDescription" :label="t('settings.pages.card.creation.description')" :single-line="false" :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')" />
|
||||
<FieldInput v-model="cardPersonality" :label="t('settings.pages.card.personality')" :single-line="false" :description="t('settings.pages.card.creation.fields_info.personality')" />
|
||||
<FieldInput v-model="cardScenario" :label="t('settings.pages.card.scenario')" :single-line="false" :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>
|
||||
@@ -730,8 +661,8 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string {
|
||||
<!-- 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="cardSystemPrompt" :label="t('settings.pages.card.systemprompt')" :single-line="false" :description="t('settings.pages.card.creation.fields_info.systemprompt')" />
|
||||
<FieldInput v-model="cardPostHistoryInstructions" :label="t('settings.pages.card.posthistoryinstructions')" :single-line="false" :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>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Card } from '@proj-airi/ccc'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { safeParseAiriCardDraft } from './airi-card-editor'
|
||||
|
||||
describe('airi card editor validation', () => {
|
||||
// https://github.com/moeru-ai/airi/issues/2108
|
||||
it('preserves display text and accepts optional empty fields for Issue #2108', () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// The creation dialog normalized every input event and treated optional
|
||||
// CCv3 text fields as required. Multi-word names were rewritten while
|
||||
// otherwise valid minimal cards could not be saved.
|
||||
//
|
||||
// We fixed this by preserving draft input and normalizing only required
|
||||
// boundary fields through the shared Valibot schema.
|
||||
const result = safeParseAiriCardDraft({
|
||||
...createCard(),
|
||||
name: ' ReLU Chan ',
|
||||
description: '',
|
||||
personality: '',
|
||||
scenario: '',
|
||||
systemPrompt: '',
|
||||
postHistoryInstructions: '',
|
||||
}, '{ "steps": 12 }')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success)
|
||||
return
|
||||
|
||||
expect(result.output.card.name).toBe('ReLU Chan')
|
||||
expect(result.output.card.description).toBe('')
|
||||
expect(result.output.card.personality).toBe('')
|
||||
expect(result.output.card.scenario).toBe('')
|
||||
expect(result.output.card.systemPrompt).toBe('')
|
||||
expect(result.output.card.postHistoryInstructions).toBe('')
|
||||
expect(result.output.artistryOptions).toEqual({ steps: 12 })
|
||||
})
|
||||
|
||||
// https://github.com/moeru-ai/airi/issues/2108
|
||||
it('rejects invalid required fields and Artistry JSON for Issue #2108', () => {
|
||||
expect(safeParseAiriCardDraft({ ...createCard(), name: ' ' }, '{}')).toEqual({
|
||||
success: false,
|
||||
error: 'name',
|
||||
})
|
||||
expect(safeParseAiriCardDraft({ ...createCard(), version: 'v1' }, '{}')).toEqual({
|
||||
success: false,
|
||||
error: 'version',
|
||||
})
|
||||
expect(safeParseAiriCardDraft(createCard(), '[]')).toEqual({
|
||||
success: false,
|
||||
error: 'invalid_artistry_json',
|
||||
})
|
||||
expect(safeParseAiriCardDraft(createCard(), '{')).toEqual({
|
||||
success: false,
|
||||
error: 'invalid_artistry_json',
|
||||
})
|
||||
})
|
||||
|
||||
it('treats blank Artistry options as absent', () => {
|
||||
const result = safeParseAiriCardDraft(createCard(), ' ')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success)
|
||||
return
|
||||
|
||||
expect(result.output.artistryOptions).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
function createCard(): Card {
|
||||
return {
|
||||
name: 'ReLU',
|
||||
version: '1.0',
|
||||
greetings: [],
|
||||
messageExample: [],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Card } from '@proj-airi/ccc'
|
||||
|
||||
import {
|
||||
check,
|
||||
nonEmpty,
|
||||
object,
|
||||
objectWithRest,
|
||||
parseJson,
|
||||
pipe,
|
||||
regex,
|
||||
safeParse,
|
||||
string,
|
||||
trim,
|
||||
unknown,
|
||||
} from 'valibot'
|
||||
|
||||
export type AiriCardDraftValidationError = 'name' | 'version' | 'invalid_artistry_json'
|
||||
|
||||
export type AiriCardDraftValidationResult
|
||||
= | {
|
||||
success: true
|
||||
output: {
|
||||
card: Card
|
||||
artistryOptions: Record<string, unknown> | undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
success: false
|
||||
error: AiriCardDraftValidationError
|
||||
}
|
||||
|
||||
const cardDraftSchema = object({
|
||||
name: pipe(string(), trim(), nonEmpty()),
|
||||
version: pipe(string(), trim(), regex(/^(?:\d+\.)+\d+$/)),
|
||||
})
|
||||
|
||||
const artistryOptionsSchema = pipe(
|
||||
string(),
|
||||
trim(),
|
||||
parseJson(),
|
||||
check(isRecord),
|
||||
objectWithRest({}, unknown()),
|
||||
)
|
||||
|
||||
/**
|
||||
* Validates and normalizes the fields owned by the AIRI Card editor.
|
||||
*
|
||||
* Display text remains untouched except for boundary whitespace on the
|
||||
* required name and version fields. Artistry options must be a JSON object
|
||||
* when present.
|
||||
*/
|
||||
export function safeParseAiriCardDraft(card: Card, artistryOptionsJson: string): AiriCardDraftValidationResult {
|
||||
const cardResult = safeParse(cardDraftSchema, card)
|
||||
if (!cardResult.success) {
|
||||
const invalidField = cardResult.issues[0]?.path?.[0]?.key
|
||||
return {
|
||||
success: false,
|
||||
error: invalidField === 'version' ? 'version' : 'name',
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedArtistryOptions = artistryOptionsJson.trim()
|
||||
if (!normalizedArtistryOptions) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
card: { ...card, ...cardResult.output },
|
||||
artistryOptions: undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const artistryResult = safeParse(artistryOptionsSchema, normalizedArtistryOptions)
|
||||
if (!artistryResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'invalid_artistry_json',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
card: { ...card, ...cardResult.output },
|
||||
artistryOptions: artistryResult.output,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
Generated
-40
@@ -411,9 +411,6 @@ catalogs:
|
||||
'@snazzah/davey':
|
||||
specifier: ^0.1.11
|
||||
version: 0.1.11
|
||||
'@stdlib/string-base-kebabcase':
|
||||
specifier: ^0.2.3
|
||||
version: 0.2.3
|
||||
'@takumi-rs/image-response':
|
||||
specifier: 1.0.0-beta.20
|
||||
version: 1.0.0-beta.20
|
||||
@@ -3888,9 +3885,6 @@ importers:
|
||||
'@shopify/draggable':
|
||||
specifier: 'catalog:'
|
||||
version: 1.2.1
|
||||
'@stdlib/string-base-kebabcase':
|
||||
specifier: 'catalog:'
|
||||
version: 0.2.3
|
||||
'@vueuse/core':
|
||||
specifier: 'catalog:'
|
||||
version: 14.2.1(vue@3.5.32(typescript@5.9.3))
|
||||
@@ -10396,26 +10390,6 @@ packages:
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@stdlib/string-base-kebabcase@0.2.3':
|
||||
resolution: {integrity: sha512-pHCAnDl4z1D0rqgx/29M2gwk3kK6Uy76bg2hNw8hoI2kypGNb1AOOf1WeIYVH8T9A0i/oZOrG7JWvyXKNdTndA==}
|
||||
engines: {node: '>=0.10.0', npm: '>2.7.0'}
|
||||
os: [aix, darwin, freebsd, linux, macos, openbsd, sunos, win32, windows]
|
||||
|
||||
'@stdlib/string-base-lowercase@0.4.0':
|
||||
resolution: {integrity: sha512-IH35Z5e4T+S3b3SfYY39mUhrD2qvJVp4VS7Rn3+jgj4+C3syocuAPsJ8C4OQXWGfblX/N9ymizbpFBCiVvMW8w==}
|
||||
engines: {node: '>=0.10.0', npm: '>2.7.0'}
|
||||
os: [aix, darwin, freebsd, linux, macos, openbsd, sunos, win32, windows]
|
||||
|
||||
'@stdlib/string-base-replace@0.2.2':
|
||||
resolution: {integrity: sha512-Y4jZwRV4Uertw7AlA/lwaYl1HjTefSriN5+ztRcQQyDYmoVN3gzoVKLJ123HPiggZ89vROfC+sk/6AKvly+0CA==}
|
||||
engines: {node: '>=0.10.0', npm: '>2.7.0'}
|
||||
os: [aix, darwin, freebsd, linux, macos, openbsd, sunos, win32, windows]
|
||||
|
||||
'@stdlib/string-base-trim@0.2.3':
|
||||
resolution: {integrity: sha512-Fd2KoPvQfLy2Sk37hB6A+kLKcCcM4LvDS0LEOiO5V455wAwOEDNaQNw+G3T/7V/j9h6UXfY7VUJNXtE4IZddiA==}
|
||||
engines: {node: '>=0.10.0', npm: '>2.7.0'}
|
||||
os: [aix, darwin, freebsd, linux, macos, openbsd, sunos, win32, windows]
|
||||
|
||||
'@stylistic/eslint-plugin@5.10.0':
|
||||
resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -24484,20 +24458,6 @@ snapshots:
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@stdlib/string-base-kebabcase@0.2.3':
|
||||
dependencies:
|
||||
'@stdlib/string-base-lowercase': 0.4.0
|
||||
'@stdlib/string-base-replace': 0.2.2
|
||||
'@stdlib/string-base-trim': 0.2.3
|
||||
|
||||
'@stdlib/string-base-lowercase@0.4.0': {}
|
||||
|
||||
'@stdlib/string-base-replace@0.2.2': {}
|
||||
|
||||
'@stdlib/string-base-trim@0.2.3':
|
||||
dependencies:
|
||||
'@stdlib/string-base-replace': 0.2.2
|
||||
|
||||
'@stylistic/eslint-plugin@5.10.0(eslint@10.2.1(jiti@2.6.1))':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1))
|
||||
|
||||
@@ -166,7 +166,6 @@ catalog:
|
||||
'@shikijs/rehype': ^4.0.2
|
||||
'@shopify/draggable': ^1.2.1
|
||||
'@snazzah/davey': ^0.1.11
|
||||
'@stdlib/string-base-kebabcase': ^0.2.3
|
||||
'@takumi-rs/image-response': 1.0.0-beta.20
|
||||
'@tresjs/cientos': ^5.7.0
|
||||
'@tresjs/core': ^5.8.0
|
||||
|
||||
Reference in New Issue
Block a user