fix(stage-ui): preserve character card runtime data (#2111)

This commit is contained in:
RainbowBird
2026-07-26 20:50:19 +08:00
committed by GitHub
parent ceb87152f6
commit f414d165de
9 changed files with 386 additions and 124 deletions
@@ -5,7 +5,7 @@ import type { AiriExtension } from '@proj-airi/stage-ui/stores/modules/airi-card
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 { applyAiriCardEditorModules, 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'
@@ -315,42 +315,32 @@ function saveCard(card: Card, activate: boolean): boolean {
showError.value = false
const { card: rawCard, artistryOptions } = draftResult.output
// Build card with modules extension
const cardWithModules = {
...rawCard,
extensions: {
...rawCard.extensions,
airi: {
modules: {
consciousness: {
provider: selectedConsciousnessProvider.value || consciousnessProvider.value,
model: selectedConsciousnessModel.value || defaultConsciousnessModel.value,
},
vision: {
provider: selectedVisionProvider.value || visionProvider.value,
model: selectedVisionModel.value || defaultVisionModel.value,
},
speech: {
provider: selectedSpeechProvider.value || speechProvider.value,
model: selectedSpeechModel.value || defaultSpeechModel.value,
voice_id: selectedSpeechVoiceId.value || defaultSpeechVoiceId.value,
},
displayModelId: selectedDisplayModelId.value || defaultDisplayModelId.value,
artistry: {
provider: selectedArtistryProvider.value || defaultArtistryProvider.value,
model: selectedArtistryModel.value,
promptPrefix: selectedArtistryPromptPrefix.value,
widgetInstruction: selectedArtistryWidgetInstruction.value,
spawnMode: selectedArtistrySpawnMode.value,
options: artistryOptions,
autonomousEnabled: selectedArtistryAutonomousEnabled.value,
autonomousThreshold: selectedArtistryAutonomousThreshold.value,
},
},
agents: {},
} as AiriExtension,
const cardWithModules = applyAiriCardEditorModules(rawCard, {
consciousness: {
provider: selectedConsciousnessProvider.value || consciousnessProvider.value,
model: selectedConsciousnessModel.value || defaultConsciousnessModel.value,
},
}
vision: {
provider: selectedVisionProvider.value || visionProvider.value,
model: selectedVisionModel.value || defaultVisionModel.value,
},
speech: {
provider: selectedSpeechProvider.value || speechProvider.value,
model: selectedSpeechModel.value || defaultSpeechModel.value,
voice_id: selectedSpeechVoiceId.value || defaultSpeechVoiceId.value,
},
displayModelId: selectedDisplayModelId.value || defaultDisplayModelId.value,
artistry: {
provider: selectedArtistryProvider.value || defaultArtistryProvider.value,
model: selectedArtistryModel.value,
promptPrefix: selectedArtistryPromptPrefix.value,
widgetInstruction: selectedArtistryWidgetInstruction.value,
spawnMode: selectedArtistrySpawnMode.value,
options: artistryOptions,
autonomousEnabled: selectedArtistryAutonomousEnabled.value,
autonomousThreshold: selectedArtistryAutonomousThreshold.value,
},
})
let savedCardId: string
if (isEditMode.value && props.cardId) {
// Edit mode: update existing card
@@ -1,8 +1,10 @@
import type { Card } from '@proj-airi/ccc'
import type { AiriExtension } from '../types/airiCard'
import { describe, expect, it } from 'vitest'
import { safeParseAiriCardDraft } from './airi-card-editor'
import { applyAiriCardEditorModules, safeParseAiriCardDraft } from './airi-card-editor'
describe('airi card editor validation', () => {
// https://github.com/moeru-ai/airi/issues/2108
@@ -67,6 +69,107 @@ describe('airi card editor validation', () => {
expect(result.output.artistryOptions).toBeUndefined()
})
it('preserves AIRI extension fields that are not editable in the form', () => {
// ROOT CAUSE:
//
// Saving the editor rebuilt `extensions.airi.modules` from visible form
// controls and reset `agents` to an empty object. Hidden settings such as
// body models, backgrounds, advanced speech/artistry fields, and agent
// prompts were therefore lost after an otherwise unrelated edit.
//
// We fix this by applying the editor-owned fields as a structured patch
// over the existing extension.
const existing: AiriExtension = {
modules: {
consciousness: { provider: 'old-chat', model: 'old-chat-model' },
vision: { provider: 'old-vision', model: 'old-vision-model' },
speech: {
provider: 'old-speech',
model: 'old-speech-model',
voice_id: 'old-voice',
pitch: 1.2,
rate: 0.9,
ssml: true,
language: 'ja',
},
vrm: { source: 'url', url: 'https://example.com/avatar.vrm' },
live2d: { source: 'file', file: 'models/avatar.model3.json' },
displayModelId: 'old-display-model',
activeBackgroundId: 'background-1',
artistry: {
enabled: true,
provider: 'old-artistry',
model: 'old-artistry-model',
workflowId: 'workflow-1',
autonomousTarget: 'user',
},
},
agents: {
minecraft: { prompt: 'Keep building.', enabled: true },
},
}
const result = applyAiriCardEditorModules({
...createCard(),
extensions: {
thirdParty: { keep: true },
airi: {
...existing,
futureRootField: { keep: true },
},
},
}, {
consciousness: { provider: 'new-chat', model: 'new-chat-model' },
vision: { provider: 'new-vision', model: 'new-vision-model' },
speech: { provider: 'new-speech', model: 'new-speech-model', voice_id: 'new-voice' },
displayModelId: 'new-display-model',
artistry: {
provider: 'new-artistry',
model: 'new-artistry-model',
promptPrefix: 'portrait',
widgetInstruction: 'Use the image widget.',
spawnMode: 'widget',
options: { steps: 12 },
autonomousEnabled: true,
autonomousThreshold: 80,
},
})
const extension = result.extensions.airi
expect(result.extensions.thirdParty).toEqual({ keep: true })
expect(extension).toHaveProperty('futureRootField', { keep: true })
expect(extension.modules.consciousness).toEqual({ provider: 'new-chat', model: 'new-chat-model' })
expect(extension.modules.vision).toEqual({ provider: 'new-vision', model: 'new-vision-model' })
expect(extension.modules.speech).toEqual({
provider: 'new-speech',
model: 'new-speech-model',
voice_id: 'new-voice',
pitch: 1.2,
rate: 0.9,
ssml: true,
language: 'ja',
})
expect(extension.modules.vrm).toEqual(existing.modules.vrm)
expect(extension.modules.live2d).toEqual(existing.modules.live2d)
expect(extension.modules.displayModelId).toBe('new-display-model')
expect(extension.modules.activeBackgroundId).toBe('background-1')
expect(extension.modules.artistry).toEqual({
enabled: true,
provider: 'new-artistry',
model: 'new-artistry-model',
promptPrefix: 'portrait',
workflowId: 'workflow-1',
widgetInstruction: 'Use the image widget.',
spawnMode: 'widget',
options: { steps: 12 },
autonomousEnabled: true,
autonomousThreshold: 80,
autonomousTarget: 'user',
})
expect(extension.agents).toEqual(existing.agents)
})
})
function createCard(): Card {
@@ -1,5 +1,7 @@
import type { Card } from '@proj-airi/ccc'
import type { AiriExtension } from '../types/airiCard'
import {
check,
nonEmpty,
@@ -16,6 +18,31 @@ import {
export type AiriCardDraftValidationError = 'name' | 'version' | 'invalid_artistry_json'
/** Module settings owned by the AIRI Card editor form. */
interface AiriCardEditorModules {
consciousness: AiriExtension['modules']['consciousness']
vision: AiriExtension['modules']['vision']
speech: Pick<AiriExtension['modules']['speech'], 'provider' | 'model' | 'voice_id'>
displayModelId?: string
artistry: Pick<
NonNullable<AiriExtension['modules']['artistry']>,
| 'provider'
| 'model'
| 'promptPrefix'
| 'widgetInstruction'
| 'spawnMode'
| 'options'
| 'autonomousEnabled'
| 'autonomousThreshold'
>
}
type CardWithAiriExtension = Card & {
extensions: NonNullable<Card['extensions']> & {
airi: AiriExtension
}
}
export type AiriCardDraftValidationResult
= | {
success: true
@@ -87,6 +114,51 @@ export function safeParseAiriCardDraft(card: Card, artistryOptionsJson: string):
}
}
/**
* Applies editor-owned module fields to a complete character card.
*
* The returned card preserves extension fields the form cannot display,
* including body models, backgrounds, advanced speech/artistry settings, and
* agent configuration.
*/
export function applyAiriCardEditorModules(
card: Card,
edited: AiriCardEditorModules,
): CardWithAiriExtension {
const existing = isAiriExtension(card.extensions?.airi)
? card.extensions.airi
: undefined
return {
...card,
extensions: {
...card.extensions,
airi: {
...existing,
modules: {
...existing?.modules,
...edited,
speech: {
...existing?.modules.speech,
...edited.speech,
},
artistry: {
...existing?.modules.artistry,
...edited.artistry,
},
},
agents: existing?.agents ?? {},
},
},
}
}
function isAiriExtension(value: unknown): value is AiriExtension {
return isRecord(value)
&& isRecord(value.modules)
&& isRecord(value.agents)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -1,6 +1,6 @@
import type { ccv3 } from '@proj-airi/ccc'
import type { AiriCard, AiriExtension } from '../stores/modules/airi-card'
import type { AiriCard, AiriExtension } from '../types/airiCard'
import JSZip from 'jszip'
@@ -17,7 +17,7 @@ describe('airi card package import/export', () => {
vi.unstubAllGlobals()
})
it('exports sanitized packages and restores display models', async () => {
it('exports shareable fields, sanitizes runtime state, and restores display models', async () => {
const displayModelsStore = useDisplayModelsStore()
const fetch = vi.fn(async () => new Response('preset-vrm-model'))
vi.stubGlobal('fetch', fetch)
@@ -49,6 +49,32 @@ describe('airi card package import/export', () => {
expect(airiFrom(imported).modules.displayModelId).toBe('display-model-imported')
})
it('applies the share-field whitelist to externally edited package JSON', async () => {
const displayModelsStore = useDisplayModelsStore()
const source = exportToJSON(createCard('preset-live2d-1'))
source.data.extensions.third_party = { token: 'do-not-import' }
const imported = await importAiriCardPackage({
file: await packageFile(source),
displayModelsStore,
})
const airi = airiFrom(imported)
expect(imported.data).toMatchObject({
name: 'AIRI / Test Card',
nickname: 'Tester',
character_version: '1.2.3',
description: 'Description',
creator: '',
tags: [],
mes_example: '',
})
expect(imported.data.extensions).not.toHaveProperty('third_party')
expect(airi.modules).not.toHaveProperty('activeBackgroundId')
expect(airi.modules.artistry).not.toHaveProperty('workflowId')
expect(airi.agents).toEqual({})
})
it('classifies invalid packages', async () => {
const emptyZip = new JSZip()
const invalidJsonZip = new JSZip()
@@ -2,7 +2,7 @@ import type { Card, ccv3 } from '@proj-airi/ccc'
import type { GenericSchema, InferOutput } from 'valibot'
import type { DisplayModel, useDisplayModelsStore } from '../stores/display-models'
import type { AiriCard, AiriExtension } from '../stores/modules/airi-card'
import type { AiriCard, AiriExtension } from '../types/airiCard'
import JSZip from 'jszip'
@@ -22,7 +22,7 @@ const MODEL_EXT: Partial<Record<DisplayModelFormat, string>> = {
}
type DisplayModelsStore = ReturnType<typeof useDisplayModelsStore>
type ExportableCard = Card & { extensions: { airi: AiriExtension } }
type ShareableAiriCard = Card & { extensions: { airi: AiriExtension } }
const manifestSchema = object({
format: literal(FORMAT),
@@ -68,7 +68,14 @@ export class AiriCardPackageError extends Error {
}
}
/** Exports only the creation/edit form whitelist; provider globals and runtime state are never cloned. */
/**
* Creates a portable AIRI Card share package.
*
* This format is intentionally not a lossless backup. It includes fields the
* creation editor lets the sender review, a sanitized AIRI module subset, and
* the selected display model. Unreviewed CCv3 metadata, custom extensions,
* agent prompts, and machine-local runtime references are omitted.
*/
export async function exportAiriCardPackage({ card, displayModelsStore }: { card: AiriCard, displayModelsStore: DisplayModelsStore }): Promise<Blob> {
const exportableCard = cardFromAiriCard(card)
const displayModel = await exportDisplayModel(exportableCard, displayModelsStore)
@@ -89,7 +96,13 @@ export async function exportAiriCardPackage({ card, displayModelsStore }: { card
return zip.generateAsync({ type: 'blob' })
}
/** Imports a package as sanitized CCv3 JSON; edited zip payloads cannot smuggle extra AIRI fields through. */
/**
* Imports a portable package through the same share-field whitelist.
*
* The returned CCv3 object is safe to pass through the normal card creation
* path: package authors cannot smuggle custom extensions, agent prompts, or
* machine-local references into persisted AIRI state.
*/
export async function importAiriCardPackage({ file, displayModelsStore }: { file: File, displayModelsStore: DisplayModelsStore }): Promise<ccv3.CharacterCardV3> {
const zip = await loadZip(file)
const manifest = await readJsonFile(zip, MANIFEST_PATH, manifestSchema)
@@ -99,7 +112,7 @@ export async function importAiriCardPackage({ file, displayModelsStore }: { file
return exportToJSON(cardFromCharacterCard(cardJson, displayModelId))
}
async function exportDisplayModel(card: ExportableCard, store: DisplayModelsStore) {
async function exportDisplayModel(card: ShareableAiriCard, store: DisplayModelsStore) {
const displayModelId = card.extensions.airi.modules.displayModelId
if (!displayModelId)
return
@@ -167,7 +180,7 @@ async function readJsonFile<S extends GenericSchema>(zip: JSZip, path: string, s
}
}
function cardFromAiriCard(card: AiriCard): ExportableCard {
function cardFromAiriCard(card: AiriCard): ShareableAiriCard {
return {
name: card.name,
nickname: card.nickname,
@@ -183,7 +196,7 @@ function cardFromAiriCard(card: AiriCard): ExportableCard {
}
}
function cardFromCharacterCard(card: CharacterCardPackageJson, displayModelId?: string): ExportableCard {
function cardFromCharacterCard(card: CharacterCardPackageJson, displayModelId?: string): ShareableAiriCard {
const data = card.data
return {
name: data.name,
@@ -242,6 +242,49 @@ describe('airi-card store', () => {
})
})
it('keeps position-sensitive CCv3 fields separate from the stable system prompt', () => {
const cardStore = useAiriCardStore()
cardStore.initialize()
const cardId = cardStore.addCard({
name: 'Runtime context card',
version: '1.0.0',
systemPrompt: 'Follow the character rules.',
description: 'A patient field researcher.',
personality: 'Curious and precise.',
scenario: 'The conversation takes place in an observatory.',
postHistoryInstructions: 'Answer the latest observation in one paragraph.',
greetings: ['Welcome to the observatory.'],
messageExample: [
['{{user}}: What did you find?', '{{char}}: A new comet.'],
],
extensions: {
airi: {
modules: {
consciousness: { provider: 'mock-consciousness-provider', model: 'mock-consciousness-model' },
vision: { provider: 'mock-vision-provider', model: 'mock-vision-model' },
speech: { provider: 'mock-speech-provider', model: 'mock-speech-model', voice_id: 'mock-speech-voice' },
artistry: { widgetInstruction: 'Use the image widget for star charts.' },
},
agents: {},
},
},
}, 'scratch')
cardStore.activeCardId = cardId
expect(cardStore.systemPrompt).toBe([
'Follow the character rules.',
'A patient field researcher.',
'Curious and precise.',
'The conversation takes place in an observatory.',
'Use the image widget for star charts.',
].join('\n\n'))
expect(cardStore.systemPrompt).not.toContain('Answer the latest observation')
expect(cardStore.systemPrompt).not.toContain('Welcome to the observatory')
expect(cardStore.systemPrompt).not.toContain('What did you find?')
})
it('falls back to the default card when the active custom card is deleted', () => {
const cardStore = useAiriCardStore()
cardStore.initialize()
@@ -1,5 +1,7 @@
import type { Card, ccv3 } from '@proj-airi/ccc'
import type { AiriCard, AiriExtension } from '../../types/airiCard'
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
import { nanoid } from 'nanoid'
import { defineStore, storeToRefs } from 'pinia'
@@ -16,72 +18,23 @@ import { useConsciousnessStore } from './consciousness'
import { useSpeechStore } from './speech'
import { useVisionStore } from './vision'
export interface AiriExtension {
modules: {
consciousness: {
provider: string // Example: "openai"
model: string // Example: "gpt-4o"
}
export type { AiriCard, AiriExtension } from '../../types/airiCard'
vision: {
provider: string // Example: "ollama"
model: string // Example: "llava"
}
function resolveSystemPrompt(card: AiriCard | undefined): string {
if (!card)
return ''
speech: {
provider: string // Example: "elevenlabs"
model: string // Example: "eleven_multilingual_v2"
voice_id: string // Example: "alloy"
// Position-sensitive CCv3 fields are deliberately excluded until provider
// message assembly owns their ordering and role semantics.
const systemPromptParts = [
card.systemPrompt,
card.description,
card.personality,
card.scenario,
card.extensions.airi.modules.artistry?.widgetInstruction,
].filter((part): part is string => typeof part === 'string' && part.trim().length > 0)
pitch?: number
rate?: number
ssml?: boolean
language?: string
}
vrm?: {
source?: 'file' | 'url'
file?: string // Example: "vrm/model.vrm"
url?: string // Example: "https://example.com/vrm/model.vrm"
}
live2d?: {
source?: 'file' | 'url'
file?: string // Example: "live2d/model.json"
url?: string // Example: "https://example.com/live2d/model.json"
}
// ID from display-models store (e.g. 'preset-live2d-1', 'display-model-<nanoid>')
displayModelId?: string
activeBackgroundId?: string
artistry?: {
enabled?: boolean
provider?: string
model?: string
promptPrefix?: string
workflowId?: string
widgetInstruction?: string
spawnMode?: 'bg' | 'widget' | 'inline' | 'bg_widget'
options?: Record<string, any>
autonomousEnabled?: boolean
autonomousThreshold?: number
autonomousTarget?: 'user' | 'assistant'
}
}
agents: {
[key: string]: { // example: minecraft
prompt: string
enabled?: boolean
}
}
}
export interface AiriCard extends Card {
extensions: {
airi: AiriExtension
} & Card['extensions']
return systemPromptParts.join('\n\n')
}
export const useAiriCardStore = defineStore('airi-card', () => {
@@ -450,20 +403,6 @@ export const useAiriCardStore = defineStore('airi-card', () => {
activeBackgroundId: activeCard.value?.extensions?.airi?.modules?.activeBackgroundId,
} satisfies AiriExtension['modules']
}),
systemPrompt: computed(() => {
const card = activeCard.value
if (!card)
return ''
const components = [
card.systemPrompt,
card.description,
card.personality,
card.extensions?.airi?.modules?.artistry?.widgetInstruction,
].filter(Boolean)
return components.join('\n\n')
}),
systemPrompt: computed(() => resolveSystemPrompt(activeCard.value)),
}
})
+75
View File
@@ -0,0 +1,75 @@
import type { Card } from '@proj-airi/ccc'
/**
* AIRI-specific runtime configuration embedded in a character card.
*
* The extension is persisted with the card. Editor surfaces must preserve
* fields they do not own so independent runtime modules can evolve without
* losing each other's configuration.
*/
export interface AiriExtension {
modules: {
consciousness: {
provider: string
model: string
}
vision: {
provider: string
model: string
}
speech: {
provider: string
model: string
voice_id: string
pitch?: number
rate?: number
ssml?: boolean
language?: string
}
vrm?: {
source?: 'file' | 'url'
file?: string
url?: string
}
live2d?: {
source?: 'file' | 'url'
file?: string
url?: string
}
/** ID from the display-models store. */
displayModelId?: string
activeBackgroundId?: string
artistry?: {
enabled?: boolean
provider?: string
model?: string
promptPrefix?: string
workflowId?: string
widgetInstruction?: string
spawnMode?: 'bg' | 'widget' | 'inline' | 'bg_widget'
options?: Record<string, unknown>
autonomousEnabled?: boolean
autonomousThreshold?: number
autonomousTarget?: 'user' | 'assistant'
}
}
agents: Record<string, {
prompt: string
enabled?: boolean
}>
}
/** Character card normalized with the AIRI extension required by the runtime. */
export interface AiriCard extends Card {
extensions: {
airi: AiriExtension
} & Card['extensions']
}
+1
View File
@@ -1 +1,2 @@
export type * from './airiCard'
export * from './chat'