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 @@
58285828 </div>
58295829 <div id="rm_character_import" class="right_menu" style="display: none;">
58305830 <form id="form_import" action="javascript:void(null);" method="post" enctype="multipart/form-data">
58315831 <input multiple type="file" id="character_import_file" accept=".json, image/png, .yaml, .yml, .charx, .byaf" name="avatar">
58325832 <input id="character_import_file_type" name="file_type" class="text_pole" value="" autocomplete="off">
58335833 </form>
58345834 <input type="file" id="character_replace_file" accept=".json, image/png, .yaml, .yml, .charx, .byaf" name="replace_avatar" hidden>
58355835 </div>
58365836 <div name="Character List Panel" id="rm_characters_block" class="right_menu">
58375837 <div id="charListFixedTop">
public/script.js+5 -1
@@ -8836,6 +8836,7 @@ export async function processDroppedFiles(files, data = new Map()) {
88368836
88378837 const allowedExtensions = [
88388838 'charx',
8839+ 'byaf',
88398840 ];
88408841
88418842 const avatarFileNames = [];
@@ -8899,7 +8900,7 @@ async function importCharacter(file, { preserveFileName = '', importTags = false
88998900 }
89008901
89018902 const ext = file.name.match(/\.(\w+)$/);
89028903 if (!ext || !(['json', 'png', 'yaml', 'yml', 'charx', 'byaf'].includes(ext[1].toLowerCase()))) {
89038904 return;
89048905 }
89058906
@@ -10484,6 +10485,9 @@ jQuery(async function () {
1048410485 await importCharactersTags(avatarFileNames);
1048510486 selectImportedChar(avatarFileNames[avatarFileNames.length - 1]);
1048610487 }
10488+
10489+ // Clear the file input value to allow re-uploading the same file
10490+ e.target.value = '';
1048710491 });
1048810492
1048910493 $('#export_button').on('click', function () {
src/byaf.js+209 -0
@@ -0,0 +1,209 @@
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+}
src/endpoints/characters.js+23 -0
@@ -22,6 +22,7 @@ import { invalidateThumbnail } from './thumbnails.js';
2222import { importRisuSprites } from './sprites.js';
2323import { getUserDirectories } from '../users.js';
2424import { getChatInfo } from './chats.js';
25+import { formatByafAsCharacterCard, getCharacterFromByafManifest, getImageBufferFromByafCharacter, getScenarioFromByafManifest } from '../byaf.js';
2526
2627// With 100 MB limit it would take roughly 3000 characters to reach this limit
2728const memoryCacheCapacity = getConfigValue('performance.memoryCacheCapacity', '100mb');
@@ -796,6 +797,27 @@ async function importFromCharX(uploadPath, { request }, preservedFileName) {
796797 return result ? fileName : '';
797798}
798799
800+async 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+
799821/**
800822 * Import a character from a JSON file.
801823 * @param {string} uploadPath Path to the uploaded file
@@ -1300,6 +1322,7 @@ router.post('/import', async function (request, response) {
13001322 'json': importFromJson,
13011323 'png': importFromPng,
13021324 'charx': importFromCharX,
1325+ 'byaf': importFromByaf,
13031326 };
13041327
13051328 try {