Import BYAF (#4300) * allow byaf import format * Add byaf import converters * Fix import of image and scenario * Update src/byaf.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Log JSON parsing errors * Use fsPromises for file acces * Fix callback variable name * Clear file input value after file upload to allow re-uploading --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

e0b020f211c1538ee2a0c52f5807119c08f72989

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
4 files changed, +239 -3Showing whitespace changes
public/index.html+2 -2
@@ -5828,10 +5828,10 @@
5828 </div>5828 </div>
5829 <div id="rm_character_import" class="right_menu" style="display: none;">5829 <div id="rm_character_import" class="right_menu" style="display: none;">
5830 <form id="form_import" action="javascript:void(null);" method="post" enctype="multipart/form-data">5830 <form id="form_import" action="javascript:void(null);" method="post" enctype="multipart/form-data">
5831 <input multiple type="file" id="character_import_file" accept=".json, image/png, .yaml, .yml, .charx" name="avatar">5831 <input multiple type="file" id="character_import_file" accept=".json, image/png, .yaml, .yml, .charx, .byaf" name="avatar">
5832 <input id="character_import_file_type" name="file_type" class="text_pole" value="" autocomplete="off">5832 <input id="character_import_file_type" name="file_type" class="text_pole" value="" autocomplete="off">
5833 </form>5833 </form>
5834 <input type="file" id="character_replace_file" accept=".json, image/png, .yaml, .yml, .charx" name="replace_avatar" hidden>5834 <input type="file" id="character_replace_file" accept=".json, image/png, .yaml, .yml, .charx, .byaf" name="replace_avatar" hidden>
5835 </div>5835 </div>
5836 <div name="Character List Panel" id="rm_characters_block" class="right_menu">5836 <div name="Character List Panel" id="rm_characters_block" class="right_menu">
5837 <div id="charListFixedTop">5837 <div id="charListFixedTop">
public/script.js+5 -1
@@ -8836,6 +8836,7 @@ export async function processDroppedFiles(files, data = new Map()) {
88368836
8837 const allowedExtensions = [8837 const allowedExtensions = [
8838 'charx',8838 'charx',
8839 'byaf',
8839 ];8840 ];
88408841
8841 const avatarFileNames = [];8842 const avatarFileNames = [];
@@ -8899,7 +8900,7 @@ async function importCharacter(file, { preserveFileName = '', importTags = false
8899 }8900 }
89008901
8901 const ext = file.name.match(/\.(\w+)$/);8902 const ext = file.name.match(/\.(\w+)$/);
8902 if (!ext || !(['json', 'png', 'yaml', 'yml', 'charx'].includes(ext[1].toLowerCase()))) {8903 if (!ext || !(['json', 'png', 'yaml', 'yml', 'charx', 'byaf'].includes(ext[1].toLowerCase()))) {
8903 return;8904 return;
8904 }8905 }
89058906
@@ -10484,6 +10485,9 @@ jQuery(async function () {
10484 await importCharactersTags(avatarFileNames);10485 await importCharactersTags(avatarFileNames);
10485 selectImportedChar(avatarFileNames[avatarFileNames.length - 1]);10486 selectImportedChar(avatarFileNames[avatarFileNames.length - 1]);
10486 }10487 }
10488
10489 // Clear the file input value to allow re-uploading the same file
10490 e.target.value = '';
10487 });10491 });
1048810492
10489 $('#export_button').on('click', function () {10493 $('#export_button').on('click', function () {
src/byaf.js+209 -0
@@ -0,0 +1,209 @@
1
2import sanitize from 'sanitize-filename';
3import { promises as fsPromises } from 'node:fs';
4import path from 'node:path';
5import urlJoin from 'url-join';
6import { DEFAULT_AVATAR_PATH } from './constants.js';
7import { extractFileFromZipBuffer, humanizedISO8601DateTime } from './util.js';
8
9export 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
16export 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
33export 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
46export 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 */
78export 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 */
118export 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 */
156export 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 */
186export 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}
src/endpoints/characters.js+23 -0
@@ -22,6 +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';
2526
26// With 100 MB limit it would take roughly 3000 characters to reach this limit27// With 100 MB limit it would take roughly 3000 characters to reach this limit
27const memoryCacheCapacity = getConfigValue('performance.memoryCacheCapacity', '100mb');28const memoryCacheCapacity = getConfigValue('performance.memoryCacheCapacity', '100mb');
@@ -796,6 +797,27 @@ async function importFromCharX(uploadPath, { request }, preservedFileName) {
796 return result ? fileName : '';797 return result ? fileName : '';
797}798}
798799
800async function importFromByaf(uploadPath, { request }, preservedFileName) {
801 const data = (await fsPromises.readFile(uploadPath)).buffer;
802 await fsPromises.unlink(uploadPath);
803 console.info('Importing from BYAF');
804
805 const manifestBuffer = await extractFileFromZipBuffer(data, 'manifest.json');
806 if (!manifestBuffer) {
807 throw new Error('Failed to extract manifest.json from BYAF file');
808 }
809
810 const manifest = JSON.parse(manifestBuffer.toString());
811 const { character, characterPath } = await getCharacterFromByafManifest(data, manifest);
812 const scenario = await getScenarioFromByafManifest(data, manifest);
813 const image = await getImageBufferFromByafCharacter(data, character, characterPath);
814
815 const card = readFromV2(formatByafAsCharacterCard(character, scenario));
816 const fileName = preservedFileName || getPngName(card.name, request.user.directories);
817 const result = await writeCharacterData(image, JSON.stringify(card), fileName, request);
818 return result ? fileName : '';
819}
820
799/**821/**
800 * Import a character from a JSON file.822 * Import a character from a JSON file.
801 * @param {string} uploadPath Path to the uploaded file823 * @param {string} uploadPath Path to the uploaded file
@@ -1300,6 +1322,7 @@ router.post('/import', async function (request, response) {
1300 'json': importFromJson,1322 'json': importFromJson,
1301 'png': importFromPng,1323 'png': importFromPng,
1302 'charx': importFromCharX,1324 'charx': importFromCharX,
1325 'byaf': importFromByaf,
1303 };1326 };
13041327
1305 try {1328 try {