|
|
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'; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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: humanizedISO8601DateTime(), |
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 ? humanizedISO8601DateTime() : scenario?.messages?.filter(m => 'createdAt' in m)[0].createdAt; |
|
|
const chatBackground = chatBackgrounds.find(bg => bg.paths.includes(scenario?.backgroundImage || ''))?.name || ''; |
|
|
|
|
|
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)}")` : '', |
|
|
}, |
|
|
}]; |
|
|
|
|
|
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; |
|
|
|