Feature/Perchance AI Character Chat import (#4211)

* Adding ZLib and Jimp imports.

* Adding checks to see if UUID or URL are from Perchance.

* Adding conversion for Perchance cards and avatar.

* Adding label and example for Perchance character import.

* Adding localization of Perchance import option.

* Lint dangling comma fix.

* Simplifying one liner arrow function.

* Checking .gz at the end of Perchance url.

* Refactoring.

* Handling Base64 avatars.

* Fixing issue with UUID and refactoring.

* Adding char name to Perchance UUID example.

* Undoing unwanted variable name change of avatarBuffer to defaultAvatarBuffer

* Adding null check.

* Minor adjustments: renaming variable and organizing imports.

* Simple refactoring and reducing level of console messages.

* Add character source for perchance

* Add null check

* Use slug for source

---------

Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
This commit is contained in:
Marcela Petra Ferraz de Novaes
2025-06-28 10:18:26 -03:00
committed by GitHub
parent 363192dd39
commit 34118bbdc2
18 changed files with 230 additions and 2 deletions
+1
View File
@@ -1380,6 +1380,7 @@
"char_import_7": "للمضيفين المسموح بهم)",
"char_import_8": "شخصية RisuRealm (رابط مباشر)",
"char_import_9": "شخصية Soulkyn (رابط مباشر)",
"char_import_10": "شخصية Perchance (رابط مباشر أو UUID + .gz)",
"Supports importing multiple characters.": "يدعم استيراد أحرف متعددة.",
"Write each URL or ID into a new line.": "اكتب كل عنوان URL أو معرف في سطر جديد.",
"Export for character": "تصدير للشخصية",
+1
View File
@@ -1380,6 +1380,7 @@
"char_import_7": "für erlaubte Hosts)",
"char_import_8": "RisuRealm-Charakter (Direktlink)",
"char_import_9": "Soulkyn-Charakter (Direktlink)",
"char_import_10": "Perchance-Charakter (Direktlink oder UUID + .gz)",
"Supports importing multiple characters.": "Unterstützt den Import mehrerer Zeichen.",
"Write each URL or ID into a new line.": "Schreiben Sie jede URL oder ID in eine neue Zeile.",
"Export for character": "Export für Zeichen",
+1
View File
@@ -1380,6 +1380,7 @@
"char_import_7": "para hosts permitidos)",
"char_import_8": "Personaje RisuRealm (Enlace directo)",
"char_import_9": "Personaje Soulkyn (Enlace directo)",
"char_import_10": "Personaje Perchance (enlace directo o UUID + .gz)",
"Supports importing multiple characters.": "Admite la importación de múltiples caracteres.",
"Write each URL or ID into a new line.": "Escriba cada URL o ID en una nueva línea.",
"Export for character": "Exportar para personaje",
+1
View File
@@ -1302,6 +1302,7 @@
"char_import_7": "pour les hôtes autorisés)",
"char_import_8": "Personnage de RisuRealm (lien direct)",
"char_import_9": "Personnage de Soulkyn (lien direct)",
"char_import_10": "Personnage de Perchance (lien direct ou UUID + .gz)",
"Supports importing multiple characters.": "Prend en charge l'importation de plusieurs caractères.",
"Write each URL or ID into a new line.": "Écrivez chaque URL ou identifiant dans une nouvelle ligne.",
"Export for character": "Exportation pour le personnage",
+1
View File
@@ -1380,6 +1380,7 @@
"char_import_7": "fyrir leyfilega gestgjafa)",
"char_import_8": "RisuRealm karakter (beinn hlekkur)",
"char_import_9": "Soulkyn karakter (beinn hlekkur)",
"char_import_10": "Perchance karakter (beinn hlekkur eða UUID + .gz)",
"Supports importing multiple characters.": "Styður innflutning á mörgum stöfum.",
"Write each URL or ID into a new line.": "Skrifaðu hverja vefslóð eða auðkenni í nýja línu.",
"Export for character": "Flytja út fyrir persónu",
+1
View File
@@ -1380,6 +1380,7 @@
"char_import_7": "per gli host consentiti)",
"char_import_8": "Personaggio RisuRealm (collegamento diretto)",
"char_import_9": "Personaggio Soulkyn (collegamento diretto)",
"char_import_10": "Perchance Personaggio (collegamento diretto o UUID + .gz)",
"Supports importing multiple characters.": "Supporta l'importazione di più caratteri.",
"Write each URL or ID into a new line.": "Scrivi ogni URL o ID in una nuova riga.",
"Export for character": "Esporta per carattere",
+1
View File
@@ -1382,6 +1382,7 @@
"char_import_7": "許可されたホストの場合)",
"char_import_8": "RisuRealm キャラクター (直接リンク)",
"char_import_9": "Soulkyn キャラクター (直接リンク)",
"char_import_10": "Perchance キャラクター (直接リンクまたは UUID + .gz)",
"Supports importing multiple characters.": "複数のキャラクターのインポートをサポートします。",
"Write each URL or ID into a new line.": "各 URL または ID を新しい行に入力します。",
"Export for character": "キャラクターのエクスポート",
+1
View File
@@ -1399,6 +1399,7 @@
"char_import_7": "허용된 호스트의 경우)",
"char_import_8": "RisuRealm 캐릭터 (직접링크)",
"char_import_9": "Soulkyn 캐릭터 (직접링크)",
"char_import_10": "Perchance 캐릭터 (직접 링크 또는 UUID + .gz)",
"Supports importing multiple characters.": "여러 문자 가져오기를 지원합니다.",
"Write each URL or ID into a new line.": "각 URL 또는 ID를 새 줄에 작성합니다.",
"Export for character": "캐릭터 내보내기",
+1
View File
@@ -1380,6 +1380,7 @@
"char_import_7": "voor toegestane hosts)",
"char_import_8": "RisuRealm-personage (directe link)",
"char_import_9": "Soulkyn-personage (directe link)",
"char_import_10": "Perchance-personage (directe link of UUID + .gz)",
"Supports importing multiple characters.": "Ondersteunt het importeren van meerdere tekens.",
"Write each URL or ID into a new line.": "Schrijf elke URL of ID op een nieuwe regel.",
"Export for character": "Exporteren voor karakter",
+1
View File
@@ -1380,6 +1380,7 @@
"char_import_7": "para hosts permitidos)",
"char_import_8": "Personagem RisuRealm (link direto)",
"char_import_9": "Personagem Soulkyn (link direto)",
"char_import_10": "Personagem Perchance (Link Direto ou UUID + .gz)",
"Supports importing multiple characters.": "Suporta importação de vários caracteres.",
"Write each URL or ID into a new line.": "Escreva cada URL ou ID em uma nova linha.",
"Export for character": "Exportar para personagem",
+1
View File
@@ -966,6 +966,7 @@
"char_import_6": "Прямая ссылка на PNG-файл (список разрешённых хостов находится в",
"char_import_7": ")",
"char_import_9": "Персонаж с Soulkyn (прямая ссылка)",
"char_import_10": "Персонаж с Perchance (прямая ссылка или UUID + .gz)",
"Grammar String": "Грамматика",
"GBNF or EBNF, depends on the backend in use. If you're using this you should know which.": "GBNF или EBNF, зависит от бэкенда. Если вы это используете, то, скорее всего, сами знаете, какой именно.",
"Account": "Аккаунт",
+1
View File
@@ -1380,6 +1380,7 @@
"char_import_7": "для дозволених хостів)",
"char_import_8": "Персонаж RisuRealm (пряме посилання)",
"char_import_9": "Персонаж Soulkyn (пряме посилання)",
"char_import_10": "Персонаж Perchance (пряме посилання або UUID + .gz)",
"Supports importing multiple characters.": "Підтримується імпорт кількох символів.",
"Write each URL or ID into a new line.": "Напишіть кожну URL-адресу або ідентифікатор у новому рядку.",
"Export for character": "Експорт для персонажа",
+1
View File
@@ -1380,6 +1380,7 @@
"char_import_7": "đối với các máy chủ được phép)",
"char_import_8": "RisuRealm (URL trực tiếp)",
"char_import_9": "Soulkyn (URL trực tiếp)",
"char_import_10": "Perchance (Nhập URL trực tiếp hoặc UUID + .gz)",
"Supports importing multiple characters.": "Hỗ trợ nhập nhiều ký tự.",
"Write each URL or ID into a new line.": "Viết mỗi URL hoặc ID vào một dòng mới.",
"Export for character": "Xuất cho nhân vật",
+1
View File
@@ -1894,6 +1894,7 @@
"char_import_7": "",
"char_import_8": "RisuRealm 角色(直链)",
"char_import_9": "Soulkyn 角色(直链)",
"char_import_10": "Perchance 角色(直链或UUID + .gz",
"Supports importing multiple characters.": "支持导入多个角色。",
"Write each URL or ID into a new line.": "将每个 URL 或 ID 写入新行。",
"Enter the Git URL of the extension to install": "输入扩展程序的 Git URL 以安装",
+1
View File
@@ -1385,6 +1385,7 @@
"char_import_7": "對於允許的主機)",
"char_import_8": "RisuRealm 角色(直接連結)",
"char_import_9": "Soulkyn 角色(直接連結)",
"char_import_10": "Perchance 角色(直接連結或 ID + .gz",
"Supports importing multiple characters.": "支援匯入多個字元。",
"Write each URL or ID into a new line.": "將每個 URL 或 ID 寫入新行。",
"Export for character": "匯出字元",
+6
View File
@@ -1836,6 +1836,12 @@ function getCharacterSource(chId = this_chid) {
return `https://realm.risuai.net/character/${realmId}`;
}
const perchanceSlug = characters[chId]?.data?.extensions?.perchance_data?.slug;
if (perchanceSlug) {
return `https://perchance.org/ai-character-chat?data=${perchanceSlug}`;
}
return '';
}
@@ -11,6 +11,7 @@
<li><span data-i18n="char_import_6">Direct PNG Link (refer to</span> <code>config.yaml</code><span data-i18n="char_import_7"> for allowed hosts)</span><br><span data-i18n="char_import_example">Example:</span> <tt>https://files.catbox.moe/notarealfile.png</tt></li>
<li><span data-i18n="char_import_8">RisuRealm Character (Direct Link)</span><br><span data-i18n="char_import_example">Example:</span> <tt>https://realm.risuai.net/character/3ca54c71-6efe-46a2-b9d0-4f62df23d712</tt></li>
<li><span data-i18n="char_import_9">Soulkyn Character (Direct Link)</span><br><span data-i18n="char_import_example">Example:</span> <tt>https://soulkyn.com/l/en-US/@haruka-509al</tt></li>
<li><span data-i18n="char_import_10">Perchance Character (Direct Link or UUID + .gz)</span><br><span data-i18n="char_import_example">Example:</span> <tt>https://perchance.org/ai-character-chat?data=Loreena~cb3a1b531477378db7cad0148ba62d71.gz</tt><br><span data-i18n="char_import_example">Example:</span> <tt>Loreena~cb3a1b531477378db7cad0148ba62d71.gz</tt></li>
</ul>
</div>
<small>
+208 -2
View File
@@ -1,15 +1,17 @@
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
import { Buffer } from 'node:buffer';
import express from 'express';
import fetch from 'node-fetch';
import sanitize from 'sanitize-filename';
import { sync as writeFileAtomicSync } from 'write-file-atomic';
import { sync as writeFileAtomicSync } from 'write-file-atomic';
import { getConfigValue, color, setPermissionsSync } from '../util.js';
import { getConfigValue, color, setPermissionsSync, isValidUrl } from '../util.js';
import { write } from '../character-card-parser.js';
import { serverDirectory } from '../server-directory.js';
import { Jimp, JimpMime } from '../jimp.js';
import { DEFAULT_AVATAR_PATH } from '../constants.js';
const contentDirectory = path.join(serverDirectory, 'default/content');
@@ -818,6 +820,193 @@ async function downloadSoulkynCharacter(slug) {
return null;
}
/** * Check if the given string is a valid Perchance UUID.
* @param {string} uuid UUID string to check
* @returns {boolean} True if the UUID is valid, false otherwise
*/
function isPerchanceUUID(uuid) {
if (!uuid) {
return false;
}
//example: Personality_Advisor~6903e991c90fd1dba52c036d917e99c6.gz
//charactername~uuid.gz
const uuidRegex = /^\w+~[a-f0-9]{32}\.gz$/;
return uuidRegex.test(uuid);
}
/**
* Parse Perchance URL to extract the character slug.
* @param {string} url Perchance character URL
* @returns {string} Slug of the character
*/
function parsePerchanceSlug(url) {
// Example: https://perchance.org/ai-character-chat?data=Personality_Advisor~6903e991c90fd1dba52c036d917e99c6.gz
// or: Personality_Advisor~6903e991c90fd1dba52c036d917e99c6.gz
return url?.split('~')[1] || '';
}
/**
* Download Perchance character card
* @param {string} slug Slug of the character
* @returns {Promise<{buffer: Buffer, fileName: string, fileType: string} | null>}
*/
async function downloadPerchanceCharacter(slug) {
// example of slug
// 6903e991c90fd1dba52c036d917e99c6.gz
const perchanceBaseURL = 'https://user.uploads.dev/file';
try {
const charURL = `${perchanceBaseURL}/${slug}`;
console.log('Downloading Perchance character from URL:', charURL);
const result = await fetch(charURL, {
headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
});
//decompress gzipped content
if (result.ok) {
const perchanceChar = await extractPerchanceCharacterFromGz(result);
const avatarUrl = perchanceChar.avatar?.url;
//check if avatarURL is a base64 of any image type
const isAvatarBase64 = avatarUrl && avatarUrl.startsWith('data:image/');
const charData = {
name: perchanceChar.name || 'Unnamed Perchance Character',
first_mes: '',
tags: [],
description: perchanceChar.roleInstruction || '',
creator: perchanceChar.metaTitle || '',
creator_notes: perchanceChar.metaDescription || '',
alternate_greetings: [],
character_version: '',
mes_example: '',
post_history_instructions: '',
system_prompt: '',
scenario: '',
personality: perchanceChar.reminderMessage || '',
extensions: {
perchance_data: {
slug: slug,
char_url: charURL,
uuid: perchanceChar.uuid || null,
avatar_url: isAvatarBase64 ? null : (avatarUrl || null),
folder_path: perchanceChar.folderPath || null,
folder_name: perchanceChar.folderName || null,
custom_data: perchanceChar.customData || {},
},
},
};
const avatarBuffer = await fetchPerchanceAvatar(avatarUrl, isAvatarBase64);
// Character card
const buffer = write(avatarBuffer, JSON.stringify({
'spec': 'chara_card_v2',
'spec_version': '2.0',
'data': charData,
}));
const fileName = `${charData.name}.png`;
const fileType = 'image/png';
return { buffer, fileName, fileType };
}
} catch (error) {
console.error('Error downloading character:', error);
throw error;
}
return null;
}
/** * Extracts Perchance character data from a gzipped response.
* @param {import('node-fetch').Response} result Fetch response containing gzipped character data
* @returns {Promise<Object>} Parsed Perchance character data
* @throws {Error} If the character data is invalid or missing required fields
*/
async function extractPerchanceCharacterFromGz(result) {
const compressedBuffer = Buffer.from(await result.arrayBuffer());
const decompressedBuffer = zlib.gunzipSync(compressedBuffer);
// inside the gz file, there is a file of the same name without extensions, but it is a json file
if (!decompressedBuffer || decompressedBuffer.length === 0) {
console.error('Perchance character data is empty or invalid');
throw new Error('Failed to download character: Invalid Perchance character data');
}
// Parse the decompressed JSON
const perchanceCharData = JSON.parse(decompressedBuffer.toString());
if (!perchanceCharData?.addCharacter) {
console.error('Perchance character data is missing addCharacter field', perchanceCharData);
throw new Error('Failed to download character: Invalid Perchance character data');
}
return perchanceCharData.addCharacter;
}
/** * Fetches the avatar from Perchance URL or uses a default avatar if not available.
* @param {string} avatarUrl URL of the avatar
* @param {boolean} isAvatarBase64 Flag indicating if the avatar URL is a base64 string
* @returns {Promise<Buffer>} Buffer containing the avatar image
*/
async function fetchPerchanceAvatar(avatarUrl, isAvatarBase64) {
const defaultAvatarPath = path.join(serverDirectory, DEFAULT_AVATAR_PATH);
const defaultAvatarBuffer = fs.readFileSync(defaultAvatarPath);
if (!avatarUrl || (!isAvatarBase64 && !isValidUrl(avatarUrl))) {
console.warn('Perchance character does not have an avatar, it is not base64, or it is an invalid url, using default avatar');
return defaultAvatarBuffer;
}
if (isAvatarBase64) {
// check if avatarUrl is a png
const isPng = avatarUrl.startsWith('data:image/png;base64,');
const base64 = avatarUrl.split(',')[1];
const buffer = Buffer.from(base64, 'base64');
if (isPng) {
return buffer;
} else {
// use jimp to convert the base64 to PNG if it's not PNG
console.trace('Perchance character avatar is not PNG, converting to PNG...');
return await Jimp.read(buffer).then(image => image.getBuffer(JimpMime.png));
}
}
// Fetch avatar from URL
console.log('Fetching Perchance avatar from URL:', avatarUrl);
const avatarResponse = await fetch(avatarUrl, { headers: { 'User-Agent': USER_AGENT } });
if (avatarResponse.ok) {
const avatarContentType = avatarResponse.headers.get('content-type');
const avatarBuffer = Buffer.from(await avatarResponse.arrayBuffer());
if (avatarContentType === 'image/png') {
return avatarBuffer;
} else {
console.trace(`Perchance character avatar is not PNG: ${avatarContentType}. Converting to PNG...`);
// use jimp to convert the image to PNG if it's not PNG
return await Jimp.read(avatarBuffer)
.then(image => image.getBuffer(JimpMime.png));
}
}
console.error('Failed to fetch Perchance avatar:', avatarResponse.statusText);
const isPerchanceOrgFileUploader = avatarUrl.includes('https://user-uploads.perchance.org');
if (isPerchanceOrgFileUploader) {
console.warn('Files from https://user-uploads.perchance.org are sometimes blocked by CloudFlare, try reuploading it in https://perchance.org/upload to get the new link from https://user-uploads.dev instead.');
}
console.warn('You can also download the avatar manually and assign it to the character:', avatarUrl);
return defaultAvatarBuffer;
}
/**
* @param {String} url
* @returns {String | null } UUID of the character
@@ -874,6 +1063,7 @@ router.post('/importURL', async (request, response) => {
const isAICharacterCardsContent = host.includes('aicharactercards.com');
const isRisu = host.includes('realm.risuai.net');
const isSoulkyn = host.includes('soulkyn.com');
const isPerchance = host.includes('perchance.org');
const isGeneric = isHostWhitelisted(host);
if (isPygmalionContent) {
@@ -929,6 +1119,13 @@ router.post('/importURL', async (request, response) => {
}
type = 'character';
result = await downloadSoulkynCharacter(soulkynSlug);
} else if (isPerchance) {
const perchanceSlug = parsePerchanceSlug(url);
if (!perchanceSlug) {
return response.sendStatus(404);
}
type = 'character';
result = await downloadPerchanceCharacter(perchanceSlug);
} else if (isGeneric) {
console.info('Downloading from generic url:', url);
type = 'character';
@@ -964,6 +1161,7 @@ router.post('/importUUID', async (request, response) => {
const isJannny = uuid.includes('_character');
const isPygmalion = (!isJannny && uuid.length == 36);
const isAICC = uuid.startsWith('AICC/');
const isPerchance = isPerchanceUUID(uuid);
const uuidType = uuid.includes('lorebook') ? 'lorebook' : 'character';
if (isPygmalion) {
@@ -976,6 +1174,10 @@ router.post('/importUUID', async (request, response) => {
const [, author, card] = uuid.split('/');
console.info('Downloading AICC character:', `${author}/${card}`);
result = await downloadAICCCharacter(`${author}/${card}`);
} else if (isPerchance) {
console.info('Downloading Perchance character:', uuid);
const parsedUuid = parsePerchanceSlug(uuid);
result = await downloadPerchanceCharacter(parsedUuid);
} else {
if (uuidType === 'character') {
console.info('Downloading chub character:', uuid);
@@ -990,6 +1192,10 @@ router.post('/importUUID', async (request, response) => {
}
}
if (!result) {
throw new Error('Failed to download content');
}
if (result.fileType) response.set('Content-Type', result.fileType);
response.set('Content-Disposition', `attachment; filename="${result.fileName}"`);
response.set('X-Custom-Content-Type', uuidType);