Add Full Support for BYAF Archives (#4701) * + Fix discarding all scenarios aside from the first one, which would cause all chats other than the first one to be lost. + Accumulate alternative starting messages from all scenarios, as different scenarios are how Backyard handles having alternative greetings. A scenario with multiple messages in its "firstMessages" array is invalid as per BYAF spec. + Return scenarios so that they can be used to populate chats, scenario overrides, and in the future, the other character settings overrides as well. * + Added support for importing chats (including swipes) from BYAF. This brings the core functionality of BYAF imports mostly in-line with the standard, without the inclusion of any features that are currently missing from ST. > The only supported feature not yet implemented is scenario overrides per-chat. This will be the the goal of the next commit. * + Implemented scenario overrides, fully bringing ST's BYAF import as in-line with the BYAF standard as possible without adding new feature functionality to ST. + Also added some new chat metadata fields in preparation for example message and system prompt overrides. * + Added support for pulling alternate icons from the BYAF archive. They are not used yet, but they are safely stored. + Added support for pulling backgrounds from the BYAF archive. They are not used yet but they are stored and linked to the chats they originally belonged to. + Saved every remaining field that was previously discarded from BYAF that a user actually needs in a way that could be used in the future, and can be accessed by the user if needed after import. Any fields that are not preserved at this point are specfic to either Backyard AI's desktop application, or to the BYAF manifest itself, and therefore have no relevance to a user. > With this, BYAF import should be fully supported and in-line with the official spec. * + Updated comments and method names to reflect changes appropriately. First round of touch ups. * + More comment updates. * - Removed commented code, that is no longer needed for reference. * > No longer sanitize for filenames in the character card, as there's no need. The sanitization now happens when generating the filename, which is subsequently used for file operations. + Added replacement function for sanitizing filenames which uses visually similar but valid for filenames replacement characters instead of deleting the characters for better data parity. * + Preserve isNSFW flag as a tag, so it's not lost on import. * + preserve labels on character alt avatars * + Deduplicate first messages, so that 5 chats with the same first message don't create 5 identical first messages. * + Adjusted icon naming logic to produce more sensible results. * > User path.basename and path.extname for getting the base name and extension of files, respectively. * > Should use camelCase for local function names. * > Use a set for greetings instead of explicitly checking for duplicates. * > Missed a snake case local function name. * > Updated background import logic to integrate with existing per-chat background system. * + Updated logic to skip import of chats and backgrounds when a character is being updated/replaced. * + Added copy of getUniqueName helper function from frontend utils to backend utils. + Refactored duplicated logic for getting unique filenames for backgrounds and character alt icons to use the newly available function. * > fixed adding an extra dot to the filenames for icons after switching to path.extname for getting the extension * > Updated metadata settings generation in byaf chat so that it doesn't overwrite valid yet 'falsy' values with defaults. Oops * > Remove variable for configuration that will likely never need to exist. * > custom_background should be an empty string if chat_background is falsy * > Make an array empty if chat_background is falsy. * > Prefer camelCase for local variable names. * + Added definitions for ByafParseResult and ByafChatBackground. > Updated overused and unweildy anonymous types to the newly defined types. * + When importing a character with chats, the character will now no longer open to a new empty chat, but instead will pick the first of the imported chats to open up to. * Rename prev_paths => paths * Prettify logs display * Set fallback chat name if scenario is untitled * card.data.name => card.name * Optimize first scenario message access * + Moved custom replacement function from importFromByaf into util.js as sanitizeSafeCharacterReplacements() so that the replacement logic may be reused anywhere for consistency. * > Missed a couple. Updated for consistency * + Preserve character full/display name without it needing to be safe as a filename. > Change logic for naming the folder the alt images live in to match the logic the sprites endpoint uses to get character sprites for consistency * > Only add full_name extension if displayName exists. * full_name => display_name * - No longer use safe lookalike replacements for filename sanitization to be consistent with how web browsers sanitize invalid characters in filenames before saving to disk. * Tiny formatting fix --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

dd758161bdf8b27f7c3c9b12c246ff677e1081fa

docfail <4965997+docfail@users.noreply.github.com>

Signed
5 files changed, +330 -71Ignore whitespace
public/script.js+1 -0
@@ -9322,6 +9322,7 @@ async function importCharacter(file, { preserveFileName = '', importTags = false
9322 const formData = new FormData();9322 const formData = new FormData();
9323 formData.append('avatar', file);9323 formData.append('avatar', file);
9324 formData.append('file_type', format);9324 formData.append('file_type', format);
9325 formData.append('user_name', name1);
9325 if (preserveFileName) formData.append('preserved_name', preserveFileName);9326 if (preserveFileName) formData.append('preserved_name', preserveFileName);
93269327
9327 try {9328 try {
src/byaf.js+226 -68
@@ -1,4 +1,3 @@
1import sanitize from 'sanitize-filename';
2import { promises as fsPromises } from 'node:fs';1import { promises as fsPromises } from 'node:fs';
3import path from 'node:path';2import path from 'node:path';
4import urlJoin from 'url-join';3import urlJoin from 'url-join';
@@ -28,7 +27,7 @@ export class ByafParser {
28 * @returns {string} String with macros replaced27 * @returns {string} String with macros replaced
29 * @private28 * @private
30 */29 */
31 replaceMacros(str) {30 static replaceMacros(str) {
32 return String(str || '')31 return String(str || '')
33 .replace(/#{user}:/gi, '{{user}}:')32 .replace(/#{user}:/gi, '{{user}}:')
34 .replace(/#{character}:/gi, '{{char}}:')33 .replace(/#{character}:/gi, '{{char}}:')
@@ -42,7 +41,7 @@ export class ByafParser {
42 * @returns {string} Formatted example messages41 * @returns {string} Formatted example messages
43 * @private42 * @private
44 */43 */
45 formatExampleMessages(examples) {44 static formatExampleMessages(examples) {
46 if (!Array.isArray(examples)) {45 if (!Array.isArray(examples)) {
47 return '';46 return '';
48 }47 }
@@ -53,7 +52,7 @@ export class ByafParser {
53 if (!example?.text) {52 if (!example?.text) {
54 return;53 return;
55 }54 }
56 formattedExamples += `<START>\n${this.replaceMacros(example.text)}\n`;55 formattedExamples += `<START>\n${ByafParser.replaceMacros(example.text)}\n`;
57 });56 });
5857
59 return formattedExamples.trimEnd();58 return formattedExamples.trimEnd();
@@ -61,21 +60,30 @@ export class ByafParser {
6160
62 /**61 /**
63 * Formats alternate greetings for a character.62 * Formats alternate greetings for a character.
64 * @param {ByafExampleMessage[]} [greetings] Array of greeting objects63 * @param {Partial<ByafScenario>[]} [scenarios] Array of scenario objects
65 * @returns {string[]} Formatted alternate greetings64 * @returns {string[]} Formatted alternate greetings
66 * @private65 * @private
67 */66 */
68 formatAlternateGreetings(greetings) {67 formatAlternateGreetings(scenarios) {
69 if (!Array.isArray(greetings)) {68 if (!Array.isArray(scenarios)) {
70 return [];69 return [];
71 }70 }
7271
73 if (greetings.length <= 1) {72 // Skip one because it goes into 'first_mes'
73 if (scenarios.length <= 1) {
74 return [];74 return [];
75 }75 }
7676 const greetings = new Set();
77 // Skip one because it goes into 'first_mes'77 const firstScenarioFirstMessage = scenarios?.[0]?.firstMessages?.[0]?.text;
78 return greetings.slice(1).map(g => this.replaceMacros(g?.text));78 for (const scenario of scenarios.slice(1).filter(s => Array.isArray(s.firstMessages) && s.firstMessages.length > 0)) {
79 // As per the BYAF spec, "firstMessages" array MUST contain AT MOST one message.
80 // So we only consider the first one if it exists.
81 const firstMessage = scenario?.firstMessages?.[0];
82 if (firstMessage?.text && firstMessage.text !== firstScenarioFirstMessage) {
83 greetings.add(ByafParser.replaceMacros(firstMessage.text));
84 }
85 }
86 return Array.from(greetings);
79 }87 }
8088
81 /**89 /**
@@ -100,8 +108,8 @@ export class ByafParser {
100 return;108 return;
101 }109 }
102 book.entries.push({110 book.entries.push({
103 keys: this.replaceMacros(item?.key).split(',').map(key => key.trim()).filter(Boolean),111 keys: ByafParser.replaceMacros(item?.key).split(',').map(key => key.trim()).filter(Boolean),
104 content: this.replaceMacros(item?.value),112 content: ByafParser.replaceMacros(item?.value),
105 extensions: {},113 extensions: {},
106 enabled: true,114 enabled: true,
107 insertion_order: index,115 insertion_order: index,
@@ -152,108 +160,147 @@ export class ByafParser {
152 }160 }
153161
154 /**162 /**
155 * Extracts a scenario object from BYAF buffer.163 * Extracts all scenario objects from BYAF buffer.
156 * @param {ByafManifest} manifest BYAF manifest164 * @param {ByafManifest} manifest BYAF manifest
157 * @returns {Promise<Partial<ByafScenario>>} Scenario object165 * @returns {Promise<Partial<ByafScenario>[]>} Scenarios array
158 * @private166 * @private
159 */167 */
160 async getScenarioFromManifest(manifest) {168 async getScenariosFromManifest(manifest) {
161 const scenariosArray = manifest?.scenarios;169 const scenariosArray = manifest?.scenarios;
162170
163 if (!Array.isArray(scenariosArray) || scenariosArray.length === 0) {171 if (!Array.isArray(scenariosArray) || scenariosArray.length === 0) {
164 console.warn('Warning: BYAF manifest contains no scenarios');172 console.warn('Warning: BYAF manifest contains no scenarios');
165 return {};173 return [{}];
166 }174 }
167175
168 if (scenariosArray.length > 1) {176 const scenarios = [];
169 console.warn('Warning: BYAF manifest contains more than one scenario, only the first one will be imported');
170 }
171177
172 const scenarioPath = scenariosArray[0];178 for (const scenarioPath of scenariosArray) {
173 if (!scenarioPath) {179 const scenarioBuffer = await extractFileFromZipBuffer(this.#data, scenarioPath);
174 console.warn('Warning: missing BYAF scenario path');180 if (!scenarioBuffer) {
175 return {};181 console.warn('Warning: failed to extract BYAF scenario JSON');
182 }
183 if (scenarioBuffer) {
184 try {
185 scenarios.push(JSON.parse(scenarioBuffer.toString()));
186 } catch (error) {
187 console.warn('Warning: BYAF scenario is not a valid JSON', error);
188 }
189 }
176 }190 }
177191
178 const scenarioBuffer = await extractFileFromZipBuffer(this.#data, scenarioPath);192 if (scenarios.length === 0) {
179 if (!scenarioBuffer) {193 console.warn('Warning: BYAF manifest contains no valid scenarios');
180 console.warn('Warning: failed to extract BYAF scenario JSON');194 return [{}];
181 return {};
182 }195 }
183196
184 try {197 return scenarios;
185 return JSON.parse(scenarioBuffer.toString());
186 } catch (error) {
187 console.warn('Warning: BYAF scenario is not a valid JSON', error);
188 return {};
189 }
190 }198 }
191199
192 /**200 /**
193 * Extracts an image from BYAF buffer.201 * Extracts all character icon images from BYAF buffer.
194 * @param {ByafCharacter} character Character object202 * @param {ByafCharacter} character Character object
195 * @param {string} characterPath Path to the character in the BYAF manifest203 * @param {string} characterPath Path to the character in the BYAF manifest
196 * @return {Promise<Buffer>} Image buffer204 * @return {Promise<{filename: string, image: Buffer, label: string}[]>} Image buffer
197 * @private205 * @private
198 */206 */
199 async getCharacterImage(character, characterPath) {207 async getCharacterImages(character, characterPath) {
200 const defaultAvatarBuffer = await fsPromises.readFile(DEFAULT_AVATAR_PATH);208 const defaultAvatarBuffer = await fsPromises.readFile(DEFAULT_AVATAR_PATH);
201 const characterImages = character?.images;209 const characterImages = character?.images;
202210
203 if (!Array.isArray(characterImages) || characterImages.length === 0) {211 if (!Array.isArray(characterImages) || characterImages.length === 0) {
204 console.warn('Warning: BYAF character has no images');212 console.warn('Warning: BYAF character has no images');
205 return defaultAvatarBuffer;213 return [{ filename: '', image: defaultAvatarBuffer, label: '' }];
206 }214 }
207215
208 const imagePath = characterImages[0]?.path;216 const imageBuffers = [];
209 if (!imagePath) {217 for (const image of characterImages) {
210 console.warn('Warning: BYAF character image path is empty');218 const imagePath = image?.path;
211 return defaultAvatarBuffer;219 if (!imagePath) {
212 }220 console.warn('Warning: BYAF character image path is empty');
221 continue;
222 }
213223
214 const fullImagePath = urlJoin(path.dirname(characterPath), imagePath);224 const fullImagePath = urlJoin(path.dirname(characterPath), imagePath);
215 const imageBuffer = await extractFileFromZipBuffer(this.#data, fullImagePath);225 const imageBuffer = await extractFileFromZipBuffer(this.#data, fullImagePath);
216 if (!imageBuffer) {226 if (!imageBuffer) {
217 console.warn('Warning: failed to extract BYAF character image');227 console.warn('Warning: failed to extract BYAF character image');
218 return defaultAvatarBuffer;228 continue;
219 }229 }
220230
221 return imageBuffer;231 imageBuffers.push({ filename: path.basename(imagePath), image: imageBuffer, label: image?.label || '' });
232 }
233 if (imageBuffers.length === 0) {
234 console.warn('Warning: BYAF character has no valid images');
235 return [{ filename: '', image: defaultAvatarBuffer, label: '' }];
236 }
237 return imageBuffers;
222 }238 }
223239
224 /**240 /**
225 * Formats BYAF data as a character card.241 * Formats BYAF data as a character card.
226 * @param {ByafManifest} manifest BYAF manifest242 * @param {ByafManifest} manifest BYAF manifest
227 * @param {ByafCharacter} character Character object243 * @param {ByafCharacter} character Character object
228 * @param {Partial<ByafScenario>} scenario Scenario object244 * @param {Partial<ByafScenario>[]} scenarios Scenarios array
229 * @return {TavernCardV2} Character card object245 * @return {TavernCardV2} Character card object
230 * @private246 * @private
231 */247 */
232 getCharacterCard(manifest, character, scenario) {248 getCharacterCard(manifest, character, scenarios) {
233 return {249 return {
234 spec: 'chara_card_v2',250 spec: 'chara_card_v2',
235 spec_version: '2.0',251 spec_version: '2.0',
236 data: {252 data: {
237 name: sanitize(character?.name || character?.displayName || ''),253 name: character?.name || character?.displayName || '',
238 description: this.replaceMacros(character?.persona),254 description: ByafParser.replaceMacros(character?.persona),
239 personality: '',255 personality: '',
240 scenario: this.replaceMacros(scenario?.narrative),256 scenario: ByafParser.replaceMacros(scenarios[0]?.narrative),
241 first_mes: this.replaceMacros(scenario?.firstMessages?.[0]?.text),257 first_mes: ByafParser.replaceMacros(scenarios[0]?.firstMessages?.[0]?.text),
242 mes_example: this.formatExampleMessages(scenario?.exampleMessages),258 mes_example: ByafParser.formatExampleMessages(scenarios[0]?.exampleMessages),
243 creator_notes: '',259 creator_notes: manifest?.author?.backyardURL || '', // To preserve the link to the author from BYAF manifest, this is a good place.
244 system_prompt: this.replaceMacros(scenario?.formattingInstructions),260 system_prompt: ByafParser.replaceMacros(scenarios[0]?.formattingInstructions),
245 post_history_instructions: '',261 post_history_instructions: '',
246 alternate_greetings: this.formatAlternateGreetings(scenario?.firstMessages),262 alternate_greetings: this.formatAlternateGreetings(scenarios),
247 character_book: this.convertCharacterBook(character?.loreItems),263 character_book: this.convertCharacterBook(character?.loreItems),
248 tags: [],264 tags: character?.isNSFW ? ['nsfw'] : [], // Since there are no tags in BYAF spec, we can use this to preserve the isNSFW flag.
249 creator: manifest?.author?.name || '',265 creator: manifest?.author?.name || '',
250 character_version: '',266 character_version: '',
251 extensions: {},267 extensions: { ...(character?.displayName && { 'display_name': character?.displayName }) }, // Preserve display name unmodified using extensions. "display_name" is not used by SillyTavern currently.
252 },268 },
253 // @ts-ignore Non-standard spec extension269 // @ts-ignore Non-standard spec extension
254 create_date: humanizedISO8601DateTime(),270 create_date: humanizedISO8601DateTime(),
255 };271 };
256 }272 }
273 /**
274 * Gets chat backgrounds from BYAF data mapped to their respective scenarios.
275 * @param {ByafCharacter} character Character object
276 * @param {Partial<ByafScenario>[]} scenarios Scenarios array
277 * @returns {Promise<Array<ByafChatBackground>>} Chat backgrounds
278 * @private
279 */
280 async getChatBackgrounds(character, scenarios) {
281 // Implementation for extracting chat backgrounds from BYAF data
282 const backgrounds = [];
283 let i = 1;
284 for (const scenario of scenarios) {
285 const bgImagePath = scenario?.backgroundImage;
286 if (bgImagePath) {
287 const data = await extractFileFromZipBuffer(this.#data, bgImagePath);
288 if (data) {
289 const existingIndex = backgrounds.findIndex(bg => bg.data.compare(data) === 0);
290 if (existingIndex !== -1) {
291 backgrounds[existingIndex].paths.push(bgImagePath);
292 continue; // Skip adding a new background since it already exists
293 }
294 backgrounds.push({
295 name: `${character?.name} bg ${i++}` || '',
296 data: data,
297 paths: [bgImagePath],
298 });
299 }
300 }
301 }
302 return backgrounds;
303 }
257304
258 /**305 /**
259 * Gets the manifest from the BYAF data.306 * Gets the manifest from the BYAF data.
@@ -275,17 +322,128 @@ export class ByafParser {
275 }322 }
276323
277 /**324 /**
325 * Imports a chat from BYAF format.
326 * @param {Partial<ByafScenario>} scenario Scenario object
327 * @param {string} userName User name
328 * @param {string} characterName Character name
329 * @param {Array<ByafChatBackground>} chatBackgrounds Chat backgrounds
330 * @returns {string} Chat data
331 */
332 static getChatFromScenario(scenario, userName, characterName, chatBackgrounds) {
333 const chatStartDate = scenario?.messages?.length == 0 ? humanizedISO8601DateTime() : scenario?.messages?.filter(m => 'createdAt' in m)[0].createdAt;
334 const chatBackground = chatBackgrounds.find(bg => bg.paths.includes(scenario?.backgroundImage || ''))?.name || '';
335 /** @type {object[]} */
336 const chat = [{
337 user_name: userName,
338 character_name: characterName,
339 create_date: chatStartDate,
340 chat_metadata: {
341 scenario: scenario?.narrative ?? '',
342 mes_example: ByafParser.formatExampleMessages(scenario?.exampleMessages),
343 system_prompt: ByafParser.replaceMacros(scenario?.formattingInstructions),
344 mes_examples_optional: scenario?.canDeleteExampleMessages ?? false,
345 byaf_model_settings: {
346 model: scenario?.model ?? '',
347 temperature: scenario?.temperature ?? 1.2,
348 top_k: scenario?.topK ?? 40,
349 top_p: scenario?.topP ?? 0.9,
350 min_p: scenario?.minP ?? 0.1,
351 min_p_enabled: scenario?.minPEnabled ?? true,
352 repeat_penalty: scenario?.repeatPenalty ?? 1.05,
353 repeat_penalty_tokens: scenario?.repeatLastN ?? 256,
354 by_prompt_template: scenario?.promptTemplate ?? 'general',
355 grammar: scenario?.grammar ?? null,
356 },
357 chat_backgrounds: chatBackground ? [chatBackground] : [],
358 custom_background: chatBackground ? `url("${encodeURI(chatBackground)}")` : '',
359 },
360 }];
361 // Add the first message IF it exists.
362 if (scenario?.firstMessages?.length && scenario?.firstMessages?.length > 0 && scenario?.firstMessages?.[0]?.text) {
363 chat.push({
364 name: characterName,
365 is_user: false,
366 send_date: chatStartDate,
367 mes: scenario?.firstMessages?.[0]?.text || '',
368 });
369 }
370
371 const sortByTimestamp = (newest, curr) => {
372 const aTime = new Date(newest.activeTimestamp);
373 const bTime = new Date(curr.activeTimestamp);
374 return aTime >= bTime ? newest : curr;
375 };
376
377 const getNewestAiMessage = (message) => {
378 return message.outputs.reduce(sortByTimestamp);
379 };
380 const getSwipesForAiMessage = (aiMessage) => {
381 return aiMessage.outputs.map(output => output.text);
382 };
383
384 const userMessages = scenario?.messages?.filter(msg => msg.type === 'human');
385 const characterMessages = scenario?.messages?.filter(msg => msg.type === 'ai');
386 /**
387 * Reorders messages by interleaving user and character messages so that they are in correct chronological order.
388 * This is only needed to import old chats from Backyard AI that were incorrectly imported by an earlier version
389 * that completely messed up the order of messages. Backyard AI Windows frontend never supported creation of chats
390 * with which were ordered like this in the first place, so for most users this is desired functionality.
391 */
392 if (userMessages && characterMessages && userMessages.length === characterMessages.length) { // Only do the reordering if there are equal numbers of user and character messages, otherwise just import in existing order, because it's probably correct already.
393 for (let i = 0; i < userMessages.length; i++) {
394 chat.push({
395 name: userName,
396 is_user: true,
397 send_date: Number(userMessages[i]?.createdAt),
398 mes: userMessages[i]?.text,
399 });
400 const aiMessage = getNewestAiMessage(characterMessages[i]);
401 const aiSwipes = getSwipesForAiMessage(characterMessages[i]);
402 chat.push({
403 name: characterName,
404 is_user: false,
405 send_date: Number(aiMessage.createdAt),
406 mes: aiMessage.text,
407 swipes: aiSwipes,
408 swipe_id: aiSwipes.findIndex(s => s === aiMessage.text),
409 });
410 }
411 } else if (scenario?.messages) {
412 for (const message of scenario.messages) {
413 const isUser = message.type === 'human';
414 const aiMessage = !isUser ? getNewestAiMessage(message) : null;
415 const chatMessage = {
416 name: isUser ? userName : characterName,
417 is_user: isUser,
418 send_date: Number(isUser ? message.createdAt : aiMessage.createdAt),
419 mes: isUser ? message.text : aiMessage.text,
420 };
421 if (!isUser) {
422 const aiSwipes = getSwipesForAiMessage(message);
423 chatMessage.swipes = aiSwipes;
424 chatMessage.swipe_id = aiSwipes.findIndex(s => s === aiMessage.text);
425 }
426 chat.push(chatMessage);
427 }
428 } else {
429 console.warn('Warning: BYAF scenario contained no messages property.');
430 }
431
432 return chat.map(obj => JSON.stringify(obj)).join('\n');
433 }
434
435 /**
278 * Parses the BYAF data.436 * Parses the BYAF data.
279 * @return {Promise<{card: TavernCardV2, image: Buffer}>} Parsed character card and image buffer437 * @return {Promise<ByafParseResult>} Parsed character card and image buffer
280 */438 */
281 async parse() {439 async parse() {
282 const manifest = await this.getManifest();440 const manifest = await this.getManifest();
283 const { character, characterPath } = await this.getCharacterFromManifest(manifest);441 const { character, characterPath } = await this.getCharacterFromManifest(manifest);
284 const scenario = await this.getScenarioFromManifest(manifest);442 const scenarios = await this.getScenariosFromManifest(manifest);
285 const image = await this.getCharacterImage(character, characterPath);443 const images = await this.getCharacterImages(character, characterPath);
286 const card = this.getCharacterCard(manifest, character, scenario);444 const card = this.getCharacterCard(manifest, character, scenarios);
287445 const chatBackgrounds = await this.getChatBackgrounds(character, scenarios);
288 return { card, image };446 return { card, images, scenarios, chatBackgrounds, character };
289 }447 }
290}448}
291449
src/endpoints/characters.js+64 -3
@@ -14,7 +14,7 @@ import storage from 'node-persist';
1414
15import { AVATAR_WIDTH, AVATAR_HEIGHT, DEFAULT_AVATAR_PATH } from '../constants.js';15import { AVATAR_WIDTH, AVATAR_HEIGHT, DEFAULT_AVATAR_PATH } from '../constants.js';
16import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction } from '../middleware/validateFileName.js';16import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction } from '../middleware/validateFileName.js';
17import { deepMerge, humanizedISO8601DateTime, tryParse, extractFileFromZipBuffer, MemoryLimitedMap, getConfigValue, mutateJsonString } from '../util.js';17import { deepMerge, humanizedISO8601DateTime, tryParse, extractFileFromZipBuffer, MemoryLimitedMap, getConfigValue, mutateJsonString, clientRelativePath, getUniqueName, sanitizeSafeCharacterReplacements } from '../util.js';
18import { TavernCardValidator } from '../validator/TavernCardValidator.js';18import { TavernCardValidator } from '../validator/TavernCardValidator.js';
19import { parse, read, write } from '../character-card-parser.js';19import { parse, read, write } from '../character-card-parser.js';
20import { readWorldInfoFile } from './worldinfo.js';20import { readWorldInfoFile } from './worldinfo.js';
@@ -814,8 +814,69 @@ async function importFromByaf(uploadPath, { request }, preservedFileName) {
814814
815 const byafData = await new ByafParser(data).parse();815 const byafData = await new ByafParser(data).parse();
816 const card = readFromV2(byafData.card);816 const card = readFromV2(byafData.card);
817 const fileName = preservedFileName || getPngName(card.name, request.user.directories);817 const fileName = preservedFileName || getPngName(sanitize(byafData.character.displayName || card.name, { replacement: sanitizeSafeCharacterReplacements }), request.user.directories);
818 const result = await writeCharacterData(byafData.image, JSON.stringify(card), fileName, request);818
819 // Don't import chats and images if the character is being replaced or updated, instead of newly imported.
820 if (!preservedFileName) {
821 /**
822 * @param {Partial<ByafScenario>} scenario
823 */
824 const createChatAsCurrentPersona = (scenario) => {
825 const chatName = sanitize(`${scenario.title || card.name} - ${humanizedISO8601DateTime()} imported.jsonl`, { replacement: sanitizeSafeCharacterReplacements });
826 const filePath = path.join(request.user.directories.chats, path.basename(fileName), chatName);
827 const dir = path.dirname(filePath);
828 if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
829 writeFileAtomicSync(filePath, ByafParser.getChatFromScenario(scenario, request.body.user_name, card.name, byafData.chatBackgrounds), 'utf8');
830 console.log(`Created ${chatName} chat from BYAF import`);
831 return chatName;
832 };
833
834 // Upload backgrounds
835 for (const bg of byafData.chatBackgrounds) {
836 const extension = path.extname(bg.paths?.[0]) || '.png';
837 const baseName = `${path.basename(fileName)}_bg`;
838 const filePath = path.join(request.user.directories.userImages, fileName);
839 if (!fs.existsSync(filePath)) fs.mkdirSync(filePath, { recursive: true });
840 const file = getUniqueName(baseName, (name) => fs.existsSync(path.join(filePath, `${name}${extension}`)));
841 if (Buffer.isBuffer(bg.data)) {
842 const newFile = `${file}${extension}`;
843 writeFileAtomicSync(path.join(filePath, newFile), bg.data);
844 bg.name = clientRelativePath(request.user.directories.root, path.join(filePath, newFile)); // Update background name to the new file
845 console.log(`Created ${newFile} background from BYAF import`);
846 }
847 }
848
849 const chats = [];
850 // Create chats for each scenario
851 if (Array.isArray(byafData.scenarios)) {
852 for (const scenario of byafData.scenarios) {
853 chats.push(createChatAsCurrentPersona(scenario));
854 }
855 }
856
857 // Update the default chat if there are any so we open to an existing chat instead of creating a new one and opening that.
858 if (chats.length > 0) {
859 card.chat = path.basename(chats[0], path.extname(chats[0]));
860 }
861
862 // Save alternate icons for the character.
863 for (const icon of byafData.images.slice(1)) {
864 // BYAF does not support character expressions, so using the same structure will not result in conflicts,
865 // even if the expression system did not tolerate additional icons that are not mapped to expressions.
866 // This will not yet allow changing icons within the UI but at least the icons will be available for manual selection, rather than being lost.
867 const altImagesFolder = path.join(request.user.directories.characters, sanitize(card.name));
868 if (!fs.existsSync(altImagesFolder)) fs.mkdirSync(altImagesFolder, { recursive: true });
869 const extension = path.extname(icon.filename) || '.png';
870 const file = getUniqueName(`${sanitize(icon.label, { replacement: sanitizeSafeCharacterReplacements }) || 'alt'}`, (name) => fs.existsSync(path.join(altImagesFolder, `${name}${extension}`)));
871 if (Buffer.isBuffer(icon.image)) {
872 writeFileAtomicSync(path.join(altImagesFolder, `${file}${extension}`), icon.image);
873 console.log(`Created ${file}${extension} alternate icon from BYAF import`);
874 }
875 }
876 }
877
878 const result = await writeCharacterData(byafData.images[0].image, JSON.stringify(card), fileName, request);
879
819 return result ? fileName : '';880 return result ? fileName : '';
820}881}
821882
src/types/byaf.d.ts+14 -0
@@ -75,3 +75,17 @@ type ByafScenario = {
75 messages: Array<ByafAiMessage | ByafHumanMessage>;75 messages: Array<ByafAiMessage | ByafHumanMessage>;
76 backgroundImage?: string;76 backgroundImage?: string;
77};77};
78
79type ByafChatBackground = {
80 name: string;
81 data: Buffer;
82 paths: string[];
83};
84
85type ByafParseResult = {
86 card: TavernCardV2,
87 images: { filename: string, image: Buffer, label: string }[],
88 scenarios: Partial<ByafScenario>[],
89 chatBackgrounds: Array<ByafChatBackground>,
90 character: ByafCharacter
91};
src/util.js+25 -0
@@ -425,6 +425,31 @@ export function clientRelativePath(root, inputPath) {
425}425}
426426
427/**427/**
428 * Returns a name that is unique among the names that exist.
429 * @param {string} name The name to check.
430 * @param {{ (name: string): boolean; }} exists Function to check if name exists.
431 * @returns {string} A unique name.
432 */
433export function getUniqueName(name, exists) {
434 let i = 1;
435 let baseName = name;
436 while (exists(name)) {
437 name = `${baseName} (${i})`;
438 i++;
439 }
440 return name;
441}
442
443/**
444 * Provides safe replacements for characters in filenames. Intended for use with sanitize() from the sanitize-filename package.
445 * @param {string} char Character to sanitize
446 * @returns {string} Safe replacement character
447 */
448export function sanitizeSafeCharacterReplacements(char) {
449 return '_';
450}
451
452/**
428 * Strip the last file extension from a given file name. If there are multiple extensions, only the last is removed.453 * Strip the last file extension from a given file name. If there are multiple extensions, only the last is removed.
429 * @param {string} filename The file name to remove the extension from.454 * @param {string} filename The file name to remove the extension from.
430 * @returns The file name, sans extension455 * @returns The file name, sans extension