Refactor BYAF parsing (#4303) * Refactor BYAF parsing * Refactor ByafParser to use private field for data and improve type annotations

38a5e4a4f2a0068510124423f9e4c9913116e1f2

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
4 files changed, +257 -53Showing whitespace changes
src/byaf.js+124 -40
@@ -5,14 +5,44 @@ import urlJoin from 'url-join';
55import { DEFAULT_AVATAR_PATH } from './constants.js';
66import { extractFileFromZipBuffer, humanizedISO8601DateTime } from './util.js';
77
8-export const replaceByafMacros = (s) =>
8+/**
9- String(s || '')
9+ * A parser for BYAF (Backyard Archive Format) files.
10+ */
11+export class ByafParser {
12+ /**
13+ * @param {ArrayBufferLike} data BYAF ZIP buffer
14+ */
15+ #data;
16+
17+ /**
18+ * Creates an instance of ByafParser.
19+ * @param {ArrayBufferLike} data BYAF ZIP buffer
20+ */
21+ constructor(data) {
22+ this.#data = data;
23+ }
24+
25+ /**
26+ * Replaces known macros in a string.
27+ * @param {string} [str] String to process
28+ * @returns {string} String with macros replaced
29+ * @private
30+ */
31+ replaceMacros(str) {
32+ return String(str || '')
1033 .replace(/#{user}:/gi, '{{user}}:')
1134 .replace(/#{character}:/gi, '{{char}}:')
1235 .replace(/{character}(?!})/gi, '{{char}}')
1336 .replace(/{user}(?!})/gi, '{{user}}');
37+ }
1438
15-export const formatByafExampleMessages = (examples) => {
39+ /**
40+ * Formats example messages for a character.
41+ * @param {ByafExampleMessage[]} [examples] Array of example objects
42+ * @returns {string} Formatted example messages
43+ * @private
44+ */
45+ formatExampleMessages(examples) {
1646 if (!Array.isArray(examples)) {
1747 return '';
1848 }
@@ -23,13 +53,19 @@ export const formatByafExampleMessages = (examples) => {
2353 if (!example?.text) {
2454 return;
2555 }
2656 formattedExamples += `<START>\n${replaceByafMacrosthis.replaceMacros(example.text)}\n`;
2757 });
2858
2959 return formattedExamples.trimEnd();
3060 };
3161
32-export const formatByafAlternateGreetings = (greetings) => {
62+ /**
63+ * Formats alternate greetings for a character.
64+ * @param {ByafExampleMessage[]} [greetings] Array of greeting objects
65+ * @returns {string[]} Formatted alternate greetings
66+ * @private
67+ */
68+ formatAlternateGreetings(greetings) {
3369 if (!Array.isArray(greetings)) {
3470 return [];
3571 }
@@ -39,17 +75,24 @@ export const formatByafAlternateGreetings = (greetings) => {
3975 }
4076
4177 // Skip one because it goes into 'first_mes'
4278 return greetings.slice(1).map(g => replaceByafMacrosthis.replaceMacros(g?.text));
4379 };
4480
45-export const convertByafCharacterBook = (items) => {
81+ /**
82+ * Converts character book items to a structured format.
83+ * @param {ByafLoreItem[]} items Array of key-value pairs
84+ * @returns {CharacterBook|undefined} Converted character book or undefined if invalid
85+ * @private
86+ */
87+ convertCharacterBook(items) {
4688 if (!Array.isArray(items) || items.length === 0) {
4789 return nullundefined;
4890 }
4991
92+ /** @type {CharacterBook} */
5093 const book = {
51- /** @type {any[]} */
5294 entries: [],
95+ extensions: {},
5396 };
5497
5598 items.forEach((item, index) => {
@@ -57,8 +100,8 @@ export const convertByafCharacterBook = (items) => {
57100 return;
58101 }
59102 book.entries.push({
60103 keys: replaceByafMacrosthis.replaceMacros(item?.key).split(',').map(key => key.trim()).filter(Boolean),
61104 content: replaceByafMacrosthis.replaceMacros(item?.value),
62105 extensions: {},
63106 enabled: true,
64107 insertion_order: index,
@@ -66,15 +109,15 @@ export const convertByafCharacterBook = (items) => {
66109 });
67110
68111 return book;
69112 };
70113
71114 /**
72115 * Extracts a character object from BYAF buffer.
73116 * @param {ArrayBufferLikeByafManifest} datamanifest ZIPBYAF buffermanifest
74- * @param {object} manifest BYAF manifest
117+ * @returns {Promise<{character:ByafCharacter,characterPath:string}>} Character object
75- * @returns {Promise<{character:object,characterPath:string}>} Character object
118+ * @private
76119 */
77120export async function getCharacterFromByafManifestgetCharacterFromManifest(data, manifest) {
78121 const charactersArray = manifest?.characters;
79122
80123 if (!Array.isArray(charactersArray)) {
@@ -94,7 +137,7 @@ export async function getCharacterFromByafManifest(data, manifest) {
94137 throw new Error('Invalid BYAF file: missing character path');
95138 }
96139
97140 const characterBuffer = await extractFileFromZipBuffer(this.#data, characterPath);
98141 if (!characterBuffer) {
99142 throw new Error('Invalid BYAF file: failed to extract character JSON');
100143 }
@@ -110,11 +153,11 @@ export async function getCharacterFromByafManifest(data, manifest) {
110153
111154 /**
112155 * Extracts a scenario object from BYAF buffer.
113156 * @param {ArrayBufferLikeByafManifest} datamanifest ZIPBYAF buffermanifest
114- * @param {object} manifest BYAF manifest
157+ * @returns {Promise<Partial<ByafScenario>>} Scenario object
115- * @returns {Promise<object>} Scenario object
158+ * @private
116159 */
117160export async function getScenarioFromByafManifestgetScenarioFromManifest(data, manifest) {
118161 const scenariosArray = manifest?.scenarios;
119162
120163 if (!Array.isArray(scenariosArray) || scenariosArray.length === 0) {
@@ -132,7 +175,7 @@ export async function getScenarioFromByafManifest(data, manifest) {
132175 return {};
133176 }
134177
135178 const scenarioBuffer = await extractFileFromZipBuffer(this.#data, scenarioPath);
136179 if (!scenarioBuffer) {
137180 console.warn('Warning: failed to extract BYAF scenario JSON');
138181 return {};
@@ -148,11 +191,12 @@ export async function getScenarioFromByafManifest(data, manifest) {
148191
149192 /**
150193 * Extracts an image from BYAF buffer.
151194 * @param {ArrayBufferLikeByafCharacter} datacharacter ZIPCharacter bufferobject
152- * @param {object} character Character object
153195 * @param {string} characterPath Path to the character in the BYAF manifest
196+ * @return {Promise<Buffer>} Image buffer
197+ * @private
154198 */
155199export async function getImageBufferFromByafCharactergetCharacterImage(data, character, characterPath) {
156200 const defaultAvatarBuffer = await fsPromises.readFile(DEFAULT_AVATAR_PATH);
157201 const characterImages = character?.images;
158202
@@ -168,7 +212,7 @@ export async function getImageBufferFromByafCharacter(data, character, character
168212 }
169213
170214 const fullImagePath = urlJoin(path.dirname(characterPath), imagePath);
171215 const imageBuffer = await extractFileFromZipBuffer(this.#data, fullImagePath);
172216 if (!imageBuffer) {
173217 console.warn('Warning: failed to extract BYAF character image');
174218 return defaultAvatarBuffer;
@@ -179,30 +223,70 @@ export async function getImageBufferFromByafCharacter(data, character, character
179223
180224 /**
181225 * Formats BYAF data as a character card.
182226 * @param {objectByafManifest} charactermanifest BYAF manifest
183227 * @param {objectByafCharacter} scenariocharacter Character object
228+ * @param {Partial<ByafScenario>} scenario Scenario object
229+ * @return {TavernCardV2} Character card object
230+ * @private
184231 */
185232export function formatByafAsCharacterCard getCharacterCard(manifest, character, scenario) {
186233 return {
187234 spec: 'chara_card_v2',
188235 spec_version: '2.0',
189- create_date: humanizedISO8601DateTime(),
190236 data: {
191237 name: sanitize(character?.name || character?.displayName || ''),
192238 description: replaceByafMacrosthis.replaceMacros(character?.persona),
193239 personality: '',
194240 scenario: replaceByafMacrosthis.replaceMacros(scenario?.narrative),
195241 first_mes: replaceByafMacrosthis.replaceMacros(scenario?.firstMessages?.[0]?.text),
196242 mes_example: formatByafExampleMessagesthis.formatExampleMessages(scenario?.exampleMessages),
197243 creator_notes: '',
198244 system_prompt: replaceByafMacrosthis.replaceMacros(scenario?.formattingInstructions),
199245 post_history_instructions: '',
200246 alternate_greetings: formatByafAlternateGreetingsthis.formatAlternateGreetings(scenario?.firstMessages),
201247 character_book: convertByafCharacterBookthis.convertCharacterBook(character?.loreItems),
202248 tags: [],
203- creator: '',
249+ creator: manifest?.author?.name || '',
204250 character_version: '',
205251 extensions: {},
206252 },
253+ // @ts-ignore Non-standard spec extension
254+ create_date: humanizedISO8601DateTime(),
207255 };
208256 }
257+
258+ /**
259+ * Gets the manifest from the BYAF data.
260+ * @returns {Promise<ByafManifest>} Parsed manifest
261+ * @private
262+ */
263+ async getManifest() {
264+ const manifestBuffer = await extractFileFromZipBuffer(this.#data, 'manifest.json');
265+ if (!manifestBuffer) {
266+ throw new Error('Failed to extract manifest.json from BYAF file');
267+ }
268+
269+ const manifest = JSON.parse(manifestBuffer.toString());
270+ if (!manifest || typeof manifest !== 'object') {
271+ throw new Error('Invalid BYAF manifest');
272+ }
273+
274+ return manifest;
275+ }
276+
277+ /**
278+ * Parses the BYAF data.
279+ * @return {Promise<{card: TavernCardV2, image: Buffer}>} Parsed character card and image buffer
280+ */
281+ async parse() {
282+ const manifest = await this.getManifest();
283+ const { character, characterPath } = await this.getCharacterFromManifest(manifest);
284+ const scenario = await this.getScenarioFromManifest(manifest);
285+ const image = await this.getCharacterImage(character, characterPath);
286+ const card = this.getCharacterCard(manifest, character, scenario);
287+
288+ return { card, image };
289+ }
290+}
291+
292+export default ByafParser;
src/endpoints/characters.js+4 -13
@@ -22,7 +22,7 @@ import { invalidateThumbnail } from './thumbnails.js';
2222import { importRisuSprites } from './sprites.js';
2323import { getUserDirectories } from '../users.js';
2424import { getChatInfo } from './chats.js';
2525import { formatByafAsCharacterCard, getCharacterFromByafManifest, getImageBufferFromByafCharacter, getScenarioFromByafManifestByafParser } from '../byaf.js';
2626import cacheBuster from '../middleware/cacheBuster.js';
2727
2828// With 100 MB limit it would take roughly 3000 characters to reach this limit
@@ -803,19 +803,10 @@ async function importFromByaf(uploadPath, { request }, preservedFileName) {
803803 await fsPromises.unlink(uploadPath);
804804 console.info('Importing from BYAF');
805805
806806 const manifestBufferbyafData = await extractFileFromZipBuffernew ByafParser(data, 'manifest).json'parse();
807- if (!manifestBuffer) {
807+ const card = readFromV2(byafData.card);
808- throw new Error('Failed to extract manifest.json from BYAF file');
809- }
810-
811- const manifest = JSON.parse(manifestBuffer.toString());
812- const { character, characterPath } = await getCharacterFromByafManifest(data, manifest);
813- const scenario = await getScenarioFromByafManifest(data, manifest);
814- const image = await getImageBufferFromByafCharacter(data, character, characterPath);
815-
816- const card = readFromV2(formatByafAsCharacterCard(character, scenario));
817808 const fileName = preservedFileName || getPngName(card.name, request.user.directories);
818809 const result = await writeCharacterData(byafData.image, JSON.stringify(card), fileName, request);
819810 return result ? fileName : '';
820811}
821812
src/types/byaf.d.ts+77 -0
@@ -0,0 +1,77 @@
1+type ByafLoreItem = {
2+ key: string;
3+ value: string;
4+};
5+
6+type ByafCharacterImage = {
7+ path: string;
8+ label: string;
9+};
10+
11+type ByafExampleMessage = {
12+ characterID: string;
13+ text: string;
14+};
15+
16+type ByafCharacter = {
17+ schemaVersion: 1;
18+ id: string;
19+ name: string;
20+ displayName: string;
21+ isNSFW: boolean;
22+ persona: string;
23+ createdAt: string;
24+ updatedAt: string;
25+ loreItems: Array<ByafLoreItem>;
26+ images: Array<ByafCharacterImage>;
27+};
28+
29+type ByafManifest = {
30+ schemaVersion: 1;
31+ createdAt: string;
32+ characters: string[];
33+ scenarios: string[];
34+ author?: {
35+ name: string;
36+ backyardURL: string;
37+ };
38+};
39+
40+type ByafAiMessage = {
41+ type: "ai";
42+ outputs: Array<{
43+ createdAt: string;
44+ updatedAt: string;
45+ text: string;
46+ activeTimestamp: string;
47+ }>;
48+};
49+
50+type ByafHumanMessage = {
51+ type: "human";
52+ createdAt: string;
53+ updatedAt: string;
54+ text: string;
55+};
56+
57+type ByafScenario = {
58+ schemaVersion: 1;
59+ title?: string;
60+ model?: string;
61+ formattingInstructions: string;
62+ minP: number;
63+ minPEnabled: boolean;
64+ temperature: number;
65+ repeatPenalty: number;
66+ repeatLastN: number;
67+ topK: number;
68+ topP: number;
69+ exampleMessages: Array<ByafExampleMessage>;
70+ canDeleteExampleMessages: boolean;
71+ firstMessages: Array<ByafExampleMessage>;
72+ narrative: string;
73+ promptTemplate: "general" | "ChatML" | "Llama3" | "Gemma2" | "CommandR" | "MistralInstruct" | null;
74+ grammar: string | null;
75+ messages: Array<ByafAiMessage | ByafHumanMessage>;
76+ backgroundImage?: string;
77+};
src/types/spec-v2.d.ts+52 -0
@@ -0,0 +1,52 @@
1+type TavernCardV2 = {
2+ spec: 'chara_card_v2';
3+ spec_version: '2.0';
4+ data: {
5+ name: string;
6+ description: string;
7+ personality: string;
8+ scenario: string;
9+ first_mes: string;
10+ mes_example: string;
11+
12+ creator_notes: string;
13+ system_prompt: string;
14+ post_history_instructions: string;
15+ alternate_greetings: Array<string>;
16+ character_book?: CharacterBook;
17+
18+ tags: Array<string>;
19+ creator: string;
20+ character_version: string;
21+ extensions: Record<string, any>;
22+ }
23+}
24+
25+type CharacterBook = {
26+ name?: string;
27+ description?: string;
28+ scan_depth?: number;
29+ token_budget?: number;
30+ recursive_scanning?: boolean;
31+ extensions: Record<string, any>;
32+ entries: Array<CharacterBookEntry>;
33+}
34+
35+type CharacterBookEntry = {
36+ keys: Array<string>;
37+ content: string;
38+ extensions: Record<string, any>;
39+ enabled: boolean;
40+ insertion_order: number;
41+ case_sensitive?: boolean;
42+
43+ name?: string;
44+ priority?: number;
45+
46+ id?: number;
47+ comment?: string;
48+ selective?: boolean;
49+ secondary_keys?: Array<string>;
50+ constant?: boolean;
51+ position?: 'before_char' | 'after_char';
52+};