diff --git a/public/index.html b/public/index.html
index 961a23b8b..d8e69730d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -2262,7 +2262,10 @@
+
-
+
diff --git a/public/script.js b/public/script.js
index 533d8597a..6669a3892 100644
--- a/public/script.js
+++ b/public/script.js
@@ -32,6 +32,7 @@ import {
importEmbeddedWorldInfo,
checkEmbeddedWorld,
setWorldInfoButtonClass,
+ importWorldInfo,
} from "./scripts/world-info.js";
import {
@@ -227,6 +228,7 @@ export {
extension_prompt_types,
updateVisibleDivs,
mesForShowdownParse,
+ printCharacters,
}
// API OBJECT FOR EXTERNAL WIRING
@@ -805,6 +807,9 @@ async function printCharacters() {
template.find('img').attr('src', this_avatar);
template.find('.avatar').attr('title', item.avatar);
template.find('.ch_name').text(item.name);
+ if (power_user.show_card_avatar_urls) {
+ template.find('.ch_avatar_url').text(item.avatar);
+ }
template.find('.ch_fav_icon').css("display", 'none');
template.toggleClass('is_fav', item.fav || item.fav == 'true');
template.find('.ch_fav').val(item.fav);
@@ -7958,6 +7963,55 @@ $(document).ready(function () {
restoreCaretPosition($(this).get(0), caretPosition);
});
+ $('#external_import_button').on('click', async () => {
+ const html = `
Enter the URL of the content to import
+ Supported sources:
+
+ - Chub characters (direct link or id)
Example: lorebooks/bartleby/example-lorebook
+ - Chub lorebooks (direct link or id)
Example: Anonymous/example-character
+ - More coming soon...
+ `
+ const input = await callPopup(html, 'input');
+
+ if (!input) {
+ console.debug('Custom content import cancelled');
+ return;
+ }
+
+ const url = input.trim();
+ console.debug('Custom content import started', url);
+
+ const request = await fetch('/import_custom', {
+ method: 'POST',
+ headers: getRequestHeaders(),
+ body: JSON.stringify({ url }),
+ });
+
+ if (!request.ok) {
+ toastr.info(request.statusText, 'Custom content import failed');
+ console.error('Custom content import failed', request.status, request.statusText);
+ return;
+ }
+
+ const data = await request.blob();
+ const customContentType = request.headers.get('X-Custom-Content-Type');
+ const fileName = request.headers.get('Content-Disposition').split('filename=')[1].replace(/"/g, '');
+ const file = new File([data], fileName, { type: data.type });
+
+ switch (customContentType) {
+ case 'character':
+ processDroppedFiles([file]);
+ break;
+ case 'lorebook':
+ await importWorldInfo(file);
+ break;
+ default:
+ toastr.warn('Unknown content type');
+ console.error('Unknown content type', customContentType);
+ break;
+ }
+ });
+
const $dropzone = $(document.body);
$dropzone.on('dragover', (event) => {
diff --git a/public/scripts/power-user.js b/public/scripts/power-user.js
index 6d0c00778..14775408b 100644
--- a/public/scripts/power-user.js
+++ b/public/scripts/power-user.js
@@ -12,6 +12,7 @@ import {
eventSource,
event_types,
getCurrentChatId,
+ printCharacters,
name1,
name2,
} from "../script.js";
@@ -107,6 +108,7 @@ let power_user = {
chat_display: chat_styles.DEFAULT,
sheld_width: sheld_width.DEFAULT,
never_resize_avatars: false,
+ show_card_avatar_urls: false,
play_message_sound: false,
play_sound_unfocused: true,
auto_save_msg_edits: false,
@@ -592,6 +594,7 @@ function loadPowerUserSettings(settings, data) {
$("#play_message_sound").prop("checked", power_user.play_message_sound);
$("#play_sound_unfocused").prop("checked", power_user.play_sound_unfocused);
$("#never_resize_avatars").prop("checked", power_user.never_resize_avatars);
+ $("#show_card_avatar_urls").prop("checked", power_user.show_card_avatar_urls);
$("#auto_save_msg_edits").prop("checked", power_user.auto_save_msg_edits);
$("#allow_name1_display").prop("checked", power_user.allow_name1_display);
$("#allow_name2_display").prop("checked", power_user.allow_name2_display);
@@ -1205,7 +1208,12 @@ $(document).ready(() => {
power_user.never_resize_avatars = !!$(this).prop('checked');
saveSettingsDebounced();
});
-
+ $("#show_card_avatar_urls").on('input', function () {
+ power_user.show_card_avatar_urls = !!$(this).prop('checked');
+ printCharacters();
+ saveSettingsDebounced();
+ });
+
$("#play_message_sound").on('input', function () {
power_user.play_message_sound = !!$(this).prop('checked');
saveSettingsDebounced();
diff --git a/public/scripts/world-info.js b/public/scripts/world-info.js
index 7b7bdc710..81df9197f 100644
--- a/public/scripts/world-info.js
+++ b/public/scripts/world-info.js
@@ -250,7 +250,7 @@ function displayWorldEntries(name, data) {
return;
}
- const existingCharLores = world_info.charLore.filter((e) => e.extraBooks.includes(name));
+ const existingCharLores = world_info.charLore?.filter((e) => e.extraBooks.includes(name));
if (existingCharLores && existingCharLores.length > 0) {
existingCharLores.forEach((charLore) => {
const tempCharLore = charLore.extraBooks.filter((e) => e !== name);
@@ -627,7 +627,7 @@ async function renameWorldInfo(name, data) {
await saveWorldInfo(newName, data, true);
await deleteWorldInfo(oldName);
- const existingCharLores = world_info.charLore.filter((e) => e.extraBooks.includes(oldName));
+ const existingCharLores = world_info.charLore?.filter((e) => e.extraBooks.includes(oldName));
if (existingCharLores && existingCharLores.length > 0) {
existingCharLores.forEach((charLore) => {
const tempCharLore = charLore.extraBooks.filter((e) => e !== oldName);
@@ -747,7 +747,7 @@ async function getCharacterLore() {
// TODO: Maybe make the utility function not use the window context?
const fileName = getCharaFilename(this_chid);
- const extraCharLore = world_info.charLore.find((e) => e.name === fileName);
+ const extraCharLore = world_info.charLore?.find((e) => e.name === fileName);
if (extraCharLore) {
worldsToSearch = new Set([...worldsToSearch, ...extraCharLore.extraBooks]);
}
@@ -1183,6 +1183,76 @@ function onWorldInfoChange(_, text) {
saveSettingsDebounced();
}
+export async function importWorldInfo(file) {
+ if (!file) {
+ return;
+ }
+
+ const formData = new FormData();
+ formData.append('avatar', file);
+
+ try {
+ let jsonData;
+
+ if (file.name.endsWith('.png')) {
+ const buffer = new Uint8Array(await getFileBuffer(file));
+ jsonData = extractDataFromPng(buffer, 'naidata');
+ } else {
+ // File should be a JSON file
+ jsonData = await parseJsonFile(file);
+ }
+
+ if (jsonData === undefined || jsonData === null) {
+ toastr.error(`File is not valid: ${file.name}`);
+ return;
+ }
+
+ // Convert Novel Lorebook
+ if (jsonData.lorebookVersion !== undefined) {
+ console.log('Converting Novel Lorebook');
+ formData.append('convertedData', JSON.stringify(convertNovelLorebook(jsonData)));
+ }
+
+ // Convert Agnai Memory Book
+ if (jsonData.kind === 'memory') {
+ console.log('Converting Agnai Memory Book');
+ formData.append('convertedData', JSON.stringify(convertAgnaiMemoryBook(jsonData)));
+ }
+
+ // Convert Risu Lorebook
+ if (jsonData.type === 'risu') {
+ console.log('Converting Risu Lorebook');
+ formData.append('convertedData', JSON.stringify(convertRisuLorebook(jsonData)));
+ }
+ } catch (error) {
+ toastr.error(`Error parsing file: ${error}`);
+ return;
+ }
+
+ jQuery.ajax({
+ type: "POST",
+ url: "/importworldinfo",
+ data: formData,
+ beforeSend: () => { },
+ cache: false,
+ contentType: false,
+ processData: false,
+ success: async function (data) {
+ if (data.name) {
+ await updateWorldInfoList();
+
+ const newIndex = world_names.indexOf(data.name);
+ if (newIndex >= 0) {
+ $("#world_editor_select").val(newIndex).trigger('change');
+ }
+
+ toastr.info(`World Info "${data.name}" imported successfully!`);
+ }
+ },
+ error: (jqXHR, exception) => { },
+ });
+}
+
jQuery(() => {
$(document).ready(function () {
@@ -1219,72 +1289,7 @@ jQuery(() => {
$("#world_import_file").on("change", async function (e) {
const file = e.target.files[0];
- if (!file) {
- return;
- }
-
- const formData = new FormData($("#form_world_import").get(0));
-
- try {
- let jsonData;
-
- if (file.name.endsWith('.png')) {
- const buffer = new Uint8Array(await getFileBuffer(file));
- jsonData = extractDataFromPng(buffer, 'naidata');
- } else {
- // File should be a JSON file
- jsonData = await parseJsonFile(file);
- }
-
- if (jsonData === undefined || jsonData === null) {
- toastr.error(`File is not valid: ${file.name}`);
- return;
- }
-
- // Convert Novel Lorebook
- if (jsonData.lorebookVersion !== undefined) {
- console.log('Converting Novel Lorebook');
- formData.append('convertedData', JSON.stringify(convertNovelLorebook(jsonData)));
- }
-
- // Convert Agnai Memory Book
- if (jsonData.kind === 'memory') {
- console.log('Converting Agnai Memory Book');
- formData.append('convertedData', JSON.stringify(convertAgnaiMemoryBook(jsonData)));
- }
-
- // Convert Risu Lorebook
- if (jsonData.type === 'risu') {
- console.log('Converting Risu Lorebook');
- formData.append('convertedData', JSON.stringify(convertRisuLorebook(jsonData)));
- }
- } catch (error) {
- toastr.error(`Error parsing file: ${error}`);
- return;
- }
-
- jQuery.ajax({
- type: "POST",
- url: "/importworldinfo",
- data: formData,
- beforeSend: () => { },
- cache: false,
- contentType: false,
- processData: false,
- success: async function (data) {
- if (data.name) {
- await updateWorldInfoList();
-
- const newIndex = world_names.indexOf(data.name);
- if (newIndex >= 0) {
- $("#world_editor_select").val(newIndex).trigger('change');
- }
-
- toastr.info(`World Info "${data.name}" imported successfully!`);
- }
- },
- error: (jqXHR, exception) => { },
- });
+ await importWorldInfo(file);
// Will allow to select the same file twice in a row
$("#form_world_import").trigger("reset");
diff --git a/public/style.css b/public/style.css
index 8c42cc7ed..371570753 100644
--- a/public/style.css
+++ b/public/style.css
@@ -1311,13 +1311,14 @@ select option:not(:checked) {
margin: 0;
flex: 1;
border-radius: 7px;
+ height: auto;
}
#character_search_bar {
margin: 0;
flex: 1;
/* padding-left: 0.75em; */
- height: fit-content;
+ height: auto;
}
input[type=search]::-webkit-search-cancel-button {
@@ -1353,6 +1354,10 @@ input[type=search]:focus::-webkit-search-cancel-button {
font-weight: bolder;
}
+.ch_avatar_url {
+ float: right;
+}
+
.character_select .avatar {
align-self: center;
}
diff --git a/server.js b/server.js
index 1be7e7dba..97ca1ff78 100644
--- a/server.js
+++ b/server.js
@@ -1085,19 +1085,19 @@ async function charaWrite(img_url, data, target_img, response = undefined, mes =
async function tryReadImage(img_url, crop) {
try {
let rawImg = await jimp.read(img_url);
- let final_width = rawImg.bitmap.width, final_height = rawImg.bitmap.height
+ let final_width = rawImg.bitmap.width, final_height = rawImg.bitmap.height
// Apply crop if defined
if (typeof crop == 'object' && [crop.x, crop.y, crop.width, crop.height].every(x => typeof x === 'number')) {
rawImg = rawImg.crop(crop.x, crop.y, crop.width, crop.height);
- // Apply standard resize if requested
- if (crop.want_resize) {
- final_width = AVATAR_WIDTH
- final_height = AVATAR_HEIGHT
- }
+ // Apply standard resize if requested
+ if (crop.want_resize) {
+ final_width = AVATAR_WIDTH
+ final_height = AVATAR_HEIGHT
+ }
}
- const image = await rawImg.cover(final_width, final_height).getBufferAsync(jimp.MIME_PNG);
+ const image = await rawImg.cover(final_width, final_height).getBufferAsync(jimp.MIME_PNG);
return image;
}
// If it's an unsupported type of image (APNG) - just read the file as buffer
@@ -3857,6 +3857,116 @@ app.post('/upload_sprite', urlencodedParser, async (request, response) => {
}
});
+app.post('/import_custom', jsonParser, async (request, response) => {
+ if (!request.body.url) {
+ return response.sendStatus(400);
+ }
+
+ try {
+ const url = request.body.url;
+ let result;
+
+ const chubParsed = parseChubUrl(url);
+
+ if (chubParsed?.type === 'character') {
+ console.log('Downloading chub character:', chubParsed.id);
+ result = await downloadChubCharacter(chubParsed.id);
+ }
+ else if (chubParsed?.type === 'lorebook') {
+ console.log('Downloading chub lorebook:', chubParsed.id);
+ result = await downloadChubLorebook(chubParsed.id);
+ }
+ else {
+ return response.sendStatus(404);
+ }
+
+ response.set('Content-Type', result.fileType);
+ response.set('Content-Disposition', `attachment; filename="${result.fileName}"`);
+ response.set('X-Custom-Content-Type', chubParsed?.type);
+ return response.send(result.buffer);
+ } catch (error) {
+ console.log('Importing custom content failed', error);
+ return response.sendStatus(500);
+ }
+});
+
+async function downloadChubLorebook(id) {
+ const fetch = require('node-fetch').default;
+
+ const result = await fetch('https://api.chub.ai/api/lorebooks/download', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ "fullPath": id,
+ "format": "SILLYTAVERN",
+ }),
+ });
+
+ if (!result.ok) {
+ throw new Error('Failed to download lorebook');
+ }
+
+ const name = id.split('/').pop();
+ const buffer = await result.buffer();
+ const fileName = `${sanitize(name)}.json`;
+ const fileType = result.headers.get('content-type');
+
+ return { buffer, fileName, fileType };
+}
+
+async function downloadChubCharacter(id) {
+ const fetch = require('node-fetch').default;
+
+ const result = await fetch('https://api.chub.ai/api/characters/download', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ "format": "tavern",
+ "fullPath": id,
+ })
+ });
+
+ if (!result.ok) {
+ throw new Error('Failed to download character');
+ }
+
+ const buffer = await result.buffer();
+ const fileName = result.headers.get('content-disposition').split('filename=')[1];
+ const fileType = result.headers.get('content-type');
+
+ return { buffer, fileName, fileType };
+}
+
+function parseChubUrl(str) {
+ const splitStr = str.split('/');
+ const length = splitStr.length;
+
+ if (length < 2) {
+ return null;
+ }
+
+ const domainIndex = splitStr.indexOf('chub.ai');
+ const lastTwo = domainIndex !== -1 ? splitStr.slice(domainIndex + 1) : splitStr;
+
+ const firstPart = lastTwo[0].toLowerCase();
+
+ if (firstPart === 'characters' || firstPart === 'lorebooks') {
+ const type = firstPart === 'characters' ? 'character' : 'lorebook';
+ const id = type === 'character' ? lastTwo.slice(1).join('/') : lastTwo.join('/');
+ return {
+ id: id,
+ type: type
+ };
+ } else if (length === 2) {
+ return {
+ id: lastTwo.join('/'),
+ type: 'character'
+ };
+ }
+
+ return null;
+}
+
function importRisuSprites(data) {
try {
const name = data?.data?.name;