| @@ -1379,6 +1379,7 @@ | ||
| 1379 | 1379 | "char_import_6": "رابط PNG المباشر (راجع", |
| 1380 | 1380 | "char_import_7": "للمضيفين المسموح بهم)", |
| 1381 | 1381 | "char_import_8": "شخصية RisuRealm (رابط مباشر)", |
| 1382 | + "char_import_9": "شخصية Soulkyn (رابط مباشر)", | |
| 1382 | 1383 | "Supports importing multiple characters.": "يدعم استيراد أحرف متعددة.", |
| 1383 | 1384 | "Write each URL or ID into a new line.": "اكتب كل عنوان URL أو معرف في سطر جديد.", |
| 1384 | 1385 | "Export for character": "تصدير للشخصية", |
| @@ -1379,6 +1379,7 @@ | ||
| 1379 | 1379 | "char_import_6": "Direkter PNG-Link (siehe", |
| 1380 | 1380 | "char_import_7": "für erlaubte Hosts)", |
| 1381 | 1381 | "char_import_8": "RisuRealm-Charakter (Direktlink)", |
| 1382 | + "char_import_9": "Soulkyn-Charakter (Direktlink)", | |
| 1382 | 1383 | "Supports importing multiple characters.": "Unterstützt den Import mehrerer Zeichen.", |
| 1383 | 1384 | "Write each URL or ID into a new line.": "Schreiben Sie jede URL oder ID in eine neue Zeile.", |
| 1384 | 1385 | "Export for character": "Export für Zeichen", |
| @@ -1379,6 +1379,7 @@ | ||
| 1379 | 1379 | "char_import_6": "Enlace PNG directo (consulte", |
| 1380 | 1380 | "char_import_7": "para hosts permitidos)", |
| 1381 | 1381 | "char_import_8": "Personaje RisuRealm (Enlace directo)", |
| 1382 | + "char_import_9": "Personaje Soulkyn (Enlace directo)", | |
| 1382 | 1383 | "Supports importing multiple characters.": "Admite la importación de múltiples caracteres.", |
| 1383 | 1384 | "Write each URL or ID into a new line.": "Escriba cada URL o ID en una nueva línea.", |
| 1384 | 1385 | "Export for character": "Exportar para personaje", |
| @@ -1301,6 +1301,7 @@ | ||
| 1301 | 1301 | "char_import_6": "Lien PNG direct (voir", |
| 1302 | 1302 | "char_import_7": "pour les hôtes autorisés)", |
| 1303 | 1303 | "char_import_8": "Personnage de RisuRealm (lien direct)", |
| 1304 | + "char_import_9": "Personnage de Soulkyn (lien direct)", | |
| 1304 | 1305 | "Supports importing multiple characters.": "Prend en charge l'importation de plusieurs caractères.", |
| 1305 | 1306 | "Write each URL or ID into a new line.": "Écrivez chaque URL ou identifiant dans une nouvelle ligne.", |
| 1306 | 1307 | "Export for character": "Exportation pour le personnage", |
| @@ -1379,6 +1379,7 @@ | ||
| 1379 | 1379 | "char_import_6": "Beinn PNG hlekkur (sjá", |
| 1380 | 1380 | "char_import_7": "fyrir leyfilega gestgjafa)", |
| 1381 | 1381 | "char_import_8": "RisuRealm karakter (beinn hlekkur)", |
| 1382 | + "char_import_9": "Soulkyn karakter (beinn hlekkur)", | |
| 1382 | 1383 | "Supports importing multiple characters.": "Styður innflutning á mörgum stöfum.", |
| 1383 | 1384 | "Write each URL or ID into a new line.": "Skrifaðu hverja vefslóð eða auðkenni í nýja línu.", |
| 1384 | 1385 | "Export for character": "Flytja út fyrir persónu", |
| @@ -1379,6 +1379,7 @@ | ||
| 1379 | 1379 | "char_import_6": "Collegamento PNG diretto (fare riferimento a", |
| 1380 | 1380 | "char_import_7": "per gli host consentiti)", |
| 1381 | 1381 | "char_import_8": "Personaggio RisuRealm (collegamento diretto)", |
| 1382 | + "char_import_9": "Personaggio Soulkyn (collegamento diretto)", | |
| 1382 | 1383 | "Supports importing multiple characters.": "Supporta l'importazione di più caratteri.", |
| 1383 | 1384 | "Write each URL or ID into a new line.": "Scrivi ogni URL o ID in una nuova riga.", |
| 1384 | 1385 | "Export for character": "Esporta per carattere", |
| @@ -1381,6 +1381,7 @@ | ||
| 1381 | 1381 | "char_import_6": "直接PNGリンク(参照", |
| 1382 | 1382 | "char_import_7": "許可されたホストの場合)", |
| 1383 | 1383 | "char_import_8": "RisuRealm キャラクター (直接リンク)", |
| 1384 | + "char_import_9": "Soulkyn キャラクター (直接リンク)", | |
| 1384 | 1385 | "Supports importing multiple characters.": "複数のキャラクターのインポートをサポートします。", |
| 1385 | 1386 | "Write each URL or ID into a new line.": "各 URL または ID を新しい行に入力します。", |
| 1386 | 1387 | "Export for character": "キャラクターのエクスポート", |
| @@ -1398,6 +1398,7 @@ | ||
| 1398 | 1398 | "char_import_6": "직접 PNG 링크(참조", |
| 1399 | 1399 | "char_import_7": "허용된 호스트의 경우)", |
| 1400 | 1400 | "char_import_8": "RisuRealm 캐릭터 (직접링크)", |
| 1401 | + "char_import_9": "Soulkyn 캐릭터 (직접링크)", | |
| 1401 | 1402 | "Supports importing multiple characters.": "여러 문자 가져오기를 지원합니다.", |
| 1402 | 1403 | "Write each URL or ID into a new line.": "각 URL 또는 ID를 새 줄에 작성합니다.", |
| 1403 | 1404 | "Export for character": "캐릭터 내보내기", |
| @@ -1379,6 +1379,7 @@ | ||
| 1379 | 1379 | "char_import_6": "Directe PNG-link (zie", |
| 1380 | 1380 | "char_import_7": "voor toegestane hosts)", |
| 1381 | 1381 | "char_import_8": "RisuRealm-personage (directe link)", |
| 1382 | + "char_import_9": "Soulkyn-personage (directe link)", | |
| 1382 | 1383 | "Supports importing multiple characters.": "Ondersteunt het importeren van meerdere tekens.", |
| 1383 | 1384 | "Write each URL or ID into a new line.": "Schrijf elke URL of ID op een nieuwe regel.", |
| 1384 | 1385 | "Export for character": "Exporteren voor karakter", |
| @@ -1379,6 +1379,7 @@ | ||
| 1379 | 1379 | "char_import_6": "Link PNG direto (consulte", |
| 1380 | 1380 | "char_import_7": "para hosts permitidos)", |
| 1381 | 1381 | "char_import_8": "Personagem RisuRealm (link direto)", |
| 1382 | + "char_import_9": "Personagem Soulkyn (link direto)", | |
| 1382 | 1383 | "Supports importing multiple characters.": "Suporta importação de vários caracteres.", |
| 1383 | 1384 | "Write each URL or ID into a new line.": "Escreva cada URL ou ID em uma nova linha.", |
| 1384 | 1385 | "Export for character": "Exportar para personagem", |
| @@ -965,6 +965,7 @@ | ||
| 965 | 965 | "char_import_5": "Персонаж с AICharacterCards.com (прямая ссылка или ID)", |
| 966 | 966 | "char_import_6": "Прямая ссылка на PNG-файл (список разрешённых хостов находится в", |
| 967 | 967 | "char_import_7": ")", |
| 968 | + "char_import_9": "Персонаж с Soulkyn (прямая ссылка)", | |
| 968 | 969 | "Grammar String": "Грамматика", |
| 969 | 970 | "GBNF or EBNF, depends on the backend in use. If you're using this you should know which.": "GBNF или EBNF, зависит от бэкенда. Если вы это используете, то, скорее всего, сами знаете, какой именно.", |
| 970 | 971 | "Account": "Аккаунт", |
| @@ -1379,6 +1379,7 @@ | ||
| 1379 | 1379 | "char_import_6": "Пряме посилання на PNG (див", |
| 1380 | 1380 | "char_import_7": "для дозволених хостів)", |
| 1381 | 1381 | "char_import_8": "Персонаж RisuRealm (пряме посилання)", |
| 1382 | + "char_import_9": "Персонаж Soulkyn (пряме посилання)", | |
| 1382 | 1383 | "Supports importing multiple characters.": "Підтримується імпорт кількох символів.", |
| 1383 | 1384 | "Write each URL or ID into a new line.": "Напишіть кожну URL-адресу або ідентифікатор у новому рядку.", |
| 1384 | 1385 | "Export for character": "Експорт для персонажа", |
| @@ -1379,6 +1379,7 @@ | ||
| 1379 | 1379 | "char_import_6": "Nhập PNG trực tiếp (tham khảo", |
| 1380 | 1380 | "char_import_7": "đối với các máy chủ được phép)", |
| 1381 | 1381 | "char_import_8": "RisuRealm (URL trực tiếp)", |
| 1382 | + "char_import_9": "Soulkyn (URL trực tiếp)", | |
| 1382 | 1383 | "Supports importing multiple characters.": "Hỗ trợ nhập nhiều ký tự.", |
| 1383 | 1384 | "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.", |
| 1384 | 1385 | "Export for character": "Xuất cho nhân vật", |
| @@ -1893,6 +1893,7 @@ | ||
| 1893 | 1893 | "char_import_6": "被允许的PNG直链(请参阅", |
| 1894 | 1894 | "char_import_7": ")", |
| 1895 | 1895 | "char_import_8": "RisuRealm 角色(直链)", |
| 1896 | + "char_import_9": "Soulkyn 角色(直链)", | |
| 1896 | 1897 | "Supports importing multiple characters.": "支持导入多个角色。", |
| 1897 | 1898 | "Write each URL or ID into a new line.": "将每个 URL 或 ID 写入新行。", |
| 1898 | 1899 | "Enter the Git URL of the extension to install": "输入扩展程序的 Git URL 以安装", |
| @@ -1384,6 +1384,7 @@ | ||
| 1384 | 1384 | "char_import_6": "直接 PNG 連結(請參閱", |
| 1385 | 1385 | "char_import_7": "對於允許的主機)", |
| 1386 | 1386 | "char_import_8": "RisuRealm 角色(直接連結)", |
| 1387 | + "char_import_9": "Soulkyn 角色(直接連結)", | |
| 1387 | 1388 | "Supports importing multiple characters.": "支援匯入多個字元。", |
| 1388 | 1389 | "Write each URL or ID into a new line.": "將每個 URL 或 ID 寫入新行。", |
| 1389 | 1390 | "Export for character": "匯出字元", |
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | <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> |
| 11 | 11 | <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> |
| 12 | 12 | <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> | |
| 13 | 14 | </ul> |
| 14 | 15 | </div> |
| 15 | 16 | <small> |
| @@ -637,6 +637,187 @@ async function downloadRisuCharacter(uuid) { | ||
| 637 | 637 | } |
| 638 | 638 | |
| 639 | 639 | /** |
| 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 | +/** | |
| 640 | 821 | * @param {String} url |
| 641 | 822 | * @returns {String | null } UUID of the character |
| 642 | 823 | */ |
| @@ -691,6 +872,7 @@ router.post('/importURL', async (request, response) => { | ||
| 691 | 872 | const isPygmalionContent = host.includes('pygmalion.chat'); |
| 692 | 873 | const isAICharacterCardsContent = host.includes('aicharactercards.com'); |
| 693 | 874 | const isRisu = host.includes('realm.risuai.net'); |
| 875 | + const isSoulkyn = host.includes('soulkyn.com'); | |
| 694 | 876 | const isGeneric = isHostWhitelisted(host); |
| 695 | 877 | |
| 696 | 878 | if (isPygmalionContent) { |
| @@ -739,6 +921,13 @@ router.post('/importURL', async (request, response) => { | ||
| 739 | 921 | |
| 740 | 922 | type = 'character'; |
| 741 | 923 | 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); | |
| 742 | 931 | } else if (isGeneric) { |
| 743 | 932 | console.info('Downloading from generic url:', url); |
| 744 | 933 | type = 'character'; |