mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 00:48:06 +00:00
feat(ccc): add forward-compatible CCv3 codec (#2113)
Signed-off-by: RainbowBird <git@luoling.moe>
This commit is contained in:
+47
-1
@@ -1,6 +1,52 @@
|
||||
# @proj-airi/ccc
|
||||
|
||||
Create Character Card in a modular way.
|
||||
Character Card protocol primitives for AIRI.
|
||||
|
||||
## What it owns
|
||||
|
||||
- CCv3 JSON envelope validation and compatibility classification.
|
||||
- Forward-compatible preservation of unknown fields during validation.
|
||||
- Shared CCv3 TypeScript contracts.
|
||||
- Character Card JSON, Markdown, PNG, and APNG export helpers.
|
||||
|
||||
The package is intentionally runtime-agnostic. It does not own AIRI module
|
||||
settings, local persistence, chat message assembly, or editor behavior.
|
||||
|
||||
## Parse a CCv3 document
|
||||
|
||||
```ts
|
||||
import { parseCharacterCardV3 } from '@proj-airi/ccc'
|
||||
|
||||
const { card, compatibility } = parseCharacterCardV3(jsonText)
|
||||
|
||||
const characterName = card.data.name
|
||||
const versionSupport = compatibility // 'older' | 'current' | 'newer'
|
||||
```
|
||||
|
||||
The parser accepts either decoded JSON data or JSON text. Older and newer
|
||||
`spec_version` values remain importable, while `compatibility` lets the caller
|
||||
decide whether to warn the user. Unknown keys are preserved on the validated
|
||||
output so future CCv3 fields are not silently deleted.
|
||||
|
||||
Malformed JSON and invalid CCv3 structures throw
|
||||
`InvalidCharacterCardError`. Use `isInvalidCharacterCardError` when a boundary
|
||||
needs to distinguish protocol failures from storage or filesystem errors.
|
||||
|
||||
## When to use it
|
||||
|
||||
- Importing or exporting community Character Cards.
|
||||
- Validating a CCv3 envelope before converting it into an application model.
|
||||
- Building format adapters such as PNG, APNG, or CHARX.
|
||||
|
||||
## When not to use it
|
||||
|
||||
- Persisting AIRI-specific active-card state.
|
||||
- Applying speech, vision, body-model, or agent configuration.
|
||||
- Constructing provider messages from prompts, greetings, examples, or a
|
||||
Lorebook.
|
||||
|
||||
Those policies belong to AIRI's character runtime rather than the community
|
||||
protocol codec.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix ."
|
||||
"lint:fix": "eslint --fix .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"meta-png": "catalog:"
|
||||
"meta-png": "catalog:",
|
||||
"valibot": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { InferOutput } from 'valibot'
|
||||
|
||||
import {
|
||||
array,
|
||||
boolean,
|
||||
literal,
|
||||
number,
|
||||
objectWithRest,
|
||||
optional,
|
||||
picklist,
|
||||
pipe,
|
||||
record,
|
||||
regex,
|
||||
safeParse,
|
||||
string,
|
||||
union,
|
||||
unknown,
|
||||
} from 'valibot'
|
||||
|
||||
const currentSpecVersion = 3
|
||||
const invalidCharacterCardMessage = 'Invalid Character Card V3.'
|
||||
|
||||
const extensionsDepthPromptSchema = objectWithRest({
|
||||
depth: number(),
|
||||
prompt: string(),
|
||||
role: string(),
|
||||
}, unknown())
|
||||
|
||||
const extensionsSchema = objectWithRest({
|
||||
depth_prompt: optional(extensionsDepthPromptSchema),
|
||||
fav: optional(boolean()),
|
||||
talkativeness: optional(number()),
|
||||
world: optional(string()),
|
||||
}, unknown())
|
||||
|
||||
const openExtensionsSchema = objectWithRest({}, unknown())
|
||||
|
||||
const assetSchema = objectWithRest({
|
||||
type: string(),
|
||||
uri: string(),
|
||||
name: string(),
|
||||
ext: string(),
|
||||
}, unknown())
|
||||
|
||||
const characterBookEntrySchema = objectWithRest({
|
||||
keys: array(string()),
|
||||
content: string(),
|
||||
extensions: openExtensionsSchema,
|
||||
enabled: boolean(),
|
||||
insertion_order: number(),
|
||||
use_regex: boolean(),
|
||||
case_sensitive: optional(boolean()),
|
||||
constant: optional(boolean()),
|
||||
id: optional(union([number(), string()])),
|
||||
name: optional(string()),
|
||||
comment: optional(string()),
|
||||
priority: optional(number()),
|
||||
selective: optional(boolean()),
|
||||
secondary_keys: optional(array(string())),
|
||||
position: optional(picklist(['before_char', 'after_char'])),
|
||||
}, unknown())
|
||||
|
||||
const characterBookSchema = objectWithRest({
|
||||
name: optional(string()),
|
||||
description: optional(string()),
|
||||
scan_depth: optional(number()),
|
||||
token_budget: optional(number()),
|
||||
recursive_scanning: optional(boolean()),
|
||||
extensions: openExtensionsSchema,
|
||||
entries: array(characterBookEntrySchema),
|
||||
}, unknown())
|
||||
|
||||
const characterCardDataSchema = objectWithRest({
|
||||
name: string(),
|
||||
description: string(),
|
||||
personality: string(),
|
||||
scenario: string(),
|
||||
first_mes: string(),
|
||||
mes_example: string(),
|
||||
alternate_greetings: array(string()),
|
||||
character_book: optional(characterBookSchema),
|
||||
character_version: string(),
|
||||
creator: string(),
|
||||
creator_notes: string(),
|
||||
extensions: extensionsSchema,
|
||||
post_history_instructions: string(),
|
||||
system_prompt: string(),
|
||||
tags: array(string()),
|
||||
assets: optional(array(assetSchema)),
|
||||
creation_date: optional(number()),
|
||||
creator_notes_multilingual: optional(record(string(), string())),
|
||||
group_only_greetings: array(string()),
|
||||
modification_date: optional(number()),
|
||||
nickname: optional(string()),
|
||||
source: optional(array(string())),
|
||||
}, unknown())
|
||||
|
||||
const characterCardV3Schema = objectWithRest({
|
||||
spec: literal('chara_card_v3'),
|
||||
spec_version: pipe(string(), regex(/^\d+(?:\.\d+)*$/)),
|
||||
data: characterCardDataSchema,
|
||||
}, unknown())
|
||||
|
||||
/** One asset reference declared by a CCv3 document. */
|
||||
export type Asset = InferOutput<typeof assetSchema>
|
||||
/** Asset references declared by a CCv3 document. */
|
||||
export type Assets = Asset[]
|
||||
/** Character-specific Lorebook data. */
|
||||
export type CharacterBook = InferOutput<typeof characterBookSchema>
|
||||
/** One independently matched and ordered Lorebook entry. */
|
||||
export type CharacterBookEntry = InferOutput<typeof characterBookEntrySchema>
|
||||
/** Application-defined Lorebook metadata. */
|
||||
export type CharacterBookExtensions = InferOutput<typeof openExtensionsSchema>
|
||||
/** Application-defined Lorebook entry metadata. */
|
||||
export type CharacterBookEntryExtensions = InferOutput<typeof openExtensionsSchema>
|
||||
/** Complete data object carried by a CCv3 envelope. */
|
||||
export type Data = InferOutput<typeof characterCardDataSchema>
|
||||
/** Fields inherited from Character Card V1. */
|
||||
export type DataV1 = Pick<Data, 'description' | 'first_mes' | 'mes_example' | 'name' | 'personality' | 'scenario'>
|
||||
/** Fields inherited from Character Card V2. */
|
||||
export type DataV2 = Pick<Data, 'alternate_greetings' | 'character_book' | 'character_version' | 'creator' | 'creator_notes' | 'extensions' | 'post_history_instructions' | 'system_prompt' | 'tags'>
|
||||
/** Fields introduced or changed by Character Card V3. */
|
||||
export type DataV3 = Pick<Data, 'assets' | 'creation_date' | 'creator_notes_multilingual' | 'group_only_greetings' | 'modification_date' | 'nickname' | 'source'>
|
||||
/** Community extension fields carried by a character card. */
|
||||
export type Extensions = InferOutput<typeof extensionsSchema>
|
||||
/** Standard `depth_prompt` extension value. */
|
||||
export type ExtensionsDepthPrompt = InferOutput<typeof extensionsDepthPromptSchema>
|
||||
/** A Character Card document using the CCv3 envelope and data contract. */
|
||||
export type CharacterCardV3 = InferOutput<typeof characterCardV3Schema>
|
||||
|
||||
/** Compatibility of a parsed document relative to AIRI's current CCv3 implementation. */
|
||||
export type CharacterCardCompatibility = 'older' | 'current' | 'newer'
|
||||
|
||||
/** A validated CCv3 document together with its compatibility classification. */
|
||||
export interface ParsedCharacterCardV3 {
|
||||
/** Validated card data. Unknown future fields remain attached to their owning objects. */
|
||||
card: CharacterCardV3
|
||||
/** Whether the source version is older than, equal to, or newer than CCv3 3.0. */
|
||||
compatibility: CharacterCardCompatibility
|
||||
}
|
||||
|
||||
/** Options retained on a CCv3 validation failure. */
|
||||
export interface InvalidCharacterCardErrorOptions {
|
||||
/** Parser or schema issues that made the source unusable. */
|
||||
cause?: unknown
|
||||
/** Original JSON text or object supplied by the caller. */
|
||||
source: unknown
|
||||
}
|
||||
|
||||
/** Error thrown when input cannot be interpreted as a Character Card V3 document. */
|
||||
export class InvalidCharacterCardError extends Error {
|
||||
readonly source: unknown
|
||||
|
||||
constructor(options: InvalidCharacterCardErrorOptions) {
|
||||
super(invalidCharacterCardMessage, { cause: options.cause })
|
||||
this.name = 'InvalidCharacterCardError'
|
||||
this.source = options.source
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks whether an error came from the CCv3 parsing boundary. */
|
||||
export function isInvalidCharacterCardError(error: unknown): error is InvalidCharacterCardError {
|
||||
return error instanceof InvalidCharacterCardError
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and validates a Character Card V3 object or JSON document.
|
||||
*
|
||||
* Unknown fields are preserved so a newer card can be inspected and exported
|
||||
* without silently discarding data AIRI does not understand yet. Older and
|
||||
* newer `spec_version` values are accepted and reported through
|
||||
* `compatibility`; callers can decide how prominently to warn users.
|
||||
*/
|
||||
export function parseCharacterCardV3(source: unknown): ParsedCharacterCardV3 {
|
||||
const candidate = parseJsonSource(source)
|
||||
const result = safeParse(characterCardV3Schema, candidate)
|
||||
|
||||
if (!result.success) {
|
||||
throw new InvalidCharacterCardError({
|
||||
cause: result.issues,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
card: result.output,
|
||||
compatibility: resolveCompatibility(result.output.spec_version),
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonSource(source: unknown): unknown {
|
||||
if (typeof source !== 'string')
|
||||
return source
|
||||
|
||||
try {
|
||||
return JSON.parse(source)
|
||||
}
|
||||
catch (cause) {
|
||||
throw new InvalidCharacterCardError({ cause, source })
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCompatibility(specVersion: string): CharacterCardCompatibility {
|
||||
const sourceVersion = Number.parseFloat(specVersion)
|
||||
if (sourceVersion < currentSpecVersion)
|
||||
return 'older'
|
||||
if (sourceVersion > currentSpecVersion)
|
||||
return 'newer'
|
||||
return 'current'
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
InvalidCharacterCardError,
|
||||
isInvalidCharacterCardError,
|
||||
parseCharacterCardV3,
|
||||
} from './characterCardV3'
|
||||
|
||||
export type {
|
||||
CharacterCardCompatibility,
|
||||
InvalidCharacterCardErrorOptions,
|
||||
ParsedCharacterCardV3,
|
||||
} from './characterCardV3'
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Data } from '../export/types'
|
||||
import type { Data } from '../codec/characterCardV3'
|
||||
import type { Message } from './types/mes_example'
|
||||
|
||||
interface CardCore {
|
||||
@@ -31,6 +31,21 @@ interface CardMeta {
|
||||
}
|
||||
|
||||
interface CardAdditional {
|
||||
/**
|
||||
* Character assets such as icons, backgrounds, and emotions.
|
||||
* - assets
|
||||
*/
|
||||
assets?: Data['assets']
|
||||
/**
|
||||
* Character-specific Lorebook.
|
||||
* - character_book
|
||||
*/
|
||||
characterBook?: Data['character_book']
|
||||
/**
|
||||
* Creation time as a UTC Unix timestamp in seconds.
|
||||
* - creation_date
|
||||
*/
|
||||
creationDate?: Data['creation_date']
|
||||
/**
|
||||
* Extensions.
|
||||
* - extensions
|
||||
@@ -59,6 +74,16 @@ interface CardAdditional {
|
||||
* @see {@link https://github.com/kwaroran/character-card-spec-v3/blob/main/SPEC_V3.md#creator_notes_multilingual}
|
||||
*/
|
||||
notesMultilingual?: Data['creator_notes_multilingual']
|
||||
/**
|
||||
* Last modification time as a UTC Unix timestamp in seconds.
|
||||
* - modification_date
|
||||
*/
|
||||
modificationDate?: Data['modification_date']
|
||||
/**
|
||||
* IDs or URLs describing the card's provenance.
|
||||
* - source
|
||||
*/
|
||||
source?: Data['source']
|
||||
}
|
||||
|
||||
interface CardDescription {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type * as ccv3 from '../codec/characterCardV3'
|
||||
|
||||
export { exportToAPNG } from './apng'
|
||||
export { exportToJSON } from './json'
|
||||
export { exportToMD as exportToMarkdown, exportToMD } from './md'
|
||||
export { exportToPNG, exportToPNGBase64 } from './png'
|
||||
export type * as ccv3 from './types'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CharacterCardV3 } from '../codec/characterCardV3'
|
||||
import type { Card } from '../define'
|
||||
import type { CharacterCardV3 } from './types/character_card_v3'
|
||||
|
||||
/**
|
||||
* Exports a Card object to CharacterCardV3 format
|
||||
@@ -23,6 +23,9 @@ function createCardData(data: Card): CharacterCardV3['data'] {
|
||||
return {
|
||||
name: data.name,
|
||||
nickname: data.nickname,
|
||||
assets: data.assets,
|
||||
character_book: data.characterBook,
|
||||
creation_date: data.creationDate,
|
||||
description: data.description ?? '',
|
||||
personality: data.personality ?? '',
|
||||
scenario: data.scenario ?? '',
|
||||
@@ -36,6 +39,8 @@ function createCardData(data: Card): CharacterCardV3['data'] {
|
||||
system_prompt: data.systemPrompt ?? '',
|
||||
post_history_instructions: data.postHistoryInstructions ?? '',
|
||||
mes_example: formatMessageExample(data.messageExample),
|
||||
modification_date: data.modificationDate,
|
||||
source: data.source,
|
||||
tags: data.tags ?? [],
|
||||
extensions: createExtensions(data),
|
||||
}
|
||||
@@ -60,7 +65,7 @@ function formatMessageExample(messageExample: string[][] | undefined): string {
|
||||
* @param data Source card data
|
||||
* @returns Extensions object
|
||||
*/
|
||||
function createExtensions(data: Card): Record<string, any> {
|
||||
function createExtensions(data: Card): CharacterCardV3['data']['extensions'] {
|
||||
return {
|
||||
depth_prompt: {
|
||||
depth: 4,
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export type Assets = Asset[]
|
||||
|
||||
export interface Asset {
|
||||
ext: string
|
||||
name: string
|
||||
type: string
|
||||
uri: string
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
export interface CharacterBook {
|
||||
description?: string
|
||||
entries: CharacterBookEntry[]
|
||||
extensions: CharacterBookExtensions
|
||||
name?: string
|
||||
recursive_scanning?: boolean
|
||||
scan_depth?: number
|
||||
token_budget?: number
|
||||
}
|
||||
|
||||
export interface CharacterBookEntry {
|
||||
case_sensitive?: boolean
|
||||
/** not used in prompt engineering */
|
||||
comment?: string
|
||||
/** if true, always inserted in the prompt (within budget limit) */
|
||||
constant?: boolean
|
||||
content: string
|
||||
enabled: boolean
|
||||
extensions: CharacterBookEntryExtensions
|
||||
|
||||
// FIELDS WITH NO CURRENT EQUIVALENT IN SILLY
|
||||
/** not used in prompt engineering */
|
||||
id?: number
|
||||
/** if two entries inserted, lower "insertion order" = inserted higher */
|
||||
insertion_order: number
|
||||
|
||||
// FIELDS WITH NO CURRENT EQUIVALENT IN AGNAI
|
||||
keys: string[]
|
||||
/** not used in prompt engineering */
|
||||
name?: string
|
||||
/** whether the entry is placed before or after the character defs */
|
||||
position?: 'after_char' | 'before_char'
|
||||
/** if token budget reached, lower priority value = discarded first */
|
||||
priority?: number
|
||||
/** see field `selective`. ignored if selective == false */
|
||||
secondary_keys?: string[]
|
||||
/** if `true`, require a key from both `keys` and `secondary_keys` to trigger the entry */
|
||||
selective?: boolean
|
||||
}
|
||||
|
||||
export interface CharacterBookExtensions extends Record<string, unknown> {}
|
||||
|
||||
export interface CharacterBookEntryExtensions extends Record<string, unknown> {}
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { Data } from './data'
|
||||
|
||||
export interface CharacterCardV3 {
|
||||
data: Data
|
||||
spec: 'chara_card_v3'
|
||||
spec_version: '3.0'
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { Assets } from './assets'
|
||||
import type { CharacterBook } from './character_book'
|
||||
import type { Extensions } from './extensions'
|
||||
|
||||
/** @see {@link https://github.com/kwaroran/character-card-spec-v3/blob/main/SPEC_V3.md#charactercard-object} */
|
||||
export type Data = DataV1 & DataV2 & DataV3
|
||||
|
||||
/** @see {@link https://github.com/malfoyslastname/character-card-spec-v2/blob/main/spec_v1.md} */
|
||||
export interface DataV1 {
|
||||
description: string
|
||||
first_mes: string
|
||||
mes_example: string
|
||||
name: string
|
||||
personality: string
|
||||
scenario: string
|
||||
}
|
||||
|
||||
/** @see {@link https://github.com/malfoyslastname/character-card-spec-v2/blob/main/spec_v2.md} */
|
||||
export interface DataV2 {
|
||||
alternate_greetings: string[]
|
||||
character_book?: CharacterBook
|
||||
character_version: string
|
||||
creator: string
|
||||
creator_notes: string
|
||||
extensions: Extensions
|
||||
post_history_instructions: string
|
||||
system_prompt: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
/** @see {@link https://github.com/kwaroran/character-card-spec-v3/blob/main/SPEC_V3.md#charactercard-object} */
|
||||
export interface DataV3 {
|
||||
assets?: Assets
|
||||
creation_date?: number
|
||||
creator_notes_multilingual?: Record<string, string>
|
||||
group_only_greetings: string[]
|
||||
modification_date?: number
|
||||
nickname?: string
|
||||
source?: string[]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
export interface Extensions extends Record<string, unknown> {
|
||||
/**
|
||||
* @default
|
||||
* ```ts
|
||||
* {
|
||||
* depth: 4,
|
||||
* prompt: '',
|
||||
* role: 'system',
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
depth_prompt?: ExtensionsDepthPrompt
|
||||
/** @default `false` */
|
||||
fav?: boolean
|
||||
/** @default `0.5` */
|
||||
talkativeness?: number
|
||||
/** @default `undefined` */
|
||||
world?: string
|
||||
}
|
||||
|
||||
export interface ExtensionsDepthPrompt {
|
||||
depth: number
|
||||
prompt: string
|
||||
role: 'system' | ({} & string)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export type { Asset, Assets } from './assets'
|
||||
export type { CharacterBook, CharacterBookEntry, CharacterBookEntryExtensions, CharacterBookExtensions } from './character_book'
|
||||
export type { CharacterCardV3 } from './character_card_v3'
|
||||
export type { Data, DataV2, DataV3 } from './data'
|
||||
export type { Extensions, ExtensionsDepthPrompt } from './extensions'
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './codec'
|
||||
export * from './define'
|
||||
export * from './export'
|
||||
export * from './utils'
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
exportToJSON,
|
||||
InvalidCharacterCardError,
|
||||
isInvalidCharacterCardError,
|
||||
parseCharacterCardV3,
|
||||
} from '../src'
|
||||
|
||||
const completeCard = {
|
||||
spec: 'chara_card_v3',
|
||||
spec_version: '3.0',
|
||||
data: {
|
||||
name: 'ReLU',
|
||||
description: 'A curious AI.',
|
||||
personality: 'Warm and precise.',
|
||||
scenario: 'A quiet observatory.',
|
||||
first_mes: 'Welcome, {{user}}.',
|
||||
mes_example: '<START>\n{{user}}: Hello\n{{char}}: Hi!',
|
||||
alternate_greetings: ['Good evening.'],
|
||||
group_only_greetings: ['Hello, everyone.'],
|
||||
character_version: '1.0.0',
|
||||
creator: 'AIRI',
|
||||
creator_notes: 'Created for codec coverage.',
|
||||
creator_notes_multilingual: {
|
||||
ja: 'コーデックテスト用です。',
|
||||
},
|
||||
system_prompt: 'Stay in character.',
|
||||
post_history_instructions: 'Answer the latest message.',
|
||||
tags: ['assistant'],
|
||||
source: ['https://example.com/relu'],
|
||||
creation_date: 1_700_000_000,
|
||||
modification_date: 1_700_000_100,
|
||||
assets: [
|
||||
{
|
||||
type: 'icon',
|
||||
uri: 'ccdefault:',
|
||||
name: 'main',
|
||||
ext: 'png',
|
||||
future_asset_field: 'preserved',
|
||||
},
|
||||
],
|
||||
character_book: {
|
||||
name: 'Observatory',
|
||||
extensions: {
|
||||
vendor: 'airi',
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
keys: ['comet'],
|
||||
content: 'The comet returns every 72 years.',
|
||||
extensions: {},
|
||||
enabled: true,
|
||||
insertion_order: 10,
|
||||
use_regex: false,
|
||||
id: 'comet-entry',
|
||||
future_entry_field: true,
|
||||
},
|
||||
],
|
||||
future_book_field: 42,
|
||||
},
|
||||
extensions: {
|
||||
airi: {
|
||||
modules: {},
|
||||
},
|
||||
},
|
||||
future_data_field: {
|
||||
keep: true,
|
||||
},
|
||||
},
|
||||
future_envelope_field: 'preserved',
|
||||
} as const
|
||||
|
||||
describe('character card V3 codec', () => {
|
||||
it('parses the complete CCv3 contract without discarding future fields', () => {
|
||||
const result = parseCharacterCardV3(completeCard)
|
||||
|
||||
expect(result.compatibility).toBe('current')
|
||||
expect(result.card).toEqual(completeCard)
|
||||
expect(result.card.data.character_book?.entries[0]?.id).toBe('comet-entry')
|
||||
expect(result.card.data.character_book?.entries[0]?.use_regex).toBe(false)
|
||||
})
|
||||
|
||||
it('parses JSON text through the same validation boundary', () => {
|
||||
const result = parseCharacterCardV3(JSON.stringify(completeCard))
|
||||
|
||||
expect(result.card.data.name).toBe('ReLU')
|
||||
expect(result.compatibility).toBe('current')
|
||||
})
|
||||
|
||||
it('accepts compatible newer documents and reports their compatibility', () => {
|
||||
const result = parseCharacterCardV3({
|
||||
...completeCard,
|
||||
spec_version: '4.0',
|
||||
})
|
||||
|
||||
expect(result.card.spec_version).toBe('4.0')
|
||||
expect(result.compatibility).toBe('newer')
|
||||
})
|
||||
|
||||
it('accepts older documents with a V3 envelope and reports their compatibility', () => {
|
||||
const result = parseCharacterCardV3({
|
||||
...completeCard,
|
||||
spec_version: '2.0',
|
||||
})
|
||||
|
||||
expect(result.compatibility).toBe('older')
|
||||
})
|
||||
|
||||
it('exports every standard CCv3 field represented by the domain card', () => {
|
||||
const exported = exportToJSON({
|
||||
name: completeCard.data.name,
|
||||
nickname: 'Re',
|
||||
version: completeCard.data.character_version,
|
||||
assets: [...completeCard.data.assets],
|
||||
characterBook: completeCard.data.character_book,
|
||||
creationDate: completeCard.data.creation_date,
|
||||
description: completeCard.data.description,
|
||||
greetings: [
|
||||
completeCard.data.first_mes,
|
||||
...completeCard.data.alternate_greetings,
|
||||
],
|
||||
greetingsGroupOnly: [...completeCard.data.group_only_greetings],
|
||||
modificationDate: completeCard.data.modification_date,
|
||||
notes: completeCard.data.creator_notes,
|
||||
notesMultilingual: completeCard.data.creator_notes_multilingual,
|
||||
personality: completeCard.data.personality,
|
||||
postHistoryInstructions: completeCard.data.post_history_instructions,
|
||||
scenario: completeCard.data.scenario,
|
||||
source: [...completeCard.data.source],
|
||||
systemPrompt: completeCard.data.system_prompt,
|
||||
tags: [...completeCard.data.tags],
|
||||
extensions: completeCard.data.extensions,
|
||||
})
|
||||
|
||||
expect(exported.data.assets).toEqual(completeCard.data.assets)
|
||||
expect(exported.data.character_book).toEqual(completeCard.data.character_book)
|
||||
expect(exported.data.creation_date).toBe(completeCard.data.creation_date)
|
||||
expect(exported.data.modification_date).toBe(completeCard.data.modification_date)
|
||||
expect(exported.data.source).toEqual(completeCard.data.source)
|
||||
})
|
||||
|
||||
it('reports one domain error for malformed JSON or invalid cards', () => {
|
||||
for (const source of [
|
||||
'{',
|
||||
{
|
||||
...completeCard,
|
||||
spec: 'chara_card_v2',
|
||||
},
|
||||
{
|
||||
...completeCard,
|
||||
data: {
|
||||
...completeCard.data,
|
||||
character_book: {
|
||||
...completeCard.data.character_book,
|
||||
entries: [{
|
||||
...completeCard.data.character_book.entries[0],
|
||||
use_regex: undefined,
|
||||
}],
|
||||
},
|
||||
},
|
||||
},
|
||||
]) {
|
||||
expect(() => parseCharacterCardV3(source)).toThrow(InvalidCharacterCardError)
|
||||
|
||||
try {
|
||||
parseCharacterCardV3(source)
|
||||
}
|
||||
catch (error) {
|
||||
expect(isInvalidCharacterCardError(error)).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
Generated
+3
@@ -3302,6 +3302,9 @@ importers:
|
||||
meta-png:
|
||||
specifier: 'catalog:'
|
||||
version: 1.0.6
|
||||
valibot:
|
||||
specifier: 'catalog:'
|
||||
version: 1.3.1(typescript@5.9.3)
|
||||
|
||||
packages/core-agent:
|
||||
dependencies:
|
||||
|
||||
@@ -7,6 +7,7 @@ export default defineConfig({
|
||||
'apps/ui-server-auth',
|
||||
'apps/stage-tamagotchi',
|
||||
'packages/cap-vite',
|
||||
'packages/ccc',
|
||||
'packages/core-agent',
|
||||
'packages/better-ws',
|
||||
'packages/plugin-sdk',
|
||||
|
||||
Reference in New Issue
Block a user