diff --git a/public/locales/ar-sa.json b/public/locales/ar-sa.json index 9939c18fd..35bac9f38 100644 --- a/public/locales/ar-sa.json +++ b/public/locales/ar-sa.json @@ -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": "تصدير للشخصية", diff --git a/public/locales/de-de.json b/public/locales/de-de.json index 90927ec03..e231cf460 100644 --- a/public/locales/de-de.json +++ b/public/locales/de-de.json @@ -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", diff --git a/public/locales/es-es.json b/public/locales/es-es.json index 6fa932c7c..7593fa883 100644 --- a/public/locales/es-es.json +++ b/public/locales/es-es.json @@ -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", diff --git a/public/locales/fr-fr.json b/public/locales/fr-fr.json index ca833d71f..25b79765e 100644 --- a/public/locales/fr-fr.json +++ b/public/locales/fr-fr.json @@ -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", diff --git a/public/locales/is-is.json b/public/locales/is-is.json index d4d990af6..aac2d663a 100644 --- a/public/locales/is-is.json +++ b/public/locales/is-is.json @@ -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", diff --git a/public/locales/it-it.json b/public/locales/it-it.json index 69580d772..8649eca9e 100644 --- a/public/locales/it-it.json +++ b/public/locales/it-it.json @@ -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", diff --git a/public/locales/ja-jp.json b/public/locales/ja-jp.json index 9a0b49d22..30cfa2369 100644 --- a/public/locales/ja-jp.json +++ b/public/locales/ja-jp.json @@ -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": "キャラクターのエクスポート", diff --git a/public/locales/ko-kr.json b/public/locales/ko-kr.json index 897bc5142..066445f95 100644 --- a/public/locales/ko-kr.json +++ b/public/locales/ko-kr.json @@ -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": "캐릭터 내보내기", diff --git a/public/locales/nl-nl.json b/public/locales/nl-nl.json index 7538d68ea..01ae11323 100644 --- a/public/locales/nl-nl.json +++ b/public/locales/nl-nl.json @@ -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", diff --git a/public/locales/pt-pt.json b/public/locales/pt-pt.json index f75680975..ddb9c7aaa 100644 --- a/public/locales/pt-pt.json +++ b/public/locales/pt-pt.json @@ -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", diff --git a/public/locales/ru-ru.json b/public/locales/ru-ru.json index 4617dff7b..45256bc49 100644 --- a/public/locales/ru-ru.json +++ b/public/locales/ru-ru.json @@ -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": "Аккаунт", diff --git a/public/locales/uk-ua.json b/public/locales/uk-ua.json index 84da31e6f..37b4edafe 100644 --- a/public/locales/uk-ua.json +++ b/public/locales/uk-ua.json @@ -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": "Експорт для персонажа", diff --git a/public/locales/vi-vn.json b/public/locales/vi-vn.json index cba999cd7..3e7b76c21 100644 --- a/public/locales/vi-vn.json +++ b/public/locales/vi-vn.json @@ -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", diff --git a/public/locales/zh-cn.json b/public/locales/zh-cn.json index 74420bb85..d2fe257f4 100644 --- a/public/locales/zh-cn.json +++ b/public/locales/zh-cn.json @@ -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 以安装", diff --git a/public/locales/zh-tw.json b/public/locales/zh-tw.json index b8ce8c793..df66394a5 100644 --- a/public/locales/zh-tw.json +++ b/public/locales/zh-tw.json @@ -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": "匯出字元", diff --git a/public/scripts/templates/importCharacters.html b/public/scripts/templates/importCharacters.html index 4bc4807bf..657075e23 100644 --- a/public/scripts/templates/importCharacters.html +++ b/public/scripts/templates/importCharacters.html @@ -10,6 +10,7 @@
  • AICharacterCards.com Character (Direct Link or ID)
    Example: AICC/aicharcards/the-game-master
  • Direct PNG Link (refer to config.yaml for allowed hosts)
    Example: https://files.catbox.moe/notarealfile.png
  • RisuRealm Character (Direct Link)
    Example: https://realm.risuai.net/character/3ca54c71-6efe-46a2-b9d0-4f62df23d712
  • +
  • Soulkyn Character (Direct Link)
    Example: https://soulkyn.com/l/en-US/@haruka-509al
  • diff --git a/src/endpoints/content-manager.js b/src/endpoints/content-manager.js index 28a21f63f..184fa3644 100644 --- a/src/endpoints/content-manager.js +++ b/src/endpoints/content-manager.js @@ -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) => `\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';