Import character card from Soulkyn (#4042)

4bec90abb5faf80e36ac5a8d70d91cd5973d11e4

buzz <buzz@users.noreply.github.com>

Signed
17 files changed, +205 -0Showing whitespace changes
public/locales/ar-sa.json+1 -0
@@ -1379,6 +1379,7 @@
13791379 "char_import_6": "رابط PNG المباشر (راجع",
13801380 "char_import_7": "للمضيفين المسموح بهم)",
13811381 "char_import_8": "شخصية RisuRealm (رابط مباشر)",
1382+ "char_import_9": "شخصية Soulkyn (رابط مباشر)",
13821383 "Supports importing multiple characters.": "يدعم استيراد أحرف متعددة.",
13831384 "Write each URL or ID into a new line.": "اكتب كل عنوان URL أو معرف في سطر جديد.",
13841385 "Export for character": "تصدير للشخصية",
public/locales/de-de.json+1 -0
@@ -1379,6 +1379,7 @@
13791379 "char_import_6": "Direkter PNG-Link (siehe",
13801380 "char_import_7": "für erlaubte Hosts)",
13811381 "char_import_8": "RisuRealm-Charakter (Direktlink)",
1382+ "char_import_9": "Soulkyn-Charakter (Direktlink)",
13821383 "Supports importing multiple characters.": "Unterstützt den Import mehrerer Zeichen.",
13831384 "Write each URL or ID into a new line.": "Schreiben Sie jede URL oder ID in eine neue Zeile.",
13841385 "Export for character": "Export für Zeichen",
public/locales/es-es.json+1 -0
@@ -1379,6 +1379,7 @@
13791379 "char_import_6": "Enlace PNG directo (consulte",
13801380 "char_import_7": "para hosts permitidos)",
13811381 "char_import_8": "Personaje RisuRealm (Enlace directo)",
1382+ "char_import_9": "Personaje Soulkyn (Enlace directo)",
13821383 "Supports importing multiple characters.": "Admite la importación de múltiples caracteres.",
13831384 "Write each URL or ID into a new line.": "Escriba cada URL o ID en una nueva línea.",
13841385 "Export for character": "Exportar para personaje",
public/locales/fr-fr.json+1 -0
@@ -1301,6 +1301,7 @@
13011301 "char_import_6": "Lien PNG direct (voir",
13021302 "char_import_7": "pour les hôtes autorisés)",
13031303 "char_import_8": "Personnage de RisuRealm (lien direct)",
1304+ "char_import_9": "Personnage de Soulkyn (lien direct)",
13041305 "Supports importing multiple characters.": "Prend en charge l'importation de plusieurs caractères.",
13051306 "Write each URL or ID into a new line.": "Écrivez chaque URL ou identifiant dans une nouvelle ligne.",
13061307 "Export for character": "Exportation pour le personnage",
public/locales/is-is.json+1 -0
@@ -1379,6 +1379,7 @@
13791379 "char_import_6": "Beinn PNG hlekkur (sjá",
13801380 "char_import_7": "fyrir leyfilega gestgjafa)",
13811381 "char_import_8": "RisuRealm karakter (beinn hlekkur)",
1382+ "char_import_9": "Soulkyn karakter (beinn hlekkur)",
13821383 "Supports importing multiple characters.": "Styður innflutning á mörgum stöfum.",
13831384 "Write each URL or ID into a new line.": "Skrifaðu hverja vefslóð eða auðkenni í nýja línu.",
13841385 "Export for character": "Flytja út fyrir persónu",
public/locales/it-it.json+1 -0
@@ -1379,6 +1379,7 @@
13791379 "char_import_6": "Collegamento PNG diretto (fare riferimento a",
13801380 "char_import_7": "per gli host consentiti)",
13811381 "char_import_8": "Personaggio RisuRealm (collegamento diretto)",
1382+ "char_import_9": "Personaggio Soulkyn (collegamento diretto)",
13821383 "Supports importing multiple characters.": "Supporta l'importazione di più caratteri.",
13831384 "Write each URL or ID into a new line.": "Scrivi ogni URL o ID in una nuova riga.",
13841385 "Export for character": "Esporta per carattere",
public/locales/ja-jp.json+1 -0
@@ -1381,6 +1381,7 @@
13811381 "char_import_6": "直接PNGリンク(参照",
13821382 "char_import_7": "許可されたホストの場合)",
13831383 "char_import_8": "RisuRealm キャラクター (直接リンク)",
1384+ "char_import_9": "Soulkyn キャラクター (直接リンク)",
13841385 "Supports importing multiple characters.": "複数のキャラクターのインポートをサポートします。",
13851386 "Write each URL or ID into a new line.": "各 URL または ID を新しい行に入力します。",
13861387 "Export for character": "キャラクターのエクスポート",
public/locales/ko-kr.json+1 -0
@@ -1398,6 +1398,7 @@
13981398 "char_import_6": "직접 PNG 링크(참조",
13991399 "char_import_7": "허용된 호스트의 경우)",
14001400 "char_import_8": "RisuRealm 캐릭터 (직접링크)",
1401+ "char_import_9": "Soulkyn 캐릭터 (직접링크)",
14011402 "Supports importing multiple characters.": "여러 문자 가져오기를 지원합니다.",
14021403 "Write each URL or ID into a new line.": "각 URL 또는 ID를 새 줄에 작성합니다.",
14031404 "Export for character": "캐릭터 내보내기",
public/locales/nl-nl.json+1 -0
@@ -1379,6 +1379,7 @@
13791379 "char_import_6": "Directe PNG-link (zie",
13801380 "char_import_7": "voor toegestane hosts)",
13811381 "char_import_8": "RisuRealm-personage (directe link)",
1382+ "char_import_9": "Soulkyn-personage (directe link)",
13821383 "Supports importing multiple characters.": "Ondersteunt het importeren van meerdere tekens.",
13831384 "Write each URL or ID into a new line.": "Schrijf elke URL of ID op een nieuwe regel.",
13841385 "Export for character": "Exporteren voor karakter",
public/locales/pt-pt.json+1 -0
@@ -1379,6 +1379,7 @@
13791379 "char_import_6": "Link PNG direto (consulte",
13801380 "char_import_7": "para hosts permitidos)",
13811381 "char_import_8": "Personagem RisuRealm (link direto)",
1382+ "char_import_9": "Personagem Soulkyn (link direto)",
13821383 "Supports importing multiple characters.": "Suporta importação de vários caracteres.",
13831384 "Write each URL or ID into a new line.": "Escreva cada URL ou ID em uma nova linha.",
13841385 "Export for character": "Exportar para personagem",
public/locales/ru-ru.json+1 -0
@@ -965,6 +965,7 @@
965965 "char_import_5": "Персонаж с AICharacterCards.com (прямая ссылка или ID)",
966966 "char_import_6": "Прямая ссылка на PNG-файл (список разрешённых хостов находится в",
967967 "char_import_7": ")",
968+ "char_import_9": "Персонаж с Soulkyn (прямая ссылка)",
968969 "Grammar String": "Грамматика",
969970 "GBNF or EBNF, depends on the backend in use. If you're using this you should know which.": "GBNF или EBNF, зависит от бэкенда. Если вы это используете, то, скорее всего, сами знаете, какой именно.",
970971 "Account": "Аккаунт",
public/locales/uk-ua.json+1 -0
@@ -1379,6 +1379,7 @@
13791379 "char_import_6": "Пряме посилання на PNG (див",
13801380 "char_import_7": "для дозволених хостів)",
13811381 "char_import_8": "Персонаж RisuRealm (пряме посилання)",
1382+ "char_import_9": "Персонаж Soulkyn (пряме посилання)",
13821383 "Supports importing multiple characters.": "Підтримується імпорт кількох символів.",
13831384 "Write each URL or ID into a new line.": "Напишіть кожну URL-адресу або ідентифікатор у новому рядку.",
13841385 "Export for character": "Експорт для персонажа",
public/locales/vi-vn.json+1 -0
@@ -1379,6 +1379,7 @@
13791379 "char_import_6": "Nhập PNG trực tiếp (tham khảo",
13801380 "char_import_7": "đối với các máy chủ được phép)",
13811381 "char_import_8": "RisuRealm (URL trực tiếp)",
1382+ "char_import_9": "Soulkyn (URL trực tiếp)",
13821383 "Supports importing multiple characters.": "Hỗ trợ nhập nhiều ký tự.",
13831384 "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.",
13841385 "Export for character": "Xuất cho nhân vật",
public/locales/zh-cn.json+1 -0
@@ -1893,6 +1893,7 @@
18931893 "char_import_6": "被允许的PNG直链(请参阅",
18941894 "char_import_7": ")",
18951895 "char_import_8": "RisuRealm 角色(直链)",
1896+ "char_import_9": "Soulkyn 角色(直链)",
18961897 "Supports importing multiple characters.": "支持导入多个角色。",
18971898 "Write each URL or ID into a new line.": "将每个 URL 或 ID 写入新行。",
18981899 "Enter the Git URL of the extension to install": "输入扩展程序的 Git URL 以安装",
public/locales/zh-tw.json+1 -0
@@ -1384,6 +1384,7 @@
13841384 "char_import_6": "直接 PNG 連結(請參閱",
13851385 "char_import_7": "對於允許的主機)",
13861386 "char_import_8": "RisuRealm 角色(直接連結)",
1387+ "char_import_9": "Soulkyn 角色(直接連結)",
13871388 "Supports importing multiple characters.": "支援匯入多個字元。",
13881389 "Write each URL or ID into a new line.": "將每個 URL 或 ID 寫入新行。",
13891390 "Export for character": "匯出字元",
public/scripts/templates/importCharacters.html+1 -0
@@ -10,6 +10,7 @@
1010 <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>
1111 <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>
1212 <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>
13+ <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>
1314 </ul>
1415</div>
1516<small>
src/endpoints/content-manager.js+189 -0
@@ -637,6 +637,187 @@ async function downloadRisuCharacter(uuid) {
637637}
638638
639639/**
640+ * Parse Soulkyn URL to extract the character slug.
641+ * @param {string} url Soulkyn character URL
642+ * @returns {string | null} Slug of the character
643+ */
644+function parseSoulkynUrl(url) {
645+ // Example: https://soulkyn.com/l/en-US/@kayla-marie
646+ const pattern = /^https:\/\/soulkyn\.com\/l\/[a-z]{2}-[A-Z]{2}\/@([\w\d-]+)/i;
647+ const match = url.match(pattern);
648+ return match ? match[1] : null;
649+}
650+
651+/**
652+ * Download Soulkyn character card
653+ * @param {string} slug Slug of the character
654+ * @returns {Promise<{buffer: Buffer, fileName: string, fileType: string} | null>}
655+ */
656+async function downloadSoulkynCharacter(slug) {
657+ const soulkynReplacements = [
658+ // https://soulkyn.com/l/en-US/help/character-backgrounds-advanced#variables-you-can-use-in-character-background-text
659+ { pattern: /__USER_?NAME__/gi, replacement: '{{user}}' },
660+ { pattern: /__PERSONA_?NAME__/gi, replacement: '{{char}}' },
661+ // ST doesn't support gender-specific pronoun macros
662+ { pattern: /__U_PRONOUN_1__/gi, replacement: 'they' },
663+ { pattern: /__U_PRONOUN_2__/gi, replacement: 'them' },
664+ { pattern: /__U_PRONOUN_3__/gi, replacement: 'their' },
665+ { pattern: /__U_PRONOUN_4__/gi, replacement: 'themselves' },
666+ { pattern: /__(USER_)?PRONOUN__/gi, replacement: 'they' },
667+ { pattern: /__(USER_)?CPRONOUN__/gi, replacement: 'them' },
668+ { pattern: /__(USER_)?UPRONOUN__/gi, replacement: 'their' },
669+ // HTML tags -> Markdown syntax
670+ { pattern: /<(strong|b)>/gi, replacement: '**' },
671+ { pattern: /<\/(strong|b)>/gi, replacement: '**' },
672+ { pattern: /<(em|i)>/gi, replacement: '*' },
673+ { pattern: /<\/(em|i)>/gi, replacement: '*' },
674+ ];
675+
676+ const normalizeContent = (str) => soulkynReplacements.reduce((acc, { pattern, replacement }) => acc.replace(pattern, replacement), str);
677+
678+ try {
679+ const url = `https://soulkyn.com/_special/rest/Sk/public/Persona/${slug}`;
680+ const result = await fetch(url, {
681+ headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
682+ });
683+ if (result.ok) {
684+ /** @type {any} */
685+ const soulkynCharData = await result.json();
686+
687+ if (soulkynCharData.result !== 'success') {
688+ console.error('Soulkyn returned error', soulkynCharData.message);
689+ throw new Error(`Failed to download character: ${soulkynCharData.message}`);
690+ }
691+
692+ // Fetch avatar
693+ let avatarBuffer = null;
694+ if (soulkynCharData.data?.Avatar?.FWSUUID) {
695+ const avatarUrl = `https://rub.soulkyn.com/${soulkynCharData.data.Avatar.FWSUUID}/`;
696+ const avatarResult = await fetch(avatarUrl, { headers: { 'User-Agent': USER_AGENT } });
697+
698+ if (avatarResult.ok) {
699+ const avatarContentType = avatarResult.headers.get('content-type');
700+ if (avatarContentType === 'image/png') {
701+ avatarBuffer = Buffer.from(await avatarResult.arrayBuffer());
702+ } else {
703+ console.warn(`Soulkyn character (${slug}) avatar is not PNG: ${avatarContentType}`);
704+ }
705+ } else {
706+ console.warn(`Soulkyn character (${slug}) avatar download failed: ${avatarResult.status}`);
707+ }
708+ } else {
709+ console.warn(`Soulkyn character (${slug}) does not have an avatar`);
710+ }
711+
712+ // Fallback to default avatar
713+ if (!avatarBuffer) {
714+ const defaultAvatarPath = path.join(serverDirectory, 'public', 'img', 'ai4.png');
715+ avatarBuffer = fs.readFileSync(defaultAvatarPath);
716+ }
717+
718+ const d = soulkynCharData.data;
719+ soulkynReplacements.push({ pattern: d.Username, replacement: '{{char}}' });
720+
721+ // Parse Soulkyn data into character chard
722+ const charData = {
723+ name: d.Username,
724+ first_mes: '',
725+ tags: [],
726+ description: '',
727+ creator: d.User.Username,
728+ creator_notes: '',
729+ alternate_greetings: [],
730+ character_version: '',
731+ mes_example: '',
732+ post_history_instructions: '',
733+ system_prompt: '',
734+ scenario: '',
735+ personality: '',
736+ extensions: {
737+ soulkyn_slug: slug,
738+ soulkyn_id: d.UUID,
739+ },
740+ };
741+
742+ if (d?.PersonaIntroText) {
743+ const match = d.PersonaIntroText.match(/^(?:\[Scenario:\s*([\s\S]*?)\]\s*)?([\s\S]*)$/);
744+ if (match) {
745+ if (match[1]) {
746+ charData.scenario = normalizeContent(match[1].trim());
747+ }
748+ charData.first_mes = normalizeContent(match[2].trim());
749+ }
750+ }
751+
752+ const descriptionArr = ['Name: {{char}}'];
753+ if (d?.Version?.Age) {
754+ descriptionArr.push(`Age: ${d.Version.Age}`);
755+ }
756+ if (d?.Version?.Gender) {
757+ descriptionArr.push(`Gender: ${d.Version.Gender}`);
758+ }
759+ if (d?.Version?.Race?.Name && !d.Version.Race.Name.match(/no preset/i)) {
760+ let race = d.Version.Race.Name;
761+ if (d.Version.Race?.Description) {
762+ race += ` (${d.Version.Race.Description})`;
763+ }
764+ descriptionArr.push(`Race: ${race}`);
765+ }
766+ if (d?.PersonalityType) {
767+ descriptionArr.push(`Personality type: ${d.PersonalityType}`);
768+ }
769+ if (Array.isArray(d?.Version?.PropertyPersonality)) {
770+ const traits = d.Version.PropertyPersonality.map((t) => t.Value).join(', ');
771+ descriptionArr.push(`Personality Traits: ${traits}`);
772+ }
773+ if (Array.isArray(d?.Version?.PropertyPhysical)) {
774+ const traits = d.Version.PropertyPhysical.map((t) => t.Value).join(', ');
775+ descriptionArr.push(`Physical Traits: ${traits}`);
776+ }
777+ if (Array.isArray(d?.Clothes?.Preset)) {
778+ descriptionArr.push(`Clothes: ${d.Clothes.Preset.join(', ')}`);
779+ }
780+ if (d?.Avatar?.Caption) {
781+ descriptionArr.push(`Image description featuring {{char}}: ${d.Avatar.Caption.replace(/\n+/g, ' ')}`);
782+ }
783+ if (d?.Version?.WelcomeMessage) {
784+ if (charData.first_mes) {
785+ descriptionArr.push(`{{char}}'s self-description: "${d.Version.WelcomeMessage}"`);
786+ } else {
787+ // Some characters lack `PersonaIntroText`. In that case we use `Version.WelcomeMessage` for `first_mes`
788+ charData.first_mes = normalizeContent(d.Version.WelcomeMessage);
789+ }
790+ }
791+ charData.description = normalizeContent(descriptionArr.join('\n'));
792+
793+ if (Array.isArray(d?.Version?.ChatExamplesValue)) {
794+ charData.mes_example = d.Version.ChatExamplesValue.map((example) => `<START>\n${normalizeContent(example)}`).join('\n');
795+ }
796+
797+ if (Array.isArray(d?.PersonaTags)) {
798+ charData.tags = d.PersonaTags.map((t) => t.Slug);
799+ }
800+
801+ // Character card
802+ const buffer = write(avatarBuffer, JSON.stringify({
803+ 'spec': 'chara_card_v2',
804+ 'spec_version': '2.0',
805+ 'data': charData,
806+ }));
807+
808+ const fileName = `${sanitize(d.UUID)}.png`;
809+ const fileType = 'image/png';
810+
811+ return { buffer, fileName, fileType };
812+ }
813+ } catch (error) {
814+ console.error('Error downloading character:', error);
815+ throw error;
816+ }
817+ return null;
818+}
819+
820+/**
640821* @param {String} url
641822* @returns {String | null } UUID of the character
642823*/
@@ -691,6 +872,7 @@ router.post('/importURL', async (request, response) => {
691872 const isPygmalionContent = host.includes('pygmalion.chat');
692873 const isAICharacterCardsContent = host.includes('aicharactercards.com');
693874 const isRisu = host.includes('realm.risuai.net');
875+ const isSoulkyn = host.includes('soulkyn.com');
694876 const isGeneric = isHostWhitelisted(host);
695877
696878 if (isPygmalionContent) {
@@ -739,6 +921,13 @@ router.post('/importURL', async (request, response) => {
739921
740922 type = 'character';
741923 result = await downloadRisuCharacter(uuid);
924+ } else if (isSoulkyn) {
925+ const soulkynSlug = parseSoulkynUrl(url);
926+ if (!soulkynSlug) {
927+ return response.sendStatus(404);
928+ }
929+ type = 'character';
930+ result = await downloadSoulkynCharacter(soulkynSlug);
742931 } else if (isGeneric) {
743932 console.info('Downloading from generic url:', url);
744933 type = 'character';