| | 1 | |
| | 2 | import sanitize from 'sanitize-filename'; |
| | 3 | import { promises as fsPromises } from 'node:fs'; |
| | 4 | import path from 'node:path'; |
| | 5 | import urlJoin from 'url-join'; |
| | 6 | import { DEFAULT_AVATAR_PATH } from './constants.js'; |
| | 7 | import { extractFileFromZipBuffer, humanizedISO8601DateTime } from './util.js'; |
| | 8 | |
| | 9 | export const replaceByafMacros = (s) => |
| | 10 | String(s || '') |
| | 11 | .replace(/#{user}:/gi, '{{user}}:') |
| | 12 | .replace(/#{character}:/gi, '{{char}}:') |
| | 13 | .replace(/{character}(?!})/gi, '{{char}}') |
| | 14 | .replace(/{user}(?!})/gi, '{{user}}'); |
| | 15 | |
| | 16 | export const formatByafExampleMessages = (examples) => { |
| | 17 | if (!Array.isArray(examples)) { |
| | 18 | return ''; |
| | 19 | } |
| | 20 | |
| | 21 | let formattedExamples = ''; |
| | 22 | |
| | 23 | examples.forEach((example) => { |
| | 24 | if (!example?.text) { |
| | 25 | return; |
| | 26 | } |
| | 27 | formattedExamples += `<START>\n${replaceByafMacros(example.text)}\n`; |
| | 28 | }); |
| | 29 | |
| | 30 | return formattedExamples.trimEnd(); |
| | 31 | }; |
| | 32 | |
| | 33 | export const formatByafAlternateGreetings = (greetings) => { |
| | 34 | if (!Array.isArray(greetings)) { |
| | 35 | return []; |
| | 36 | } |
| | 37 | |
| | 38 | if (greetings.length <= 1) { |
| | 39 | return []; |
| | 40 | } |
| | 41 | |
| | 42 | // Skip one because it goes into 'first_mes' |
| | 43 | return greetings.slice(1).map(g => replaceByafMacros(g?.text)); |
| | 44 | }; |
| | 45 | |
| | 46 | export const convertByafCharacterBook = (items) => { |
| | 47 | if (!Array.isArray(items) || items.length === 0) { |
| | 48 | return null; |
| | 49 | } |
| | 50 | |
| | 51 | const book = { |
| | 52 | /** @type {any[]} */ |
| | 53 | entries: [], |
| | 54 | }; |
| | 55 | |
| | 56 | items.forEach((item, index) => { |
| | 57 | if (!item) { |
| | 58 | return; |
| | 59 | } |
| | 60 | book.entries.push({ |
| | 61 | keys: replaceByafMacros(item?.key).split(',').map(key => key.trim()).filter(Boolean), |
| | 62 | content: replaceByafMacros(item?.value), |
| | 63 | extensions: {}, |
| | 64 | enabled: true, |
| | 65 | insertion_order: index, |
| | 66 | }); |
| | 67 | }); |
| | 68 | |
| | 69 | return book; |
| | 70 | }; |
| | 71 | |
| | 72 | /** |
| | 73 | * Extracts a character object from BYAF buffer. |
| | 74 | * @param {ArrayBufferLike} data ZIP buffer |
| | 75 | * @param {object} manifest BYAF manifest |
| | 76 | * @returns {Promise<{character:object,characterPath:string}>} Character object |
| | 77 | */ |
| | 78 | export async function getCharacterFromByafManifest(data, manifest) { |
| | 79 | const charactersArray = manifest?.characters; |
| | 80 | |
| | 81 | if (!Array.isArray(charactersArray)) { |
| | 82 | throw new Error('Invalid BYAF file: missing characters array'); |
| | 83 | } |
| | 84 | |
| | 85 | if (charactersArray.length === 0) { |
| | 86 | throw new Error('Invalid BYAF file: characters array is empty'); |
| | 87 | } |
| | 88 | |
| | 89 | if (charactersArray.length > 1) { |
| | 90 | console.warn('Warning: BYAF manifest contains more than one character, only the first one will be imported'); |
| | 91 | } |
| | 92 | |
| | 93 | const characterPath = charactersArray[0]; |
| | 94 | if (!characterPath) { |
| | 95 | throw new Error('Invalid BYAF file: missing character path'); |
| | 96 | } |
| | 97 | |
| | 98 | const characterBuffer = await extractFileFromZipBuffer(data, characterPath); |
| | 99 | if (!characterBuffer) { |
| | 100 | throw new Error('Invalid BYAF file: failed to extract character JSON'); |
| | 101 | } |
| | 102 | |
| | 103 | try { |
| | 104 | const character = JSON.parse(characterBuffer.toString()); |
| | 105 | return { character, characterPath }; |
| | 106 | } catch (error) { |
| | 107 | console.error('Failed to parse character JSON from BYAF:', error); |
| | 108 | throw new Error('Invalid BYAF file: character is not a valid JSON'); |
| | 109 | } |
| | 110 | } |
| | 111 | |
| | 112 | /** |
| | 113 | * Extracts a scenario object from BYAF buffer. |
| | 114 | * @param {ArrayBufferLike} data ZIP buffer |
| | 115 | * @param {object} manifest BYAF manifest |
| | 116 | * @returns {Promise<object>} Scenario object |
| | 117 | */ |
| | 118 | export async function getScenarioFromByafManifest(data, manifest) { |
| | 119 | const scenariosArray = manifest?.scenarios; |
| | 120 | |
| | 121 | if (!Array.isArray(scenariosArray) || scenariosArray.length === 0) { |
| | 122 | console.warn('Warning: BYAF manifest contains no scenarios'); |
| | 123 | return {}; |
| | 124 | } |
| | 125 | |
| | 126 | if (scenariosArray.length > 1) { |
| | 127 | console.warn('Warning: BYAF manifest contains more than one scenario, only the first one will be imported'); |
| | 128 | } |
| | 129 | |
| | 130 | const scenarioPath = scenariosArray[0]; |
| | 131 | if (!scenarioPath) { |
| | 132 | console.warn('Warning: missing BYAF scenario path'); |
| | 133 | return {}; |
| | 134 | } |
| | 135 | |
| | 136 | const scenarioBuffer = await extractFileFromZipBuffer(data, scenarioPath); |
| | 137 | if (!scenarioBuffer) { |
| | 138 | console.warn('Warning: failed to extract BYAF scenario JSON'); |
| | 139 | return {}; |
| | 140 | } |
| | 141 | |
| | 142 | try { |
| | 143 | return JSON.parse(scenarioBuffer.toString()); |
| | 144 | } catch (error) { |
| | 145 | console.warn('Warning: BYAF scenario is not a valid JSON', error); |
| | 146 | return {}; |
| | 147 | } |
| | 148 | } |
| | 149 | |
| | 150 | /** |
| | 151 | * Extracts an image from BYAF buffer. |
| | 152 | * @param {ArrayBufferLike} data ZIP buffer |
| | 153 | * @param {object} character Character object |
| | 154 | * @param {string} characterPath Path to the character in the BYAF manifest |
| | 155 | */ |
| | 156 | export async function getImageBufferFromByafCharacter(data, character, characterPath) { |
| | 157 | const defaultAvatarBuffer = await fsPromises.readFile(DEFAULT_AVATAR_PATH); |
| | 158 | const characterImages = character?.images; |
| | 159 | |
| | 160 | if (!Array.isArray(characterImages) || characterImages.length === 0) { |
| | 161 | console.warn('Warning: BYAF character has no images'); |
| | 162 | return defaultAvatarBuffer; |
| | 163 | } |
| | 164 | |
| | 165 | const imagePath = characterImages[0]?.path; |
| | 166 | if (!imagePath) { |
| | 167 | console.warn('Warning: BYAF character image path is empty'); |
| | 168 | return defaultAvatarBuffer; |
| | 169 | } |
| | 170 | |
| | 171 | const fullImagePath = urlJoin(path.dirname(characterPath), imagePath); |
| | 172 | const imageBuffer = await extractFileFromZipBuffer(data, fullImagePath); |
| | 173 | if (!imageBuffer) { |
| | 174 | console.warn('Warning: failed to extract BYAF character image'); |
| | 175 | return defaultAvatarBuffer; |
| | 176 | } |
| | 177 | |
| | 178 | return imageBuffer; |
| | 179 | } |
| | 180 | |
| | 181 | /** |
| | 182 | * Formats BYAF data as a character card. |
| | 183 | * @param {object} character |
| | 184 | * @param {object} scenario |
| | 185 | */ |
| | 186 | export function formatByafAsCharacterCard(character, scenario) { |
| | 187 | return { |
| | 188 | spec: 'chara_card_v2', |
| | 189 | spec_version: '2.0', |
| | 190 | create_date: humanizedISO8601DateTime(), |
| | 191 | data: { |
| | 192 | name: sanitize(character?.name || character?.displayName || ''), |
| | 193 | description: replaceByafMacros(character?.persona), |
| | 194 | personality: '', |
| | 195 | scenario: replaceByafMacros(scenario?.narrative), |
| | 196 | first_mes: replaceByafMacros(scenario?.firstMessages?.[0]?.text), |
| | 197 | mes_example: formatByafExampleMessages(scenario?.exampleMessages), |
| | 198 | creator_notes: '', |
| | 199 | system_prompt: replaceByafMacros(scenario?.formattingInstructions), |
| | 200 | post_history_instructions: '', |
| | 201 | alternate_greetings: formatByafAlternateGreetings(scenario?.firstMessages), |
| | 202 | character_book: convertByafCharacterBook(character?.loreItems), |
| | 203 | tags: [], |
| | 204 | creator: '', |
| | 205 | character_version: '', |
| | 206 | extensions: {}, |
| | 207 | }, |
| | 208 | }; |
| | 209 | } |