Import character card from Soulkyn (#4042)

This commit is contained in:
buzz
2025-05-27 21:30:15 +02:00
committed by GitHub
parent e2222ac40a
commit 4bec90abb5
17 changed files with 205 additions and 0 deletions

View File

@@ -1379,6 +1379,7 @@
"char_import_6": "رابط PNG المباشر (راجع",
"char_import_7": "للمضيفين المسموح بهم)",
"char_import_8": "شخصية RisuRealm (رابط مباشر)",
"char_import_9": "شخصية Soulkyn (رابط مباشر)",
"Supports importing multiple characters.": "يدعم استيراد أحرف متعددة.",
"Write each URL or ID into a new line.": "اكتب كل عنوان URL أو معرف في سطر جديد.",
"Export for character": "تصدير للشخصية",

View File

@@ -1379,6 +1379,7 @@
"char_import_6": "Direkter PNG-Link (siehe",
"char_import_7": "für erlaubte Hosts)",
"char_import_8": "RisuRealm-Charakter (Direktlink)",
"char_import_9": "Soulkyn-Charakter (Direktlink)",
"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",

View File

@@ -1379,6 +1379,7 @@
"char_import_6": "Enlace PNG directo (consulte",
"char_import_7": "para hosts permitidos)",
"char_import_8": "Personaje RisuRealm (Enlace directo)",
"char_import_9": "Personaje Soulkyn (Enlace directo)",
"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",

View File

@@ -1301,6 +1301,7 @@
"char_import_6": "Lien PNG direct (voir",
"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)",
"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",

View File

@@ -1379,6 +1379,7 @@
"char_import_6": "Beinn PNG hlekkur (sjá",
"char_import_7": "fyrir leyfilega gestgjafa)",
"char_import_8": "RisuRealm karakter (beinn hlekkur)",
"char_import_9": "Soulkyn karakter (beinn hlekkur)",
"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",

View File

@@ -1379,6 +1379,7 @@
"char_import_6": "Collegamento PNG diretto (fare riferimento a",
"char_import_7": "per gli host consentiti)",
"char_import_8": "Personaggio RisuRealm (collegamento diretto)",
"char_import_9": "Personaggio Soulkyn (collegamento diretto)",
"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",

View File

@@ -1381,6 +1381,7 @@
"char_import_6": "直接PNGリンク参照",
"char_import_7": "許可されたホストの場合)",
"char_import_8": "RisuRealm キャラクター (直接リンク)",
"char_import_9": "Soulkyn キャラクター (直接リンク)",
"Supports importing multiple characters.": "複数のキャラクターのインポートをサポートします。",
"Write each URL or ID into a new line.": "各 URL または ID を新しい行に入力します。",
"Export for character": "キャラクターのエクスポート",

View File

@@ -1398,6 +1398,7 @@
"char_import_6": "직접 PNG 링크(참조",
"char_import_7": "허용된 호스트의 경우)",
"char_import_8": "RisuRealm 캐릭터 (직접링크)",
"char_import_9": "Soulkyn 캐릭터 (직접링크)",
"Supports importing multiple characters.": "여러 문자 가져오기를 지원합니다.",
"Write each URL or ID into a new line.": "각 URL 또는 ID를 새 줄에 작성합니다.",
"Export for character": "캐릭터 내보내기",

View File

@@ -1379,6 +1379,7 @@
"char_import_6": "Directe PNG-link (zie",
"char_import_7": "voor toegestane hosts)",
"char_import_8": "RisuRealm-personage (directe link)",
"char_import_9": "Soulkyn-personage (directe link)",
"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",

View File

@@ -1379,6 +1379,7 @@
"char_import_6": "Link PNG direto (consulte",
"char_import_7": "para hosts permitidos)",
"char_import_8": "Personagem RisuRealm (link direto)",
"char_import_9": "Personagem Soulkyn (link direto)",
"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",

View File

@@ -965,6 +965,7 @@
"char_import_5": "Персонаж с AICharacterCards.com (прямая ссылка или ID)",
"char_import_6": "Прямая ссылка на PNG-файл (список разрешённых хостов находится в",
"char_import_7": ")",
"char_import_9": "Персонаж с Soulkyn (прямая ссылка)",
"Grammar String": "Грамматика",
"GBNF or EBNF, depends on the backend in use. If you're using this you should know which.": "GBNF или EBNF, зависит от бэкенда. Если вы это используете, то, скорее всего, сами знаете, какой именно.",
"Account": "Аккаунт",

View File

@@ -1379,6 +1379,7 @@
"char_import_6": "Пряме посилання на PNG (див",
"char_import_7": "для дозволених хостів)",
"char_import_8": "Персонаж RisuRealm (пряме посилання)",
"char_import_9": "Персонаж Soulkyn (пряме посилання)",
"Supports importing multiple characters.": "Підтримується імпорт кількох символів.",
"Write each URL or ID into a new line.": "Напишіть кожну URL-адресу або ідентифікатор у новому рядку.",
"Export for character": "Експорт для персонажа",

View File

@@ -1379,6 +1379,7 @@
"char_import_6": "Nhập PNG trực tiếp (tham khảo",
"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)",
"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",

View File

@@ -1893,6 +1893,7 @@
"char_import_6": "被允许的PNG直链请参阅",
"char_import_7": "",
"char_import_8": "RisuRealm 角色(直链)",
"char_import_9": "Soulkyn 角色(直链)",
"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 以安装",

View File

@@ -1384,6 +1384,7 @@
"char_import_6": "直接 PNG 連結(請參閱",
"char_import_7": "對於允許的主機)",
"char_import_8": "RisuRealm 角色(直接連結)",
"char_import_9": "Soulkyn 角色(直接連結)",
"Supports importing multiple characters.": "支援匯入多個字元。",
"Write each URL or ID into a new line.": "將每個 URL 或 ID 寫入新行。",
"Export for character": "匯出字元",

View File

@@ -10,6 +10,7 @@
<li><span data-i18n="char_import_5">AICharacterCards.com Character (Direct Link or ID)</span><br><span data-i18n="char_import_example">Example:</span> <tt>AICC/aicharcards/the-game-master</tt></li>
<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>
</ul>
</div>
<small>

View File

@@ -636,6 +636,187 @@ async function downloadRisuCharacter(uuid) {
return { buffer, fileName, fileType };
}
/**
* Parse Soulkyn URL to extract the character slug.
* @param {string} url Soulkyn character URL
* @returns {string | null} Slug of the character
*/
function parseSoulkynUrl(url) {
// Example: https://soulkyn.com/l/en-US/@kayla-marie
const pattern = /^https:\/\/soulkyn\.com\/l\/[a-z]{2}-[A-Z]{2}\/@([\w\d-]+)/i;
const match = url.match(pattern);
return match ? match[1] : null;
}
/**
* Download Soulkyn character card
* @param {string} slug Slug of the character
* @returns {Promise<{buffer: Buffer, fileName: string, fileType: string} | null>}
*/
async function downloadSoulkynCharacter(slug) {
const soulkynReplacements = [
// https://soulkyn.com/l/en-US/help/character-backgrounds-advanced#variables-you-can-use-in-character-background-text
{ pattern: /__USER_?NAME__/gi, replacement: '{{user}}' },
{ pattern: /__PERSONA_?NAME__/gi, replacement: '{{char}}' },
// ST doesn't support gender-specific pronoun macros
{ pattern: /__U_PRONOUN_1__/gi, replacement: 'they' },
{ pattern: /__U_PRONOUN_2__/gi, replacement: 'them' },
{ pattern: /__U_PRONOUN_3__/gi, replacement: 'their' },
{ pattern: /__U_PRONOUN_4__/gi, replacement: 'themselves' },
{ pattern: /__(USER_)?PRONOUN__/gi, replacement: 'they' },
{ pattern: /__(USER_)?CPRONOUN__/gi, replacement: 'them' },
{ pattern: /__(USER_)?UPRONOUN__/gi, replacement: 'their' },
// HTML tags -> Markdown syntax
{ pattern: /<(strong|b)>/gi, replacement: '**' },
{ pattern: /<\/(strong|b)>/gi, replacement: '**' },
{ pattern: /<(em|i)>/gi, replacement: '*' },
{ pattern: /<\/(em|i)>/gi, replacement: '*' },
];
const normalizeContent = (str) => soulkynReplacements.reduce((acc, { pattern, replacement }) => acc.replace(pattern, replacement), str);
try {
const url = `https://soulkyn.com/_special/rest/Sk/public/Persona/${slug}`;
const result = await fetch(url, {
headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
});
if (result.ok) {
/** @type {any} */
const soulkynCharData = await result.json();
if (soulkynCharData.result !== 'success') {
console.error('Soulkyn returned error', soulkynCharData.message);
throw new Error(`Failed to download character: ${soulkynCharData.message}`);
}
// Fetch avatar
let avatarBuffer = null;
if (soulkynCharData.data?.Avatar?.FWSUUID) {
const avatarUrl = `https://rub.soulkyn.com/${soulkynCharData.data.Avatar.FWSUUID}/`;
const avatarResult = await fetch(avatarUrl, { headers: { 'User-Agent': USER_AGENT } });
if (avatarResult.ok) {
const avatarContentType = avatarResult.headers.get('content-type');
if (avatarContentType === 'image/png') {
avatarBuffer = Buffer.from(await avatarResult.arrayBuffer());
} else {
console.warn(`Soulkyn character (${slug}) avatar is not PNG: ${avatarContentType}`);
}
} else {
console.warn(`Soulkyn character (${slug}) avatar download failed: ${avatarResult.status}`);
}
} else {
console.warn(`Soulkyn character (${slug}) does not have an avatar`);
}
// Fallback to default avatar
if (!avatarBuffer) {
const defaultAvatarPath = path.join(serverDirectory, 'public', 'img', 'ai4.png');
avatarBuffer = fs.readFileSync(defaultAvatarPath);
}
const d = soulkynCharData.data;
soulkynReplacements.push({ pattern: d.Username, replacement: '{{char}}' });
// Parse Soulkyn data into character chard
const charData = {
name: d.Username,
first_mes: '',
tags: [],
description: '',
creator: d.User.Username,
creator_notes: '',
alternate_greetings: [],
character_version: '',
mes_example: '',
post_history_instructions: '',
system_prompt: '',
scenario: '',
personality: '',
extensions: {
soulkyn_slug: slug,
soulkyn_id: d.UUID,
},
};
if (d?.PersonaIntroText) {
const match = d.PersonaIntroText.match(/^(?:\[Scenario:\s*([\s\S]*?)\]\s*)?([\s\S]*)$/);
if (match) {
if (match[1]) {
charData.scenario = normalizeContent(match[1].trim());
}
charData.first_mes = normalizeContent(match[2].trim());
}
}
const descriptionArr = ['Name: {{char}}'];
if (d?.Version?.Age) {
descriptionArr.push(`Age: ${d.Version.Age}`);
}
if (d?.Version?.Gender) {
descriptionArr.push(`Gender: ${d.Version.Gender}`);
}
if (d?.Version?.Race?.Name && !d.Version.Race.Name.match(/no preset/i)) {
let race = d.Version.Race.Name;
if (d.Version.Race?.Description) {
race += ` (${d.Version.Race.Description})`;
}
descriptionArr.push(`Race: ${race}`);
}
if (d?.PersonalityType) {
descriptionArr.push(`Personality type: ${d.PersonalityType}`);
}
if (Array.isArray(d?.Version?.PropertyPersonality)) {
const traits = d.Version.PropertyPersonality.map((t) => t.Value).join(', ');
descriptionArr.push(`Personality Traits: ${traits}`);
}
if (Array.isArray(d?.Version?.PropertyPhysical)) {
const traits = d.Version.PropertyPhysical.map((t) => t.Value).join(', ');
descriptionArr.push(`Physical Traits: ${traits}`);
}
if (Array.isArray(d?.Clothes?.Preset)) {
descriptionArr.push(`Clothes: ${d.Clothes.Preset.join(', ')}`);
}
if (d?.Avatar?.Caption) {
descriptionArr.push(`Image description featuring {{char}}: ${d.Avatar.Caption.replace(/\n+/g, ' ')}`);
}
if (d?.Version?.WelcomeMessage) {
if (charData.first_mes) {
descriptionArr.push(`{{char}}'s self-description: "${d.Version.WelcomeMessage}"`);
} else {
// Some characters lack `PersonaIntroText`. In that case we use `Version.WelcomeMessage` for `first_mes`
charData.first_mes = normalizeContent(d.Version.WelcomeMessage);
}
}
charData.description = normalizeContent(descriptionArr.join('\n'));
if (Array.isArray(d?.Version?.ChatExamplesValue)) {
charData.mes_example = d.Version.ChatExamplesValue.map((example) => `<START>\n${normalizeContent(example)}`).join('\n');
}
if (Array.isArray(d?.PersonaTags)) {
charData.tags = d.PersonaTags.map((t) => t.Slug);
}
// Character card
const buffer = write(avatarBuffer, JSON.stringify({
'spec': 'chara_card_v2',
'spec_version': '2.0',
'data': charData,
}));
const fileName = `${sanitize(d.UUID)}.png`;
const fileType = 'image/png';
return { buffer, fileName, fileType };
}
} catch (error) {
console.error('Error downloading character:', error);
throw error;
}
return null;
}
/**
* @param {String} url
* @returns {String | null } UUID of the character
@@ -691,6 +872,7 @@ router.post('/importURL', async (request, response) => {
const isPygmalionContent = host.includes('pygmalion.chat');
const isAICharacterCardsContent = host.includes('aicharactercards.com');
const isRisu = host.includes('realm.risuai.net');
const isSoulkyn = host.includes('soulkyn.com');
const isGeneric = isHostWhitelisted(host);
if (isPygmalionContent) {
@@ -739,6 +921,13 @@ router.post('/importURL', async (request, response) => {
type = 'character';
result = await downloadRisuCharacter(uuid);
} else if (isSoulkyn) {
const soulkynSlug = parseSoulkynUrl(url);
if (!soulkynSlug) {
return response.sendStatus(404);
}
type = 'character';
result = await downloadSoulkynCharacter(soulkynSlug);
} else if (isGeneric) {
console.info('Downloading from generic url:', url);
type = 'character';