sy / src /byaf.js
吴松泽
main
c120a1c
import { promises as fsPromises } from 'node:fs';
import path from 'node:path';
import urlJoin from 'url-join';
import { DEFAULT_AVATAR_PATH } from './constants.js';
import { extractFileFromZipBuffer, humanizedISO8601DateTime } from './util.js';
/**
* A parser for BYAF (Backyard Archive Format) files.
*/
export class ByafParser {
/**
* @param {ArrayBufferLike} data BYAF ZIP buffer
*/
#data;
/**
* Creates an instance of ByafParser.
* @param {ArrayBufferLike} data BYAF ZIP buffer
*/
constructor(data) {
this.#data = data;
}
/**
* Replaces known macros in a string.
* @param {string} [str] String to process
* @returns {string} String with macros replaced
* @private
*/
static replaceMacros(str) {
return String(str || '')
.replace(/#{user}:/gi, '{{user}}:')
.replace(/#{character}:/gi, '{{char}}:')
.replace(/{character}(?!})/gi, '{{char}}')
.replace(/{user}(?!})/gi, '{{user}}');
}
/**
* Formats example messages for a character.
* @param {ByafExampleMessage[]} [examples] Array of example objects
* @returns {string} Formatted example messages
* @private
*/
static formatExampleMessages(examples) {
if (!Array.isArray(examples)) {
return '';
}
let formattedExamples = '';
examples.forEach((example) => {
if (!example?.text) {
return;
}
formattedExamples += `<START>\n${ByafParser.replaceMacros(example.text)}\n`;
});
return formattedExamples.trimEnd();
}
/**
* Formats alternate greetings for a character.
* @param {Partial<ByafScenario>[]} [scenarios] Array of scenario objects
* @returns {string[]} Formatted alternate greetings
* @private
*/
formatAlternateGreetings(scenarios) {
if (!Array.isArray(scenarios)) {
return [];
}
// Skip one because it goes into 'first_mes'
if (scenarios.length <= 1) {
return [];
}
const greetings = new Set();
const firstScenarioFirstMessage = scenarios?.[0]?.firstMessages?.[0]?.text;
for (const scenario of scenarios.slice(1).filter(s => Array.isArray(s.firstMessages) && s.firstMessages.length > 0)) {
// As per the BYAF spec, "firstMessages" array MUST contain AT MOST one message.
// So we only consider the first one if it exists.
const firstMessage = scenario?.firstMessages?.[0];
if (firstMessage?.text && firstMessage.text !== firstScenarioFirstMessage) {
greetings.add(ByafParser.replaceMacros(firstMessage.text));
}
}
return Array.from(greetings);
}
/**
* Converts character book items to a structured format.
* @param {ByafLoreItem[]} items Array of key-value pairs
* @returns {CharacterBook|undefined} Converted character book or undefined if invalid
* @private
*/
convertCharacterBook(items) {
if (!Array.isArray(items) || items.length === 0) {
return undefined;
}
/** @type {CharacterBook} */
const book = {
entries: [],
extensions: {},
};
items.forEach((item, index) => {
if (!item) {
return;
}
book.entries.push({
keys: ByafParser.replaceMacros(item?.key).split(',').map(key => key.trim()).filter(Boolean),
content: ByafParser.replaceMacros(item?.value),
extensions: {},
enabled: true,
insertion_order: index,
});
});
return book;
}
/**
* Extracts a character object from BYAF buffer.
* @param {ByafManifest} manifest BYAF manifest
* @returns {Promise<{character:ByafCharacter,characterPath:string}>} Character object
* @private
*/
async getCharacterFromManifest(manifest) {
const charactersArray = manifest?.characters;
if (!Array.isArray(charactersArray)) {
throw new Error('Invalid BYAF file: missing characters array');
}
if (charactersArray.length === 0) {
throw new Error('Invalid BYAF file: characters array is empty');
}
if (charactersArray.length > 1) {
console.warn('Warning: BYAF manifest contains more than one character, only the first one will be imported');
}
const characterPath = charactersArray[0];
if (!characterPath) {
throw new Error('Invalid BYAF file: missing character path');
}
const characterBuffer = await extractFileFromZipBuffer(this.#data, characterPath);
if (!characterBuffer) {
throw new Error('Invalid BYAF file: failed to extract character JSON');
}
try {
const character = JSON.parse(characterBuffer.toString());
return { character, characterPath };
} catch (error) {
console.error('Failed to parse character JSON from BYAF:', error);
throw new Error('Invalid BYAF file: character is not a valid JSON');
}
}
/**
* Extracts all scenario objects from BYAF buffer.
* @param {ByafManifest} manifest BYAF manifest
* @returns {Promise<Partial<ByafScenario>[]>} Scenarios array
* @private
*/
async getScenariosFromManifest(manifest) {
const scenariosArray = manifest?.scenarios;
if (!Array.isArray(scenariosArray) || scenariosArray.length === 0) {
console.warn('Warning: BYAF manifest contains no scenarios');
return [{}];
}
const scenarios = [];
for (const scenarioPath of scenariosArray) {
const scenarioBuffer = await extractFileFromZipBuffer(this.#data, scenarioPath);
if (!scenarioBuffer) {
console.warn('Warning: failed to extract BYAF scenario JSON');
}
if (scenarioBuffer) {
try {
scenarios.push(JSON.parse(scenarioBuffer.toString()));
} catch (error) {
console.warn('Warning: BYAF scenario is not a valid JSON', error);
}
}
}
if (scenarios.length === 0) {
console.warn('Warning: BYAF manifest contains no valid scenarios');
return [{}];
}
return scenarios;
}
/**
* Extracts all character icon images from BYAF buffer.
* @param {ByafCharacter} character Character object
* @param {string} characterPath Path to the character in the BYAF manifest
* @return {Promise<{filename: string, image: Buffer, label: string}[]>} Image buffer
* @private
*/
async getCharacterImages(character, characterPath) {
const defaultAvatarBuffer = await fsPromises.readFile(DEFAULT_AVATAR_PATH);
const characterImages = character?.images;
if (!Array.isArray(characterImages) || characterImages.length === 0) {
console.warn('Warning: BYAF character has no images');
return [{ filename: '', image: defaultAvatarBuffer, label: '' }];
}
const imageBuffers = [];
for (const image of characterImages) {
const imagePath = image?.path;
if (!imagePath) {
console.warn('Warning: BYAF character image path is empty');
continue;
}
const fullImagePath = urlJoin(path.dirname(characterPath), imagePath);
const imageBuffer = await extractFileFromZipBuffer(this.#data, fullImagePath);
if (!imageBuffer) {
console.warn('Warning: failed to extract BYAF character image');
continue;
}
imageBuffers.push({ filename: path.basename(imagePath), image: imageBuffer, label: image?.label || '' });
}
if (imageBuffers.length === 0) {
console.warn('Warning: BYAF character has no valid images');
return [{ filename: '', image: defaultAvatarBuffer, label: '' }];
}
return imageBuffers;
}
/**
* Formats BYAF data as a character card.
* @param {ByafManifest} manifest BYAF manifest
* @param {ByafCharacter} character Character object
* @param {Partial<ByafScenario>[]} scenarios Scenarios array
* @return {TavernCardV2} Character card object
* @private
*/
getCharacterCard(manifest, character, scenarios) {
return {
spec: 'chara_card_v2',
spec_version: '2.0',
data: {
name: character?.name || character?.displayName || '',
description: ByafParser.replaceMacros(character?.persona),
personality: '',
scenario: ByafParser.replaceMacros(scenarios[0]?.narrative),
first_mes: ByafParser.replaceMacros(scenarios[0]?.firstMessages?.[0]?.text),
mes_example: ByafParser.formatExampleMessages(scenarios[0]?.exampleMessages),
creator_notes: manifest?.author?.backyardURL || '', // To preserve the link to the author from BYAF manifest, this is a good place.
system_prompt: ByafParser.replaceMacros(scenarios[0]?.formattingInstructions),
post_history_instructions: '',
alternate_greetings: this.formatAlternateGreetings(scenarios),
character_book: this.convertCharacterBook(character?.loreItems),
tags: character?.isNSFW ? ['nsfw'] : [], // Since there are no tags in BYAF spec, we can use this to preserve the isNSFW flag.
creator: manifest?.author?.name || '',
character_version: '',
extensions: { ...(character?.displayName && { 'display_name': character?.displayName }) }, // Preserve display name unmodified using extensions. "display_name" is not used by SillyTavern currently.
},
// @ts-ignore Non-standard spec extension
create_date: humanizedISO8601DateTime(),
};
}
/**
* Gets chat backgrounds from BYAF data mapped to their respective scenarios.
* @param {ByafCharacter} character Character object
* @param {Partial<ByafScenario>[]} scenarios Scenarios array
* @returns {Promise<Array<ByafChatBackground>>} Chat backgrounds
* @private
*/
async getChatBackgrounds(character, scenarios) {
// Implementation for extracting chat backgrounds from BYAF data
const backgrounds = [];
let i = 1;
for (const scenario of scenarios) {
const bgImagePath = scenario?.backgroundImage;
if (bgImagePath) {
const data = await extractFileFromZipBuffer(this.#data, bgImagePath);
if (data) {
const existingIndex = backgrounds.findIndex(bg => bg.data.compare(data) === 0);
if (existingIndex !== -1) {
backgrounds[existingIndex].paths.push(bgImagePath);
continue; // Skip adding a new background since it already exists
}
backgrounds.push({
name: `${character?.name} bg ${i++}` || '',
data: data,
paths: [bgImagePath],
});
}
}
}
return backgrounds;
}
/**
* Gets the manifest from the BYAF data.
* @returns {Promise<ByafManifest>} Parsed manifest
* @private
*/
async getManifest() {
const manifestBuffer = await extractFileFromZipBuffer(this.#data, 'manifest.json');
if (!manifestBuffer) {
throw new Error('Failed to extract manifest.json from BYAF file');
}
const manifest = JSON.parse(manifestBuffer.toString());
if (!manifest || typeof manifest !== 'object') {
throw new Error('Invalid BYAF manifest');
}
return manifest;
}
/**
* Imports a chat from BYAF format.
* @param {Partial<ByafScenario>} scenario Scenario object
* @param {string} userName User name
* @param {string} characterName Character name
* @param {Array<ByafChatBackground>} chatBackgrounds Chat backgrounds
* @returns {string} Chat data
*/
static getChatFromScenario(scenario, userName, characterName, chatBackgrounds) {
const chatStartDate = scenario?.messages?.length == 0 ? humanizedISO8601DateTime() : scenario?.messages?.filter(m => 'createdAt' in m)[0].createdAt;
const chatBackground = chatBackgrounds.find(bg => bg.paths.includes(scenario?.backgroundImage || ''))?.name || '';
/** @type {object[]} */
const chat = [{
user_name: userName,
character_name: characterName,
create_date: chatStartDate,
chat_metadata: {
scenario: scenario?.narrative ?? '',
mes_example: ByafParser.formatExampleMessages(scenario?.exampleMessages),
system_prompt: ByafParser.replaceMacros(scenario?.formattingInstructions),
mes_examples_optional: scenario?.canDeleteExampleMessages ?? false,
byaf_model_settings: {
model: scenario?.model ?? '',
temperature: scenario?.temperature ?? 1.2,
top_k: scenario?.topK ?? 40,
top_p: scenario?.topP ?? 0.9,
min_p: scenario?.minP ?? 0.1,
min_p_enabled: scenario?.minPEnabled ?? true,
repeat_penalty: scenario?.repeatPenalty ?? 1.05,
repeat_penalty_tokens: scenario?.repeatLastN ?? 256,
by_prompt_template: scenario?.promptTemplate ?? 'general',
grammar: scenario?.grammar ?? null,
},
chat_backgrounds: chatBackground ? [chatBackground] : [],
custom_background: chatBackground ? `url("${encodeURI(chatBackground)}")` : '',
},
}];
// Add the first message IF it exists.
if (scenario?.firstMessages?.length && scenario?.firstMessages?.length > 0 && scenario?.firstMessages?.[0]?.text) {
chat.push({
name: characterName,
is_user: false,
send_date: chatStartDate,
mes: scenario?.firstMessages?.[0]?.text || '',
});
}
const sortByTimestamp = (newest, curr) => {
const aTime = new Date(newest.activeTimestamp);
const bTime = new Date(curr.activeTimestamp);
return aTime >= bTime ? newest : curr;
};
const getNewestAiMessage = (message) => {
return message.outputs.reduce(sortByTimestamp);
};
const getSwipesForAiMessage = (aiMessage) => {
return aiMessage.outputs.map(output => output.text);
};
const userMessages = scenario?.messages?.filter(msg => msg.type === 'human');
const characterMessages = scenario?.messages?.filter(msg => msg.type === 'ai');
/**
* Reorders messages by interleaving user and character messages so that they are in correct chronological order.
* This is only needed to import old chats from Backyard AI that were incorrectly imported by an earlier version
* that completely messed up the order of messages. Backyard AI Windows frontend never supported creation of chats
* with which were ordered like this in the first place, so for most users this is desired functionality.
*/
if (userMessages && characterMessages && userMessages.length === characterMessages.length) { // Only do the reordering if there are equal numbers of user and character messages, otherwise just import in existing order, because it's probably correct already.
for (let i = 0; i < userMessages.length; i++) {
chat.push({
name: userName,
is_user: true,
send_date: Number(userMessages[i]?.createdAt),
mes: userMessages[i]?.text,
});
const aiMessage = getNewestAiMessage(characterMessages[i]);
const aiSwipes = getSwipesForAiMessage(characterMessages[i]);
chat.push({
name: characterName,
is_user: false,
send_date: Number(aiMessage.createdAt),
mes: aiMessage.text,
swipes: aiSwipes,
swipe_id: aiSwipes.findIndex(s => s === aiMessage.text),
});
}
} else if (scenario?.messages) {
for (const message of scenario.messages) {
const isUser = message.type === 'human';
const aiMessage = !isUser ? getNewestAiMessage(message) : null;
const chatMessage = {
name: isUser ? userName : characterName,
is_user: isUser,
send_date: Number(isUser ? message.createdAt : aiMessage.createdAt),
mes: isUser ? message.text : aiMessage.text,
};
if (!isUser) {
const aiSwipes = getSwipesForAiMessage(message);
chatMessage.swipes = aiSwipes;
chatMessage.swipe_id = aiSwipes.findIndex(s => s === aiMessage.text);
}
chat.push(chatMessage);
}
} else {
console.warn('Warning: BYAF scenario contained no messages property.');
}
return chat.map(obj => JSON.stringify(obj)).join('\n');
}
/**
* Parses the BYAF data.
* @return {Promise<ByafParseResult>} Parsed character card and image buffer
*/
async parse() {
const manifest = await this.getManifest();
const { character, characterPath } = await this.getCharacterFromManifest(manifest);
const scenarios = await this.getScenariosFromManifest(manifest);
const images = await this.getCharacterImages(character, characterPath);
const card = this.getCharacterCard(manifest, character, scenarios);
const chatBackgrounds = await this.getChatBackgrounds(character, scenarios);
return { card, images, scenarios, chatBackgrounds, character };
}
}
export default ByafParser;