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>
Signed| @@ -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); |
| 9326 | 9327 | ||
| 9327 | try { | 9328 | try { |
| @@ -1,4 +1,3 @@ | |||
| 1 | import sanitize from 'sanitize-filename'; | ||
| 2 | import { promises as fsPromises } from 'node:fs'; | 1 | import { promises as fsPromises } from 'node:fs'; |
| 3 | import path from 'node:path'; | 2 | import path from 'node:path'; |
| 4 | import urlJoin from 'url-join'; | 3 | import urlJoin from 'url-join'; |
| @@ -28,7 +27,7 @@ export class ByafParser { | |||
| 28 | * @returns {string} String with macros replaced | 27 | * @returns {string} String with macros replaced |
| 29 | * @private | 28 | * @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 messages | 41 | * @returns {string} Formatted example messages |
| 43 | * @private | 42 | * @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 | }); |
| 58 | 57 | ||
| 59 | return formattedExamples.trimEnd(); | 58 | return formattedExamples.trimEnd(); |
| @@ -61,21 +60,30 @@ export class ByafParser { | |||
| 61 | 60 | ||
| 62 | /** | 61 | /** |
| 63 | * Formats alternate greetings for a character. | 62 | * Formats alternate greetings for a character. |
| 64 | * @param {ByafExampleMessage[]} [greetings] Array of greeting objects | 63 | * @param {Partial<ByafScenario>[]} [scenarios] Array of scenario objects |
| 65 | * @returns {string[]} Formatted alternate greetings | 64 | * @returns {string[]} Formatted alternate greetings |
| 66 | * @private | 65 | * @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 | } |
| 72 | 71 | ||
| 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 | } |
| 76 | 76 | 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 | } |
| 80 | 88 | ||
| 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 | } |
| 153 | 161 | ||
| 154 | /** | 162 | /** |
| 155 | * Extracts a scenario object from BYAF buffer. | 163 | * Extracts all scenario objects from BYAF buffer. |
| 156 | * @param {ByafManifest} manifest BYAF manifest | 164 | * @param {ByafManifest} manifest BYAF manifest |
| 157 | * @returns {Promise<Partial<ByafScenario>>} Scenario object | 165 | * @returns {Promise<Partial<ByafScenario>[]>} Scenarios array |
| 158 | * @private | 166 | * @private |
| 159 | */ | 167 | */ |
| 160 | async getScenarioFromManifest(manifest) { | 168 | async getScenariosFromManifest(manifest) { |
| 161 | const scenariosArray = manifest?.scenarios; | 169 | const scenariosArray = manifest?.scenarios; |
| 162 | 170 | ||
| 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 | } |
| 167 | 175 | ||
| 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 | } | ||
| 171 | 177 | ||
| 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 | } |
| 177 | 191 | ||
| 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 | } |
| 183 | 196 | ||
| 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 | } |
| 191 | 199 | ||
| 192 | /** | 200 | /** |
| 193 | * Extracts an image from BYAF buffer. | 201 | * Extracts all character icon images from BYAF buffer. |
| 194 | * @param {ByafCharacter} character Character object | 202 | * @param {ByafCharacter} character Character object |
| 195 | * @param {string} characterPath Path to the character in the BYAF manifest | 203 | * @param {string} characterPath Path to the character in the BYAF manifest |
| 196 | * @return {Promise<Buffer>} Image buffer | 204 | * @return {Promise<{filename: string, image: Buffer, label: string}[]>} Image buffer |
| 197 | * @private | 205 | * @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; |
| 202 | 210 | ||
| 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 | } |
| 207 | 215 | ||
| 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 | } | ||
| 213 | 223 | ||
| 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 | } |
| 220 | 230 | ||
| 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 | } |
| 223 | 239 | ||
| 224 | /** | 240 | /** |
| 225 | * Formats BYAF data as a character card. | 241 | * Formats BYAF data as a character card. |
| 226 | * @param {ByafManifest} manifest BYAF manifest | 242 | * @param {ByafManifest} manifest BYAF manifest |
| 227 | * @param {ByafCharacter} character Character object | 243 | * @param {ByafCharacter} character Character object |
| 228 | * @param {Partial<ByafScenario>} scenario Scenario object | 244 | * @param {Partial<ByafScenario>[]} scenarios Scenarios array |
| 229 | * @return {TavernCardV2} Character card object | 245 | * @return {TavernCardV2} Character card object |
| 230 | * @private | 246 | * @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 extension | 269 | // @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 | } | ||
| 257 | 304 | ||
| 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 | } |
| 276 | 323 | ||
| 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 buffer | 437 | * @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); |
| 287 | 445 | const chatBackgrounds = await this.getChatBackgrounds(character, scenarios); | |
| 288 | return { card, image }; | 446 | return { card, images, scenarios, chatBackgrounds, character }; |
| 289 | } | 447 | } |
| 290 | } | 448 | } |
| 291 | 449 | ||
| @@ -14,7 +14,7 @@ import storage from 'node-persist'; | |||
| 14 | 14 | ||
| 15 | import { AVATAR_WIDTH, AVATAR_HEIGHT, DEFAULT_AVATAR_PATH } from '../constants.js'; | 15 | import { AVATAR_WIDTH, AVATAR_HEIGHT, DEFAULT_AVATAR_PATH } from '../constants.js'; |
| 16 | import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction } from '../middleware/validateFileName.js'; | 16 | import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction } from '../middleware/validateFileName.js'; |
| 17 | import { deepMerge, humanizedISO8601DateTime, tryParse, extractFileFromZipBuffer, MemoryLimitedMap, getConfigValue, mutateJsonString } from '../util.js'; | 17 | import { deepMerge, humanizedISO8601DateTime, tryParse, extractFileFromZipBuffer, MemoryLimitedMap, getConfigValue, mutateJsonString, clientRelativePath, getUniqueName, sanitizeSafeCharacterReplacements } from '../util.js'; |
| 18 | import { TavernCardValidator } from '../validator/TavernCardValidator.js'; | 18 | import { TavernCardValidator } from '../validator/TavernCardValidator.js'; |
| 19 | import { parse, read, write } from '../character-card-parser.js'; | 19 | import { parse, read, write } from '../character-card-parser.js'; |
| 20 | import { readWorldInfoFile } from './worldinfo.js'; | 20 | import { readWorldInfoFile } from './worldinfo.js'; |
| @@ -814,8 +814,69 @@ async function importFromByaf(uploadPath, { request }, preservedFileName) { | |||
| 814 | 814 | ||
| 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 | } |
| 821 | 882 | ||
| @@ -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 | |||
| 79 | type ByafChatBackground = { | ||
| 80 | name: string; | ||
| 81 | data: Buffer; | ||
| 82 | paths: string[]; | ||
| 83 | }; | ||
| 84 | |||
| 85 | type ByafParseResult = { | ||
| 86 | card: TavernCardV2, | ||
| 87 | images: { filename: string, image: Buffer, label: string }[], | ||
| 88 | scenarios: Partial<ByafScenario>[], | ||
| 89 | chatBackgrounds: Array<ByafChatBackground>, | ||
| 90 | character: ByafCharacter | ||
| 91 | }; | ||
| @@ -425,6 +425,31 @@ export function clientRelativePath(root, inputPath) { | |||
| 425 | } | 425 | } |
| 426 | 426 | ||
| 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 | */ | ||
| 433 | export 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 | */ | ||
| 448 | export 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 extension | 455 | * @returns The file name, sans extension |