| 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 } from './util.js'; |
|
|
| |
| |
| |
| export class ByafParser { |
| |
| |
| |
| #data; |
|
|
| |
| |
| |
| |
| constructor(data) { |
| this.#data = data; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| static replaceMacros(str) { |
| return String(str || '') |
| .replace(/#{user}:/gi, '{{user}}:') |
| .replace(/#{character}:/gi, '{{char}}:') |
| .replace(/{character}(?!})/gi, '{{char}}') |
| .replace(/{user}(?!})/gi, '{{user}}'); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| formatAlternateGreetings(scenarios) { |
| if (!Array.isArray(scenarios)) { |
| return []; |
| } |
|
|
| |
| 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)) { |
| |
| |
| const firstMessage = scenario?.firstMessages?.[0]; |
| if (firstMessage?.text && firstMessage.text !== firstScenarioFirstMessage) { |
| greetings.add(ByafParser.replaceMacros(firstMessage.text)); |
| } |
| } |
| return Array.from(greetings); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| convertCharacterBook(items) { |
| if (!Array.isArray(items) || items.length === 0) { |
| return undefined; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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'); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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 || '', |
| 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'] : [], |
| creator: manifest?.author?.name || '', |
| character_version: '', |
| extensions: { ...(character?.displayName && { 'display_name': character?.displayName }) }, |
| }, |
| |
| create_date: new Date().toISOString(), |
| }; |
| } |
| |
| |
| |
| |
| |
| |
| |
| async getChatBackgrounds(character, scenarios) { |
| |
| 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; |
| } |
| backgrounds.push({ |
| name: `${character?.name} bg ${i++}` || '', |
| data: data, |
| paths: [bgImagePath], |
| }); |
| } |
| } |
| } |
| return backgrounds; |
| } |
|
|
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| static getChatFromScenario(scenario, userName, characterName, chatBackgrounds) { |
| const chatStartDate = scenario?.messages?.length == 0 ? new Date().toISOString() : scenario?.messages?.filter(m => 'createdAt' in m)[0].createdAt; |
| const chatBackground = chatBackgrounds.find(bg => bg.paths.includes(scenario?.backgroundImage || ''))?.name || ''; |
| |
| const chat = [{ |
| user_name: 'unused', |
| character_name: 'unused', |
| 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)}")` : '', |
| }, |
| }]; |
| |
| 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'); |
| |
| |
| |
| |
| |
| |
| if (userMessages && characterMessages && userMessages.length === characterMessages.length) { |
| 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'); |
| } |
|
|
| |
| |
| |
| |
| 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; |
|
|