From f414d165de85d19f929f33ed10d731e2358a54a1 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Sun, 26 Jul 2026 20:50:19 +0800 Subject: [PATCH] fix(stage-ui): preserve character card runtime data (#2111) --- .../components/CardCreationDialog.vue | 62 +++++------ .../src/services/airi-card-editor.test.ts | 105 +++++++++++++++++- .../stage-ui/src/services/airi-card-editor.ts | 72 ++++++++++++ .../services/airi-card-import-export.test.ts | 30 ++++- .../src/services/airi-card-import-export.ts | 27 +++-- .../src/stores/modules/airi-card.test.ts | 43 +++++++ .../stage-ui/src/stores/modules/airi-card.ts | 95 +++------------- packages/stage-ui/src/types/airiCard.ts | 75 +++++++++++++ packages/stage-ui/src/types/index.ts | 1 + 9 files changed, 386 insertions(+), 124 deletions(-) create mode 100644 packages/stage-ui/src/types/airiCard.ts 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 918c53066..b06fa67b3 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 @@ -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 diff --git a/packages/stage-ui/src/services/airi-card-editor.test.ts b/packages/stage-ui/src/services/airi-card-editor.test.ts index 0e5a7a096..d9272678e 100644 --- a/packages/stage-ui/src/services/airi-card-editor.test.ts +++ b/packages/stage-ui/src/services/airi-card-editor.test.ts @@ -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 { diff --git a/packages/stage-ui/src/services/airi-card-editor.ts b/packages/stage-ui/src/services/airi-card-editor.ts index 072c05030..d1ad1b891 100644 --- a/packages/stage-ui/src/services/airi-card-editor.ts +++ b/packages/stage-ui/src/services/airi-card-editor.ts @@ -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 + displayModelId?: string + artistry: Pick< + NonNullable, + | 'provider' + | 'model' + | 'promptPrefix' + | 'widgetInstruction' + | 'spawnMode' + | 'options' + | 'autonomousEnabled' + | 'autonomousThreshold' + > +} + +type CardWithAiriExtension = Card & { + extensions: NonNullable & { + 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 { return typeof value === 'object' && value !== null && !Array.isArray(value) } diff --git a/packages/stage-ui/src/services/airi-card-import-export.test.ts b/packages/stage-ui/src/services/airi-card-import-export.test.ts index 6716de86c..6d37d2599 100644 --- a/packages/stage-ui/src/services/airi-card-import-export.test.ts +++ b/packages/stage-ui/src/services/airi-card-import-export.test.ts @@ -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() diff --git a/packages/stage-ui/src/services/airi-card-import-export.ts b/packages/stage-ui/src/services/airi-card-import-export.ts index 421656ad3..237281734 100644 --- a/packages/stage-ui/src/services/airi-card-import-export.ts +++ b/packages/stage-ui/src/services/airi-card-import-export.ts @@ -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> = { } type DisplayModelsStore = ReturnType -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 { 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 { 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(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, diff --git a/packages/stage-ui/src/stores/modules/airi-card.test.ts b/packages/stage-ui/src/stores/modules/airi-card.test.ts index 9f68f9546..ea0e1f4e5 100644 --- a/packages/stage-ui/src/stores/modules/airi-card.test.ts +++ b/packages/stage-ui/src/stores/modules/airi-card.test.ts @@ -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() diff --git a/packages/stage-ui/src/stores/modules/airi-card.ts b/packages/stage-ui/src/stores/modules/airi-card.ts index 878d73cab..617cc87b5 100644 --- a/packages/stage-ui/src/stores/modules/airi-card.ts +++ b/packages/stage-ui/src/stores/modules/airi-card.ts @@ -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-') - 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 - 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)), } }) diff --git a/packages/stage-ui/src/types/airiCard.ts b/packages/stage-ui/src/types/airiCard.ts new file mode 100644 index 000000000..b188dd20d --- /dev/null +++ b/packages/stage-ui/src/types/airiCard.ts @@ -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 + autonomousEnabled?: boolean + autonomousThreshold?: number + autonomousTarget?: 'user' | 'assistant' + } + } + + agents: Record +} + +/** Character card normalized with the AIRI extension required by the runtime. */ +export interface AiriCard extends Card { + extensions: { + airi: AiriExtension + } & Card['extensions'] +} diff --git a/packages/stage-ui/src/types/index.ts b/packages/stage-ui/src/types/index.ts index b3232d840..ee3a59c7c 100644 --- a/packages/stage-ui/src/types/index.ts +++ b/packages/stage-ui/src/types/index.ts @@ -1 +1,2 @@ +export type * from './airiCard' export * from './chat'