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';
5import { DEFAULT_AVATAR_PATH } from './constants.js';5import { DEFAULT_AVATAR_PATH } from './constants.js';
6import { extractFileFromZipBuffer, humanizedISO8601DateTime } from './util.js';6import { extractFileFromZipBuffer, humanizedISO8601DateTime } from './util.js';
77
8export const replaceByafMacros = (s) =>8/**
9 String(s || '')9 * A parser for BYAF (Backyard Archive Format) files.
10 */
11export 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 || '')
10 .replace(/#{user}:/gi, '{{user}}:')33 .replace(/#{user}:/gi, '{{user}}:')
11 .replace(/#{character}:/gi, '{{char}}:')34 .replace(/#{character}:/gi, '{{char}}:')
12 .replace(/{character}(?!})/gi, '{{char}}')35 .replace(/{character}(?!})/gi, '{{char}}')
13 .replace(/{user}(?!})/gi, '{{user}}');36 .replace(/{user}(?!})/gi, '{{user}}');
37 }
1438
15export 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) {
16 if (!Array.isArray(examples)) {46 if (!Array.isArray(examples)) {
17 return '';47 return '';
18 }48 }
@@ -23,13 +53,19 @@ export const formatByafExampleMessages = (examples) => {
23 if (!example?.text) {53 if (!example?.text) {
24 return;54 return;
25 }55 }
26 formattedExamples += `<START>\n${replaceByafMacros(example.text)}\n`;56 formattedExamples += `<START>\n${this.replaceMacros(example.text)}\n`;
27 });57 });
2858
29 return formattedExamples.trimEnd();59 return formattedExamples.trimEnd();
30};60 }
3161
32export 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) {
33 if (!Array.isArray(greetings)) {69 if (!Array.isArray(greetings)) {
34 return [];70 return [];
35 }71 }
@@ -39,17 +75,24 @@ export const formatByafAlternateGreetings = (greetings) => {
39 }75 }
4076
41 // Skip one because it goes into 'first_mes'77 // Skip one because it goes into 'first_mes'
42 return greetings.slice(1).map(g => replaceByafMacros(g?.text));78 return greetings.slice(1).map(g => this.replaceMacros(g?.text));
43};79 }
4480
45export 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) {
46 if (!Array.isArray(items) || items.length === 0) {88 if (!Array.isArray(items) || items.length === 0) {
47 return null;89 return undefined;
48 }90 }
4991
92 /** @type {CharacterBook} */
50 const book = {93 const book = {
51 /** @type {any[]} */
52 entries: [],94 entries: [],
95 extensions: {},
53 };96 };
5497
55 items.forEach((item, index) => {98 items.forEach((item, index) => {
@@ -57,8 +100,8 @@ export const convertByafCharacterBook = (items) => {
57 return;100 return;
58 }101 }
59 book.entries.push({102 book.entries.push({
60 keys: replaceByafMacros(item?.key).split(',').map(key => key.trim()).filter(Boolean),103 keys: this.replaceMacros(item?.key).split(',').map(key => key.trim()).filter(Boolean),
61 content: replaceByafMacros(item?.value),104 content: this.replaceMacros(item?.value),
62 extensions: {},105 extensions: {},
63 enabled: true,106 enabled: true,
64 insertion_order: index,107 insertion_order: index,
@@ -66,15 +109,15 @@ export const convertByafCharacterBook = (items) => {
66 });109 });
67110
68 return book;111 return book;
69};112 }
70113
71 /**114 /**
72 * Extracts a character object from BYAF buffer.115 * Extracts a character object from BYAF buffer.
73 * @param {ArrayBufferLike} data ZIP buffer116 * @param {ByafManifest} manifest BYAF manifest
74 * @param {object} manifest BYAF manifest117 * @returns {Promise<{character:ByafCharacter,characterPath:string}>} Character object
75 * @returns {Promise<{character:object,characterPath:string}>} Character object118 * @private
76 */119 */
77export async function getCharacterFromByafManifest(data, manifest) {120 async getCharacterFromManifest(manifest) {
78 const charactersArray = manifest?.characters;121 const charactersArray = manifest?.characters;
79122
80 if (!Array.isArray(charactersArray)) {123 if (!Array.isArray(charactersArray)) {
@@ -94,7 +137,7 @@ export async function getCharacterFromByafManifest(data, manifest) {
94 throw new Error('Invalid BYAF file: missing character path');137 throw new Error('Invalid BYAF file: missing character path');
95 }138 }
96139
97 const characterBuffer = await extractFileFromZipBuffer(data, characterPath);140 const characterBuffer = await extractFileFromZipBuffer(this.#data, characterPath);
98 if (!characterBuffer) {141 if (!characterBuffer) {
99 throw new Error('Invalid BYAF file: failed to extract character JSON');142 throw new Error('Invalid BYAF file: failed to extract character JSON');
100 }143 }
@@ -110,11 +153,11 @@ export async function getCharacterFromByafManifest(data, manifest) {
110153
111 /**154 /**
112 * Extracts a scenario object from BYAF buffer.155 * Extracts a scenario object from BYAF buffer.
113 * @param {ArrayBufferLike} data ZIP buffer156 * @param {ByafManifest} manifest BYAF manifest
114 * @param {object} manifest BYAF manifest157 * @returns {Promise<Partial<ByafScenario>>} Scenario object
115 * @returns {Promise<object>} Scenario object158 * @private
116 */159 */
117export async function getScenarioFromByafManifest(data, manifest) {160 async getScenarioFromManifest(manifest) {
118 const scenariosArray = manifest?.scenarios;161 const scenariosArray = manifest?.scenarios;
119162
120 if (!Array.isArray(scenariosArray) || scenariosArray.length === 0) {163 if (!Array.isArray(scenariosArray) || scenariosArray.length === 0) {
@@ -132,7 +175,7 @@ export async function getScenarioFromByafManifest(data, manifest) {
132 return {};175 return {};
133 }176 }
134177
135 const scenarioBuffer = await extractFileFromZipBuffer(data, scenarioPath);178 const scenarioBuffer = await extractFileFromZipBuffer(this.#data, scenarioPath);
136 if (!scenarioBuffer) {179 if (!scenarioBuffer) {
137 console.warn('Warning: failed to extract BYAF scenario JSON');180 console.warn('Warning: failed to extract BYAF scenario JSON');
138 return {};181 return {};
@@ -148,11 +191,12 @@ export async function getScenarioFromByafManifest(data, manifest) {
148191
149 /**192 /**
150 * Extracts an image from BYAF buffer.193 * Extracts an image from BYAF buffer.
151 * @param {ArrayBufferLike} data ZIP buffer194 * @param {ByafCharacter} character Character object
152 * @param {object} character Character object
153 * @param {string} characterPath Path to the character in the BYAF manifest195 * @param {string} characterPath Path to the character in the BYAF manifest
196 * @return {Promise<Buffer>} Image buffer
197 * @private
154 */198 */
155export async function getImageBufferFromByafCharacter(data, character, characterPath) {199 async getCharacterImage(character, characterPath) {
156 const defaultAvatarBuffer = await fsPromises.readFile(DEFAULT_AVATAR_PATH);200 const defaultAvatarBuffer = await fsPromises.readFile(DEFAULT_AVATAR_PATH);
157 const characterImages = character?.images;201 const characterImages = character?.images;
158202
@@ -168,7 +212,7 @@ export async function getImageBufferFromByafCharacter(data, character, character
168 }212 }
169213
170 const fullImagePath = urlJoin(path.dirname(characterPath), imagePath);214 const fullImagePath = urlJoin(path.dirname(characterPath), imagePath);
171 const imageBuffer = await extractFileFromZipBuffer(data, fullImagePath);215 const imageBuffer = await extractFileFromZipBuffer(this.#data, fullImagePath);
172 if (!imageBuffer) {216 if (!imageBuffer) {
173 console.warn('Warning: failed to extract BYAF character image');217 console.warn('Warning: failed to extract BYAF character image');
174 return defaultAvatarBuffer;218 return defaultAvatarBuffer;
@@ -179,30 +223,70 @@ export async function getImageBufferFromByafCharacter(data, character, character
179223
180 /**224 /**
181 * Formats BYAF data as a character card.225 * Formats BYAF data as a character card.
182 * @param {object} character226 * @param {ByafManifest} manifest BYAF manifest
183 * @param {object} scenario227 * @param {ByafCharacter} character Character object
228 * @param {Partial<ByafScenario>} scenario Scenario object
229 * @return {TavernCardV2} Character card object
230 * @private
184 */231 */
185export function formatByafAsCharacterCard(character, scenario) {232 getCharacterCard(manifest, character, scenario) {
186 return {233 return {
187 spec: 'chara_card_v2',234 spec: 'chara_card_v2',
188 spec_version: '2.0',235 spec_version: '2.0',
189 create_date: humanizedISO8601DateTime(),
190 data: {236 data: {
191 name: sanitize(character?.name || character?.displayName || ''),237 name: sanitize(character?.name || character?.displayName || ''),
192 description: replaceByafMacros(character?.persona),238 description: this.replaceMacros(character?.persona),
193 personality: '',239 personality: '',
194 scenario: replaceByafMacros(scenario?.narrative),240 scenario: this.replaceMacros(scenario?.narrative),
195 first_mes: replaceByafMacros(scenario?.firstMessages?.[0]?.text),241 first_mes: this.replaceMacros(scenario?.firstMessages?.[0]?.text),
196 mes_example: formatByafExampleMessages(scenario?.exampleMessages),242 mes_example: this.formatExampleMessages(scenario?.exampleMessages),
197 creator_notes: '',243 creator_notes: '',
198 system_prompt: replaceByafMacros(scenario?.formattingInstructions),244 system_prompt: this.replaceMacros(scenario?.formattingInstructions),
199 post_history_instructions: '',245 post_history_instructions: '',
200 alternate_greetings: formatByafAlternateGreetings(scenario?.firstMessages),246 alternate_greetings: this.formatAlternateGreetings(scenario?.firstMessages),
201 character_book: convertByafCharacterBook(character?.loreItems),247 character_book: this.convertCharacterBook(character?.loreItems),
202 tags: [],248 tags: [],
203 creator: '',249 creator: manifest?.author?.name || '',
204 character_version: '',250 character_version: '',
205 extensions: {},251 extensions: {},
206 },252 },
253 // @ts-ignore Non-standard spec extension
254 create_date: humanizedISO8601DateTime(),
207 };255 };
208 }256 }
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
292export default ByafParser;
src/endpoints/characters.js+4 -13
@@ -22,7 +22,7 @@ import { invalidateThumbnail } from './thumbnails.js';
22import { importRisuSprites } from './sprites.js';22import { importRisuSprites } from './sprites.js';
23import { getUserDirectories } from '../users.js';23import { getUserDirectories } from '../users.js';
24import { getChatInfo } from './chats.js';24import { getChatInfo } from './chats.js';
25import { formatByafAsCharacterCard, getCharacterFromByafManifest, getImageBufferFromByafCharacter, getScenarioFromByafManifest } from '../byaf.js';25import { ByafParser } from '../byaf.js';
26import cacheBuster from '../middleware/cacheBuster.js';26import cacheBuster from '../middleware/cacheBuster.js';
2727
28// With 100 MB limit it would take roughly 3000 characters to reach this limit28// With 100 MB limit it would take roughly 3000 characters to reach this limit
@@ -803,19 +803,10 @@ async function importFromByaf(uploadPath, { request }, preservedFileName) {
803 await fsPromises.unlink(uploadPath);803 await fsPromises.unlink(uploadPath);
804 console.info('Importing from BYAF');804 console.info('Importing from BYAF');
805805
806 const manifestBuffer = await extractFileFromZipBuffer(data, 'manifest.json');806 const byafData = await new ByafParser(data).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));
817 const fileName = preservedFileName || getPngName(card.name, request.user.directories);808 const fileName = preservedFileName || getPngName(card.name, request.user.directories);
818 const result = await writeCharacterData(image, JSON.stringify(card), fileName, request);809 const result = await writeCharacterData(byafData.image, JSON.stringify(card), fileName, request);
819 return result ? fileName : '';810 return result ? fileName : '';
820}811}
821812
src/types/byaf.d.ts+77 -0
@@ -0,0 +1,77 @@
1type ByafLoreItem = {
2 key: string;
3 value: string;
4};
5
6type ByafCharacterImage = {
7 path: string;
8 label: string;
9};
10
11type ByafExampleMessage = {
12 characterID: string;
13 text: string;
14};
15
16type 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
29type ByafManifest = {
30 schemaVersion: 1;
31 createdAt: string;
32 characters: string[];
33 scenarios: string[];
34 author?: {
35 name: string;
36 backyardURL: string;
37 };
38};
39
40type ByafAiMessage = {
41 type: "ai";
42 outputs: Array<{
43 createdAt: string;
44 updatedAt: string;
45 text: string;
46 activeTimestamp: string;
47 }>;
48};
49
50type ByafHumanMessage = {
51 type: "human";
52 createdAt: string;
53 updatedAt: string;
54 text: string;
55};
56
57type 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 @@
1type 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
25type 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
35type 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};