fix(stage-pages): validate and preserve AIRI Card drafts (#2109)

Signed-off-by: RainbowBird <git@luoling.moe>
This commit is contained in:
RainbowBird
2026-07-26 17:05:28 +08:00
committed by GitHub
parent d6fe72b50a
commit 019d3fa15f
6 changed files with 193 additions and 133 deletions
-1
View File
@@ -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:",
@@ -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)
}
-40
View File
@@ -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))
-1
View File
@@ -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