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

View File

@@ -1379,6 +1379,7 @@
"char_import_6": "Direkter PNG-Link (siehe", "char_import_6": "Direkter PNG-Link (siehe",
"char_import_7": "für erlaubte Hosts)", "char_import_7": "für erlaubte Hosts)",
"char_import_8": "RisuRealm-Charakter (Direktlink)", "char_import_8": "RisuRealm-Charakter (Direktlink)",
"char_import_9": "Soulkyn-Charakter (Direktlink)",
"Supports importing multiple characters.": "Unterstützt den Import mehrerer Zeichen.", "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.", "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", "Export for character": "Export für Zeichen",

View File

@@ -1379,6 +1379,7 @@
"char_import_6": "Enlace PNG directo (consulte", "char_import_6": "Enlace PNG directo (consulte",
"char_import_7": "para hosts permitidos)", "char_import_7": "para hosts permitidos)",
"char_import_8": "Personaje RisuRealm (Enlace directo)", "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.", "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.", "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", "Export for character": "Exportar para personaje",

View File

@@ -1301,6 +1301,7 @@
"char_import_6": "Lien PNG direct (voir", "char_import_6": "Lien PNG direct (voir",
"char_import_7": "pour les hôtes autorisés)", "char_import_7": "pour les hôtes autorisés)",
"char_import_8": "Personnage de RisuRealm (lien direct)", "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.", "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.", "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", "Export for character": "Exportation pour le personnage",

View File

@@ -1379,6 +1379,7 @@
"char_import_6": "Beinn PNG hlekkur (sjá", "char_import_6": "Beinn PNG hlekkur (sjá",
"char_import_7": "fyrir leyfilega gestgjafa)", "char_import_7": "fyrir leyfilega gestgjafa)",
"char_import_8": "RisuRealm karakter (beinn hlekkur)", "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.", "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.", "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", "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_6": "Collegamento PNG diretto (fare riferimento a",
"char_import_7": "per gli host consentiti)", "char_import_7": "per gli host consentiti)",
"char_import_8": "Personaggio RisuRealm (collegamento diretto)", "char_import_8": "Personaggio RisuRealm (collegamento diretto)",
"char_import_9": "Personaggio Soulkyn (collegamento diretto)",
"Supports importing multiple characters.": "Supporta l'importazione di più caratteri.", "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.", "Write each URL or ID into a new line.": "Scrivi ogni URL o ID in una nuova riga.",
"Export for character": "Esporta per carattere", "Export for character": "Esporta per carattere",

View File

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

View File

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

View File

@@ -1379,6 +1379,7 @@
"char_import_6": "Directe PNG-link (zie", "char_import_6": "Directe PNG-link (zie",
"char_import_7": "voor toegestane hosts)", "char_import_7": "voor toegestane hosts)",
"char_import_8": "RisuRealm-personage (directe link)", "char_import_8": "RisuRealm-personage (directe link)",
"char_import_9": "Soulkyn-personage (directe link)",
"Supports importing multiple characters.": "Ondersteunt het importeren van meerdere tekens.", "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.", "Write each URL or ID into a new line.": "Schrijf elke URL of ID op een nieuwe regel.",
"Export for character": "Exporteren voor karakter", "Export for character": "Exporteren voor karakter",

View File

@@ -1379,6 +1379,7 @@
"char_import_6": "Link PNG direto (consulte", "char_import_6": "Link PNG direto (consulte",
"char_import_7": "para hosts permitidos)", "char_import_7": "para hosts permitidos)",
"char_import_8": "Personagem RisuRealm (link direto)", "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.", "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.", "Write each URL or ID into a new line.": "Escreva cada URL ou ID em uma nova linha.",
"Export for character": "Exportar para personagem", "Export for character": "Exportar para personagem",

View File

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

View File

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

View File

@@ -1379,6 +1379,7 @@
"char_import_6": "Nhập PNG trực tiếp (tham khảo", "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_7": "đối với các máy chủ được phép)",
"char_import_8": "RisuRealm (URL trực tiế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ự.", "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.", "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", "Export for character": "Xuất cho nhân vật",

View File

@@ -1893,6 +1893,7 @@
"char_import_6": "被允许的PNG直链请参阅", "char_import_6": "被允许的PNG直链请参阅",
"char_import_7": "", "char_import_7": "",
"char_import_8": "RisuRealm 角色(直链)", "char_import_8": "RisuRealm 角色(直链)",
"char_import_9": "Soulkyn 角色(直链)",
"Supports importing multiple characters.": "支持导入多个角色。", "Supports importing multiple characters.": "支持导入多个角色。",
"Write each URL or ID into a new line.": "将每个 URL 或 ID 写入新行。", "Write each URL or ID into a new line.": "将每个 URL 或 ID 写入新行。",
"Enter the Git URL of the extension to install": "输入扩展程序的 Git URL 以安装", "Enter the Git URL of the extension to install": "输入扩展程序的 Git URL 以安装",

View File

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

View File

@@ -636,6 +636,187 @@ async function downloadRisuCharacter(uuid) {
return { buffer, fileName, fileType }; 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 * @param {String} url
* @returns {String | null } UUID of the character * @returns {String | null } UUID of the character
@@ -691,6 +872,7 @@ router.post('/importURL', async (request, response) => {
const isPygmalionContent = host.includes('pygmalion.chat'); const isPygmalionContent = host.includes('pygmalion.chat');
const isAICharacterCardsContent = host.includes('aicharactercards.com'); const isAICharacterCardsContent = host.includes('aicharactercards.com');
const isRisu = host.includes('realm.risuai.net'); const isRisu = host.includes('realm.risuai.net');
const isSoulkyn = host.includes('soulkyn.com');
const isGeneric = isHostWhitelisted(host); const isGeneric = isHostWhitelisted(host);
if (isPygmalionContent) { if (isPygmalionContent) {
@@ -739,6 +921,13 @@ router.post('/importURL', async (request, response) => {
type = 'character'; type = 'character';
result = await downloadRisuCharacter(uuid); 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) { } else if (isGeneric) {
console.info('Downloading from generic url:', url); console.info('Downloading from generic url:', url);
type = 'character'; type = 'character';