haasillytavern / src /endpoints /content-manager.js
Haay's picture
Upload 926 files
519a20c verified
import fs from 'node:fs';
import path from 'node:path';
import { Buffer } from 'node:buffer';
import express from 'express';
import fetch from 'node-fetch';
import sanitize from 'sanitize-filename';
import { sync as writeFileAtomicSync } from 'write-file-atomic';
import { getConfigValue, color, setPermissionsSync } from '../util.js';
import { write } from '../character-card-parser.js';
import { serverDirectory } from '../server-directory.js';
import { DEFAULT_AVATAR_PATH } from '../constants.js';
const contentDirectory = path.join(serverDirectory, 'default/content');
const scaffoldDirectory = path.join(serverDirectory, 'default/scaffold');
const contentIndexPath = path.join(contentDirectory, 'index.json');
const scaffoldIndexPath = path.join(scaffoldDirectory, 'index.json');
const WHITELIST_GENERIC_URL_DOWNLOAD_SOURCES = getConfigValue('whitelistImportDomains', []);
const USER_AGENT = 'SillyTavern';
/**
* @typedef {Object} ContentItem
* @property {string} filename
* @property {string} type
* @property {string} [name]
* @property {string|null} [folder]
*/
/**
* @typedef {string} ContentType
* @enum {string}
*/
export const CONTENT_TYPES = {
SETTINGS: 'settings',
CHARACTER: 'character',
SPRITES: 'sprites',
BACKGROUND: 'background',
WORLD: 'world',
AVATAR: 'avatar',
THEME: 'theme',
WORKFLOW: 'workflow',
KOBOLD_PRESET: 'kobold_preset',
OPENAI_PRESET: 'openai_preset',
NOVEL_PRESET: 'novel_preset',
TEXTGEN_PRESET: 'textgen_preset',
INSTRUCT: 'instruct',
CONTEXT: 'context',
MOVING_UI: 'moving_ui',
QUICK_REPLIES: 'quick_replies',
SYSPROMPT: 'sysprompt',
REASONING: 'reasoning',
};
/**
* Gets the default presets from the content directory.
* @param {import('../users.js').UserDirectoryList} directories User directories
* @returns {object[]} Array of default presets
*/
export function getDefaultPresets(directories) {
try {
const contentIndex = getContentIndex();
const presets = [];
for (const contentItem of contentIndex) {
if (contentItem.type.endsWith('_preset') || ['instruct', 'context', 'sysprompt', 'reasoning'].includes(contentItem.type)) {
contentItem.name = path.parse(contentItem.filename).name;
contentItem.folder = getTargetByType(contentItem.type, directories);
presets.push(contentItem);
}
}
return presets;
} catch (err) {
console.warn('Failed to get default presets', err);
return [];
}
}
/**
* Gets a default JSON file from the content directory.
* @param {string} filename Name of the file to get
* @returns {object | null} JSON object or null if the file doesn't exist
*/
export function getDefaultPresetFile(filename) {
try {
const contentPath = path.join(contentDirectory, filename);
if (!fs.existsSync(contentPath)) {
return null;
}
const fileContent = fs.readFileSync(contentPath, 'utf8');
return JSON.parse(fileContent);
} catch (err) {
console.warn(`Failed to get default file ${filename}`, err);
return null;
}
}
/**
* Seeds content for a user.
* @param {ContentItem[]} contentIndex Content index
* @param {import('../users.js').UserDirectoryList} directories User directories
* @param {string[]} forceCategories List of categories to force check (even if content check is skipped)
* @returns {Promise<boolean>} Whether any content was added
*/
async function seedContentForUser(contentIndex, directories, forceCategories) {
let anyContentAdded = false;
if (!fs.existsSync(directories.root)) {
fs.mkdirSync(directories.root, { recursive: true });
}
const contentLogPath = path.join(directories.root, 'content.log');
const contentLog = getContentLog(contentLogPath);
for (const contentItem of contentIndex) {
// If the content item is already in the log, skip it
if (contentLog.includes(contentItem.filename) && !forceCategories?.includes(contentItem.type)) {
continue;
}
if (!contentItem.folder) {
console.warn(`Content file ${contentItem.filename} has no parent folder`);
continue;
}
const contentPath = path.join(contentItem.folder, contentItem.filename);
if (!fs.existsSync(contentPath)) {
console.warn(`Content file ${contentItem.filename} is missing`);
continue;
}
const contentTarget = getTargetByType(contentItem.type, directories);
if (!contentTarget) {
console.warn(`Content file ${contentItem.filename} has unknown type ${contentItem.type}`);
continue;
}
const basePath = path.parse(contentItem.filename).base;
const targetPath = path.join(contentTarget, basePath);
contentLog.push(contentItem.filename);
if (fs.existsSync(targetPath)) {
console.warn(`Content file ${contentItem.filename} already exists in ${contentTarget}`);
continue;
}
fs.cpSync(contentPath, targetPath, { recursive: true, force: false });
setPermissionsSync(targetPath);
console.info(`Content file ${contentItem.filename} copied to ${contentTarget}`);
anyContentAdded = true;
}
writeFileAtomicSync(contentLogPath, contentLog.join('\n'));
return anyContentAdded;
}
/**
* Checks for new content and seeds it for all users.
* @param {import('../users.js').UserDirectoryList[]} directoriesList List of user directories
* @param {string[]} forceCategories List of categories to force check (even if content check is skipped)
* @returns {Promise<void>}
*/
export async function checkForNewContent(directoriesList, forceCategories = []) {
try {
const contentCheckSkip = getConfigValue('skipContentCheck', false, 'boolean');
if (contentCheckSkip && forceCategories?.length === 0) {
return;
}
const contentIndex = getContentIndex();
let anyContentAdded = false;
for (const directories of directoriesList) {
const seedResult = await seedContentForUser(contentIndex, directories, forceCategories);
if (seedResult) {
anyContentAdded = true;
}
}
if (anyContentAdded && !contentCheckSkip && forceCategories?.length === 0) {
console.info();
console.info(`${color.blue('If you don\'t want to receive content updates in the future, set')} ${color.yellow('skipContentCheck')} ${color.blue('to true in the config.yaml file.')}`);
console.info();
}
} catch (err) {
console.error('Content check failed', err);
}
}
/**
* Gets combined content index from the content and scaffold directories.
* @returns {ContentItem[]} Array of content index
*/
function getContentIndex() {
const result = [];
if (fs.existsSync(scaffoldIndexPath)) {
const scaffoldIndexText = fs.readFileSync(scaffoldIndexPath, 'utf8');
const scaffoldIndex = JSON.parse(scaffoldIndexText);
if (Array.isArray(scaffoldIndex)) {
scaffoldIndex.forEach((item) => {
item.folder = scaffoldDirectory;
});
result.push(...scaffoldIndex);
}
}
if (fs.existsSync(contentIndexPath)) {
const contentIndexText = fs.readFileSync(contentIndexPath, 'utf8');
const contentIndex = JSON.parse(contentIndexText);
if (Array.isArray(contentIndex)) {
contentIndex.forEach((item) => {
item.folder = contentDirectory;
});
result.push(...contentIndex);
}
}
return result;
}
/**
* Gets content by type and format.
* @param {string} type Type of content
* @param {'json'|'string'|'raw'} format Format of content
* @returns {string[]|Buffer[]} Array of content
*/
export function getContentOfType(type, format) {
const contentIndex = getContentIndex();
const indexItems = contentIndex.filter((item) => item.type === type && item.folder);
const files = [];
for (const item of indexItems) {
if (!item.folder) {
continue;
}
try {
const filePath = path.join(item.folder, item.filename);
const fileContent = fs.readFileSync(filePath);
switch (format) {
case 'json':
files.push(JSON.parse(fileContent.toString()));
break;
case 'string':
files.push(fileContent.toString());
break;
case 'raw':
files.push(fileContent);
break;
}
} catch {
// Ignore errors
}
}
return files;
}
/**
* Gets the target directory for the specified asset type.
* @param {ContentType} type Asset type
* @param {import('../users.js').UserDirectoryList} directories User directories
* @returns {string | null} Target directory
*/
function getTargetByType(type, directories) {
switch (type) {
case CONTENT_TYPES.SETTINGS:
return directories.root;
case CONTENT_TYPES.CHARACTER:
return directories.characters;
case CONTENT_TYPES.SPRITES:
return directories.characters;
case CONTENT_TYPES.BACKGROUND:
return directories.backgrounds;
case CONTENT_TYPES.WORLD:
return directories.worlds;
case CONTENT_TYPES.AVATAR:
return directories.avatars;
case CONTENT_TYPES.THEME:
return directories.themes;
case CONTENT_TYPES.WORKFLOW:
return directories.comfyWorkflows;
case CONTENT_TYPES.KOBOLD_PRESET:
return directories.koboldAI_Settings;
case CONTENT_TYPES.OPENAI_PRESET:
return directories.openAI_Settings;
case CONTENT_TYPES.NOVEL_PRESET:
return directories.novelAI_Settings;
case CONTENT_TYPES.TEXTGEN_PRESET:
return directories.textGen_Settings;
case CONTENT_TYPES.INSTRUCT:
return directories.instruct;
case CONTENT_TYPES.CONTEXT:
return directories.context;
case CONTENT_TYPES.MOVING_UI:
return directories.movingUI;
case CONTENT_TYPES.QUICK_REPLIES:
return directories.quickreplies;
case CONTENT_TYPES.SYSPROMPT:
return directories.sysprompt;
case CONTENT_TYPES.REASONING:
return directories.reasoning;
default:
return null;
}
}
/**
* Gets the content log from the content log file.
* @param {string} contentLogPath Path to the content log file
* @returns {string[]} Array of content log lines
*/
function getContentLog(contentLogPath) {
if (!fs.existsSync(contentLogPath)) {
return [];
}
const contentLogText = fs.readFileSync(contentLogPath, 'utf8');
return contentLogText.split('\n');
}
async function downloadChubLorebook(id) {
const [lorebooks, creatorName, projectName] = id.split('/');
const result = await fetch(`https://api.chub.ai/api/${lorebooks}/${creatorName}/${projectName}`, {
method: 'GET',
headers: { 'Accept': 'application/json', 'User-Agent': USER_AGENT },
});
if (!result.ok) {
const text = await result.text();
console.error('Chub returned error', result.statusText, text);
throw new Error('Failed to fetch lorebook metadata');
}
/** @type {any} */
const metadata = await result.json();
const projectId = metadata.node?.id;
if (!projectId) {
throw new Error('Project ID not found in lorebook metadata');
}
const downloadUrl = `https://api.chub.ai/api/v4/projects/${projectId}/repository/files/raw%252Fsillytavern_raw.json/raw`;
const downloadResult = await fetch(downloadUrl, {
method: 'GET',
headers: { 'Accept': 'application/json', 'User-Agent': USER_AGENT },
});
if (!downloadResult.ok) {
const text = await downloadResult.text();
console.error('Chub returned error', downloadResult.statusText, text);
throw new Error('Failed to download lorebook');
}
const name = projectName;
const buffer = Buffer.from(await downloadResult.arrayBuffer());
const fileName = `${sanitize(name)}.json`;
const fileType = downloadResult.headers.get('content-type');
return { buffer, fileName, fileType };
}
async function downloadChubCharacter(id) {
const [creatorName, projectName] = id.split('/');
const result = await fetch(`https://api.chub.ai/api/characters/${creatorName}/${projectName}`, {
method: 'GET',
headers: { 'Accept': 'application/json', 'User-Agent': USER_AGENT },
});
if (!result.ok) {
const text = await result.text();
console.error('Chub returned error', result.statusText, text);
throw new Error('Failed to fetch character metadata');
}
/** @type {any} */
const metadata = await result.json();
const downloadUrl = metadata.node?.max_res_url;
if (!downloadUrl) {
throw new Error('Download URL not found in character metadata');
}
const downloadResult = await fetch(downloadUrl);
if (!downloadResult.ok) {
const text = await downloadResult.text();
console.error('Chub returned error', downloadResult.statusText, text);
throw new Error('Failed to download character');
}
const buffer = Buffer.from(await downloadResult.arrayBuffer());
const fileName =
downloadResult.headers.get('content-disposition')?.split('filename=')[1]?.replace(/["']/g, '') ||
`${sanitize(projectName)}.png`;
const fileType = downloadResult.headers.get('content-type');
return { buffer, fileName, fileType };
}
/**
* Downloads a character card from the Pygsite.
* @param {string} id UUID of the character
* @returns {Promise<{buffer: Buffer, fileName: string, fileType: string}>}
*/
async function downloadPygmalionCharacter(id) {
const result = await fetch(`https://server.pygmalion.chat/api/export/character/${id}/v2`);
if (!result.ok) {
const text = await result.text();
console.error('Pygsite returned error', result.status, text);
throw new Error('Failed to download character');
}
/** @type {any} */
const jsonData = await result.json();
const characterData = jsonData?.character;
if (!characterData || typeof characterData !== 'object') {
console.error('Pygsite returned invalid character data', jsonData);
throw new Error('Failed to download character');
}
try {
const avatarUrl = characterData?.data?.avatar;
if (!avatarUrl) {
console.error('Pygsite character does not have an avatar', characterData);
throw new Error('Failed to download avatar');
}
const avatarResult = await fetch(avatarUrl);
const avatarBuffer = Buffer.from(await avatarResult.arrayBuffer());
const cardBuffer = write(avatarBuffer, JSON.stringify(characterData));
return {
buffer: cardBuffer,
fileName: `${sanitize(id)}.png`,
fileType: 'image/png',
};
} catch (e) {
console.error('Failed to download avatar, using JSON instead', e);
return {
buffer: Buffer.from(JSON.stringify(jsonData)),
fileName: `${sanitize(id)}.json`,
fileType: 'application/json',
};
}
}
/**
*
* @param {String} str
* @returns { { id: string, type: "character" | "lorebook" } | null }
*/
function parseChubUrl(str) {
const splitStr = str.split('/');
const length = splitStr.length;
if (length < 2) {
return null;
}
let domainIndex = -1;
splitStr.forEach((part, index) => {
if (part === 'www.chub.ai' || part === 'chub.ai' || part === 'www.characterhub.org' || part === 'characterhub.org') {
domainIndex = index;
}
});
const lastTwo = domainIndex !== -1 ? splitStr.slice(domainIndex + 1) : splitStr;
const firstPart = lastTwo[0].toLowerCase();
if (firstPart === 'characters' || firstPart === 'lorebooks') {
const type = firstPart === 'characters' ? 'character' : 'lorebook';
const id = type === 'character' ? lastTwo.slice(1).join('/') : lastTwo.join('/');
return {
id: id,
type: type,
};
} else if (length === 2) {
return {
id: lastTwo.join('/'),
type: 'character',
};
}
return null;
}
// Warning: Some characters might not exist in JannyAI.me
async function downloadJannyCharacter(uuid) {
// This endpoint is being guarded behind Bot Fight Mode of Cloudflare
// So hosted ST on Azure/AWS/GCP/Collab might get blocked by IP
// Should work normally on self-host PC/Android
const result = await fetch('https://api.jannyai.com/api/v1/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
'characterId': uuid,
}),
});
if (result.ok) {
/** @type {any} */
const downloadResult = await result.json();
if (downloadResult.status === 'ok') {
const imageResult = await fetch(downloadResult.downloadUrl);
const buffer = Buffer.from(await imageResult.arrayBuffer());
const fileName = `${sanitize(uuid)}.png`;
const fileType = imageResult.headers.get('content-type');
return { buffer, fileName, fileType };
}
}
console.error('Janny returned error', result.statusText, await result.text());
throw new Error('Failed to download character');
}
//Download Character Cards from AICharactersCards.com (AICC) API.
async function downloadAICCCharacter(id) {
const apiURL = `https://aicharactercards.com/wp-json/pngapi/v1/image/${id}`;
try {
const response = await fetch(apiURL);
if (!response.ok) {
throw new Error(`Failed to download character: ${response.statusText}`);
}
const contentType = response.headers.get('content-type') || 'image/png'; // Default to 'image/png' if header is missing
const buffer = Buffer.from(await response.arrayBuffer());
const fileName = `${sanitize(id)}.png`; // Assuming PNG, but adjust based on actual content or headers
return {
buffer: buffer,
fileName: fileName,
fileType: contentType,
};
} catch (error) {
console.error('Error downloading character:', error);
throw error;
}
}
/**
* Parses an aicharactercards URL to extract the path.
* @param {string} url URL to parse
* @returns {string | null} AICC path
*/
function parseAICC(url) {
const pattern = /^https?:\/\/aicharactercards\.com\/character-cards\/([^/]+)\/([^/]+)\/?$|([^/]+)\/([^/]+)$/;
const match = url.match(pattern);
if (match) {
// Match group 1 & 2 for full URL, 3 & 4 for relative path
return match[1] && match[2] ? `${match[1]}/${match[2]}` : `${match[3]}/${match[4]}`;
}
return null;
}
/**
* Download character card from generic url.
* @param {String} url
*/
async function downloadGenericPng(url) {
try {
const result = await fetch(url);
if (result.ok) {
const buffer = Buffer.from(await result.arrayBuffer());
let fileName = sanitize(result.url.split('?')[0].split('/').reverse()[0]);
const contentType = result.headers.get('content-type') || 'image/png'; //yoink it from AICC function lol
// The `importCharacter()` function detects the MIME (content-type) of the file
// using its file extension. The problem is that not all third-party APIs serve
// their cards with a `.png` extension. To support more third-party sites,
// dynamically append the `.png` extension to the filename if it doesn't
// already have a file extension.
if (contentType === 'image/png') {
const ext = fileName.match(/\.(\w+)$/); // Same regex used by `importCharacter()`
if (!ext) {
fileName += '.png';
}
}
return {
buffer: buffer,
fileName: fileName,
fileType: contentType,
};
}
} catch (error) {
console.error('Error downloading file: ', error);
throw error;
}
return null;
}
/**
* Parse Risu Realm URL to extract the UUID.
* @param {string} url Risu Realm URL
* @returns {string | null} UUID of the character
*/
function parseRisuUrl(url) {
// Example: https://realm.risuai.net/character/7adb0ed8d81855c820b3506980fb40f054ceef010ff0c4bab73730c0ebe92279
// or https://realm.risuai.net/character/7adb0ed8-d818-55c8-20b3-506980fb40f0
const pattern = /^https?:\/\/realm\.risuai\.net\/character\/([a-f0-9-]+)\/?$/i;
const match = url.match(pattern);
return match ? match[1] : null;
}
/**
* Download RisuAI character card
* @param {string} uuid UUID of the character
* @returns {Promise<{buffer: Buffer, fileName: string, fileType: string}>}
*/
async function downloadRisuCharacter(uuid) {
const result = await fetch(`https://realm.risuai.net/api/v1/download/png-v3/${uuid}?non_commercial=true`);
if (!result.ok) {
const text = await result.text();
console.error('RisuAI returned error', result.statusText, text);
throw new Error('Failed to download character');
}
const buffer = Buffer.from(await result.arrayBuffer());
const fileName = `${sanitize(uuid)}.png`;
const fileType = 'image/png';
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, DEFAULT_AVATAR_PATH);
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
*/
function getUuidFromUrl(url) {
// Extract UUID from URL
const uuidRegex = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/;
const matches = url.match(uuidRegex);
// Check if UUID is found
const uuid = matches ? matches[0] : null;
return uuid;
}
/**
* Filter to get the domain host of a url instead of a blanket string search.
* @param {String} url URL to strip
* @returns {String} Domain name
*/
function getHostFromUrl(url) {
try {
const urlObj = new URL(url);
return urlObj.hostname;
} catch {
return '';
}
}
/**
* Checks if host is part of generic download source whitelist.
* @param {String} host Host to check
* @returns {boolean} If the host is on the whitelist.
*/
function isHostWhitelisted(host) {
return WHITELIST_GENERIC_URL_DOWNLOAD_SOURCES.includes(host);
}
export const router = express.Router();
router.post('/importURL', async (request, response) => {
if (!request.body.url) {
return response.sendStatus(400);
}
try {
const url = request.body.url;
const host = getHostFromUrl(url);
let result;
let type;
const isChub = host.includes('chub.ai') || host.includes('characterhub.org');
const isJannnyContent = host.includes('janitorai');
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) {
const uuid = getUuidFromUrl(url);
if (!uuid) {
return response.sendStatus(404);
}
type = 'character';
result = await downloadPygmalionCharacter(uuid);
} else if (isJannnyContent) {
const uuid = getUuidFromUrl(url);
if (!uuid) {
return response.sendStatus(404);
}
type = 'character';
result = await downloadJannyCharacter(uuid);
} else if (isAICharacterCardsContent) {
const AICCParsed = parseAICC(url);
if (!AICCParsed) {
return response.sendStatus(404);
}
type = 'character';
result = await downloadAICCCharacter(AICCParsed);
} else if (isChub) {
const chubParsed = parseChubUrl(url);
type = chubParsed?.type;
if (chubParsed?.type === 'character') {
console.info('Downloading chub character:', chubParsed.id);
result = await downloadChubCharacter(chubParsed.id);
}
else if (chubParsed?.type === 'lorebook') {
console.info('Downloading chub lorebook:', chubParsed.id);
result = await downloadChubLorebook(chubParsed.id);
}
else {
return response.sendStatus(404);
}
} else if (isRisu) {
const uuid = parseRisuUrl(url);
if (!uuid) {
return response.sendStatus(404);
}
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';
result = await downloadGenericPng(url);
} else {
console.error(`Received an import for "${getHostFromUrl(url)}", but site is not whitelisted. This domain must be added to the config key "whitelistImportDomains" to allow import from this source.`);
return response.sendStatus(404);
}
if (!result) {
return response.sendStatus(404);
}
if (result.fileType) response.set('Content-Type', result.fileType);
response.set('Content-Disposition', `attachment; filename="${encodeURI(result.fileName)}"`);
response.set('X-Custom-Content-Type', type);
return response.send(result.buffer);
} catch (error) {
console.error('Importing custom content failed', error);
return response.sendStatus(500);
}
});
router.post('/importUUID', async (request, response) => {
if (!request.body.url) {
return response.sendStatus(400);
}
try {
const uuid = request.body.url;
let result;
const isJannny = uuid.includes('_character');
const isPygmalion = (!isJannny && uuid.length == 36);
const isAICC = uuid.startsWith('AICC/');
const uuidType = uuid.includes('lorebook') ? 'lorebook' : 'character';
if (isPygmalion) {
console.info('Downloading Pygmalion character:', uuid);
result = await downloadPygmalionCharacter(uuid);
} else if (isJannny) {
console.info('Downloading Janitor character:', uuid.split('_')[0]);
result = await downloadJannyCharacter(uuid.split('_')[0]);
} else if (isAICC) {
const [, author, card] = uuid.split('/');
console.info('Downloading AICC character:', `${author}/${card}`);
result = await downloadAICCCharacter(`${author}/${card}`);
} else {
if (uuidType === 'character') {
console.info('Downloading chub character:', uuid);
result = await downloadChubCharacter(uuid);
}
else if (uuidType === 'lorebook') {
console.info('Downloading chub lorebook:', uuid);
result = await downloadChubLorebook(uuid);
}
else {
return response.sendStatus(404);
}
}
if (result.fileType) response.set('Content-Type', result.fileType);
response.set('Content-Disposition', `attachment; filename="${result.fileName}"`);
response.set('X-Custom-Content-Type', uuidType);
return response.send(result.buffer);
} catch (error) {
console.error('Importing custom content failed', error);
return response.sendStatus(500);
}
});