diff --git a/packages/stage-pages/package.json b/packages/stage-pages/package.json index e70e036fd..f808eaa74 100644 --- a/packages/stage-pages/package.json +++ b/packages/stage-pages/package.json @@ -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:", diff --git a/packages/stage-pages/src/pages/settings/airi-card/components/CardCreationDialog.vue b/packages/stage-pages/src/pages/settings/airi-card/components/CardCreationDialog.vue index 92f58405e..1e54f1bcf 100644 --- a/packages/stage-pages/src/pages/settings/airi-card/components/CardCreationDialog.vue +++ b/packages/stage-pages/src/pages/settings/airi-card/components/CardCreationDialog.vue @@ -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(false) const errorMessage = ref('') 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 | 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(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( - /* - Function used to generate Computed values, with an optional sanitize function - */ - key: T, - transform?: (input: string) => string, -) { +function makeComputed(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 {
- +
- - + +
@@ -730,8 +661,8 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string {
- - + +
diff --git a/packages/stage-ui/src/services/airi-card-editor.test.ts b/packages/stage-ui/src/services/airi-card-editor.test.ts new file mode 100644 index 000000000..0e5a7a096 --- /dev/null +++ b/packages/stage-ui/src/services/airi-card-editor.test.ts @@ -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: [], + } +} diff --git a/packages/stage-ui/src/services/airi-card-editor.ts b/packages/stage-ui/src/services/airi-card-editor.ts new file mode 100644 index 000000000..072c05030 --- /dev/null +++ b/packages/stage-ui/src/services/airi-card-editor.ts @@ -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 | 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 { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 720e056ff..04750d729 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b6096b72a..dc43cf7f7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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