duzhong commited on
Commit
7962cde
·
verified ·
1 Parent(s): 09e8c1d

Upload src/endpoints/content-manager.js with huggingface_hub

Browse files
Files changed (1) hide show
  1. src/endpoints/content-manager.js +1045 -0
src/endpoints/content-manager.js ADDED
@@ -0,0 +1,1045 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import zlib from 'node:zlib';
4
+ import { Buffer } from 'node:buffer';
5
+
6
+ import express from 'express';
7
+ import fetch from 'node-fetch';
8
+ import sanitize from 'sanitize-filename';
9
+ import { sync as writeFileAtomicSync } from 'write-file-atomic';
10
+
11
+ import { getConfigValue, color, setPermissionsSync, isValidUrl } from '../util.js';
12
+ import { write } from '../character-card-parser.js';
13
+ import { serverDirectory } from '../server-directory.js';
14
+ import { Jimp, JimpMime } from '../jimp.js';
15
+ import { DEFAULT_AVATAR_PATH } from '../constants.js';
16
+
17
+ const contentDirectory = path.join(serverDirectory, 'default/content');
18
+ const scaffoldDirectory = path.join(serverDirectory, 'default/scaffold');
19
+ const contentIndexPath = path.join(contentDirectory, 'index.json');
20
+ const scaffoldIndexPath = path.join(scaffoldDirectory, 'index.json');
21
+
22
+ const WHITELIST_GENERIC_URL_DOWNLOAD_SOURCES = getConfigValue('whitelistImportDomains', []);
23
+ const USER_AGENT = 'SillyTavern';
24
+
25
+ /**
26
+ * @typedef {Object} ContentItem
27
+ * @property {string} filename
28
+ * @property {string} type
29
+ * @property {string} [name]
30
+ * @property {string|null} [folder]
31
+ */
32
+
33
+ /**
34
+ * @typedef {string} ContentType
35
+ * @enum {string}
36
+ */
37
+ export const CONTENT_TYPES = {
38
+ SETTINGS: 'settings',
39
+ CHARACTER: 'character',
40
+ SPRITES: 'sprites',
41
+ BACKGROUND: 'background',
42
+ WORLD: 'world',
43
+ AVATAR: 'avatar',
44
+ THEME: 'theme',
45
+ WORKFLOW: 'workflow',
46
+ KOBOLD_PRESET: 'kobold_preset',
47
+ OPENAI_PRESET: 'openai_preset',
48
+ NOVEL_PRESET: 'novel_preset',
49
+ TEXTGEN_PRESET: 'textgen_preset',
50
+ INSTRUCT: 'instruct',
51
+ CONTEXT: 'context',
52
+ MOVING_UI: 'moving_ui',
53
+ QUICK_REPLIES: 'quick_replies',
54
+ SYSPROMPT: 'sysprompt',
55
+ REASONING: 'reasoning',
56
+ };
57
+
58
+ /**
59
+ * Gets the default presets from the content directory.
60
+ * @param {import('../users.js').UserDirectoryList} directories User directories
61
+ * @returns {object[]} Array of default presets
62
+ */
63
+ export function getDefaultPresets(directories) {
64
+ try {
65
+ const contentIndex = getContentIndex();
66
+ const presets = [];
67
+
68
+ for (const contentItem of contentIndex) {
69
+ if (contentItem.type.endsWith('_preset') || ['instruct', 'context', 'sysprompt', 'reasoning'].includes(contentItem.type)) {
70
+ contentItem.name = path.parse(contentItem.filename).name;
71
+ contentItem.folder = getTargetByType(contentItem.type, directories);
72
+ presets.push(contentItem);
73
+ }
74
+ }
75
+
76
+ return presets;
77
+ } catch (err) {
78
+ console.warn('Failed to get default presets', err);
79
+ return [];
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Gets a default JSON file from the content directory.
85
+ * @param {string} filename Name of the file to get
86
+ * @returns {object | null} JSON object or null if the file doesn't exist
87
+ */
88
+ export function getDefaultPresetFile(filename) {
89
+ try {
90
+ const contentPath = path.join(contentDirectory, filename);
91
+
92
+ if (!fs.existsSync(contentPath)) {
93
+ return null;
94
+ }
95
+
96
+ const fileContent = fs.readFileSync(contentPath, 'utf8');
97
+ return JSON.parse(fileContent);
98
+ } catch (err) {
99
+ console.warn(`Failed to get default file ${filename}`, err);
100
+ return null;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Seeds content for a user.
106
+ * @param {ContentItem[]} contentIndex Content index
107
+ * @param {import('../users.js').UserDirectoryList} directories User directories
108
+ * @param {string[]} forceCategories List of categories to force check (even if content check is skipped)
109
+ * @returns {Promise<boolean>} Whether any content was added
110
+ */
111
+ async function seedContentForUser(contentIndex, directories, forceCategories) {
112
+ let anyContentAdded = false;
113
+
114
+ if (!fs.existsSync(directories.root)) {
115
+ fs.mkdirSync(directories.root, { recursive: true });
116
+ }
117
+
118
+ const contentLogPath = path.join(directories.root, 'content.log');
119
+ const contentLog = getContentLog(contentLogPath);
120
+
121
+ for (const contentItem of contentIndex) {
122
+ // If the content item is already in the log, skip it
123
+ if (contentLog.includes(contentItem.filename) && !forceCategories?.includes(contentItem.type)) {
124
+ continue;
125
+ }
126
+
127
+ if (!contentItem.folder) {
128
+ console.warn(`Content file ${contentItem.filename} has no parent folder`);
129
+ continue;
130
+ }
131
+
132
+ const contentPath = path.join(contentItem.folder, contentItem.filename);
133
+
134
+ if (!fs.existsSync(contentPath)) {
135
+ console.warn(`Content file ${contentItem.filename} is missing`);
136
+ continue;
137
+ }
138
+
139
+ const contentTarget = getTargetByType(contentItem.type, directories);
140
+
141
+ if (!contentTarget) {
142
+ console.warn(`Content file ${contentItem.filename} has unknown type ${contentItem.type}`);
143
+ continue;
144
+ }
145
+
146
+ const basePath = path.parse(contentItem.filename).base;
147
+ const targetPath = path.join(contentTarget, basePath);
148
+ contentLog.push(contentItem.filename);
149
+
150
+ if (fs.existsSync(targetPath)) {
151
+ console.warn(`Content file ${contentItem.filename} already exists in ${contentTarget}`);
152
+ continue;
153
+ }
154
+
155
+ fs.cpSync(contentPath, targetPath, { recursive: true, force: false });
156
+ setPermissionsSync(targetPath);
157
+ console.info(`Content file ${contentItem.filename} copied to ${contentTarget}`);
158
+ anyContentAdded = true;
159
+ }
160
+
161
+ writeFileAtomicSync(contentLogPath, contentLog.join('\n'));
162
+ return anyContentAdded;
163
+ }
164
+
165
+ /**
166
+ * Checks for new content and seeds it for all users.
167
+ * @param {import('../users.js').UserDirectoryList[]} directoriesList List of user directories
168
+ * @param {string[]} forceCategories List of categories to force check (even if content check is skipped)
169
+ * @returns {Promise<void>}
170
+ */
171
+ export async function checkForNewContent(directoriesList, forceCategories = []) {
172
+ try {
173
+ const contentCheckSkip = getConfigValue('skipContentCheck', false, 'boolean');
174
+ if (contentCheckSkip && forceCategories?.length === 0) {
175
+ return;
176
+ }
177
+
178
+ const contentIndex = getContentIndex();
179
+ let anyContentAdded = false;
180
+
181
+ for (const directories of directoriesList) {
182
+ const seedResult = await seedContentForUser(contentIndex, directories, forceCategories);
183
+
184
+ if (seedResult) {
185
+ anyContentAdded = true;
186
+ }
187
+ }
188
+
189
+ if (anyContentAdded && !contentCheckSkip && forceCategories?.length === 0) {
190
+ console.info();
191
+ 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.')}`);
192
+ console.info();
193
+ }
194
+ } catch (err) {
195
+ console.error('Content check failed', err);
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Gets combined content index from the content and scaffold directories.
201
+ * @returns {ContentItem[]} Array of content index
202
+ */
203
+ function getContentIndex() {
204
+ const result = [];
205
+
206
+ if (fs.existsSync(scaffoldIndexPath)) {
207
+ const scaffoldIndexText = fs.readFileSync(scaffoldIndexPath, 'utf8');
208
+ const scaffoldIndex = JSON.parse(scaffoldIndexText);
209
+ if (Array.isArray(scaffoldIndex)) {
210
+ scaffoldIndex.forEach((item) => {
211
+ item.folder = scaffoldDirectory;
212
+ });
213
+ result.push(...scaffoldIndex);
214
+ }
215
+ }
216
+
217
+ if (fs.existsSync(contentIndexPath)) {
218
+ const contentIndexText = fs.readFileSync(contentIndexPath, 'utf8');
219
+ const contentIndex = JSON.parse(contentIndexText);
220
+ if (Array.isArray(contentIndex)) {
221
+ contentIndex.forEach((item) => {
222
+ item.folder = contentDirectory;
223
+ });
224
+ result.push(...contentIndex);
225
+ }
226
+ }
227
+
228
+ return result;
229
+ }
230
+
231
+ /**
232
+ * Gets content by type and format.
233
+ * @param {string} type Type of content
234
+ * @param {'json'|'string'|'raw'} format Format of content
235
+ * @returns {string[]|Buffer[]} Array of content
236
+ */
237
+ export function getContentOfType(type, format) {
238
+ const contentIndex = getContentIndex();
239
+ const indexItems = contentIndex.filter((item) => item.type === type && item.folder);
240
+ const files = [];
241
+ for (const item of indexItems) {
242
+ if (!item.folder) {
243
+ continue;
244
+ }
245
+ try {
246
+ const filePath = path.join(item.folder, item.filename);
247
+ const fileContent = fs.readFileSync(filePath);
248
+ switch (format) {
249
+ case 'json':
250
+ files.push(JSON.parse(fileContent.toString()));
251
+ break;
252
+ case 'string':
253
+ files.push(fileContent.toString());
254
+ break;
255
+ case 'raw':
256
+ files.push(fileContent);
257
+ break;
258
+ }
259
+ } catch {
260
+ // Ignore errors
261
+ }
262
+ }
263
+ return files;
264
+ }
265
+
266
+ /**
267
+ * Gets the target directory for the specified asset type.
268
+ * @param {ContentType} type Asset type
269
+ * @param {import('../users.js').UserDirectoryList} directories User directories
270
+ * @returns {string | null} Target directory
271
+ */
272
+ function getTargetByType(type, directories) {
273
+ switch (type) {
274
+ case CONTENT_TYPES.SETTINGS:
275
+ return directories.root;
276
+ case CONTENT_TYPES.CHARACTER:
277
+ return directories.characters;
278
+ case CONTENT_TYPES.SPRITES:
279
+ return directories.characters;
280
+ case CONTENT_TYPES.BACKGROUND:
281
+ return directories.backgrounds;
282
+ case CONTENT_TYPES.WORLD:
283
+ return directories.worlds;
284
+ case CONTENT_TYPES.AVATAR:
285
+ return directories.avatars;
286
+ case CONTENT_TYPES.THEME:
287
+ return directories.themes;
288
+ case CONTENT_TYPES.WORKFLOW:
289
+ return directories.comfyWorkflows;
290
+ case CONTENT_TYPES.KOBOLD_PRESET:
291
+ return directories.koboldAI_Settings;
292
+ case CONTENT_TYPES.OPENAI_PRESET:
293
+ return directories.openAI_Settings;
294
+ case CONTENT_TYPES.NOVEL_PRESET:
295
+ return directories.novelAI_Settings;
296
+ case CONTENT_TYPES.TEXTGEN_PRESET:
297
+ return directories.textGen_Settings;
298
+ case CONTENT_TYPES.INSTRUCT:
299
+ return directories.instruct;
300
+ case CONTENT_TYPES.CONTEXT:
301
+ return directories.context;
302
+ case CONTENT_TYPES.MOVING_UI:
303
+ return directories.movingUI;
304
+ case CONTENT_TYPES.QUICK_REPLIES:
305
+ return directories.quickreplies;
306
+ case CONTENT_TYPES.SYSPROMPT:
307
+ return directories.sysprompt;
308
+ case CONTENT_TYPES.REASONING:
309
+ return directories.reasoning;
310
+ default:
311
+ return null;
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Gets the content log from the content log file.
317
+ * @param {string} contentLogPath Path to the content log file
318
+ * @returns {string[]} Array of content log lines
319
+ */
320
+ function getContentLog(contentLogPath) {
321
+ if (!fs.existsSync(contentLogPath)) {
322
+ return [];
323
+ }
324
+
325
+ const contentLogText = fs.readFileSync(contentLogPath, 'utf8');
326
+ return contentLogText.split('\n');
327
+ }
328
+
329
+ async function downloadChubLorebook(id) {
330
+ const [lorebooks, creatorName, projectName] = id.split('/');
331
+ const result = await fetch(`https://api.chub.ai/api/${lorebooks}/${creatorName}/${projectName}`, {
332
+ method: 'GET',
333
+ headers: { 'Accept': 'application/json', 'User-Agent': USER_AGENT },
334
+ });
335
+
336
+ if (!result.ok) {
337
+ const text = await result.text();
338
+ console.error('Chub returned error', result.statusText, text);
339
+ throw new Error('Failed to fetch lorebook metadata');
340
+ }
341
+
342
+ /** @type {any} */
343
+ const metadata = await result.json();
344
+ const projectId = metadata.node?.id;
345
+
346
+ if (!projectId) {
347
+ throw new Error('Project ID not found in lorebook metadata');
348
+ }
349
+
350
+ const downloadUrl = `https://api.chub.ai/api/v4/projects/${projectId}/repository/files/raw%252Fsillytavern_raw.json/raw`;
351
+ const downloadResult = await fetch(downloadUrl, {
352
+ method: 'GET',
353
+ headers: { 'Accept': 'application/json', 'User-Agent': USER_AGENT },
354
+ });
355
+
356
+ if (!downloadResult.ok) {
357
+ const text = await downloadResult.text();
358
+ console.error('Chub returned error', downloadResult.statusText, text);
359
+ throw new Error('Failed to download lorebook');
360
+ }
361
+
362
+ const name = projectName;
363
+ const buffer = Buffer.from(await downloadResult.arrayBuffer());
364
+ const fileName = `${sanitize(name)}.json`;
365
+ const fileType = downloadResult.headers.get('content-type');
366
+
367
+ return { buffer, fileName, fileType };
368
+ }
369
+
370
+ async function downloadChubCharacter(id) {
371
+ const [creatorName, projectName] = id.split('/');
372
+ const result = await fetch(`https://api.chub.ai/api/characters/${creatorName}/${projectName}?full=true`, {
373
+ method: 'GET',
374
+ headers: { 'Accept': 'application/json', 'User-Agent': USER_AGENT },
375
+ });
376
+
377
+ if (!result.ok) {
378
+ const text = await result.text();
379
+ console.error('Chub returned error', result.statusText, text);
380
+ throw new Error('Failed to fetch character metadata');
381
+ }
382
+
383
+ /** @type {any} */
384
+ const metadata = await result.json();
385
+ const { definition, topics } = metadata.node;
386
+
387
+ /** @type {TavernCardV2} */
388
+ const characterCard = {
389
+ data: {
390
+ name: definition.name,
391
+ description: definition.personality,
392
+ personality: definition.tavern_personality,
393
+ scenario: definition.scenario,
394
+ first_mes: definition.first_message,
395
+ mes_example: definition.example_dialogs,
396
+ creator_notes: definition.description,
397
+ system_prompt: definition.system_prompt,
398
+ post_history_instructions: definition.post_history_instructions,
399
+ alternate_greetings: definition.alternate_greetings,
400
+ tags: topics,
401
+ creator: creatorName,
402
+ character_version: '',
403
+ character_book: definition.embedded_lorebook,
404
+ extensions: definition.extensions,
405
+ },
406
+ spec: 'chara_card_v2',
407
+ spec_version: '2.0',
408
+ };
409
+
410
+ const defaultAvatarPath = path.join(serverDirectory, DEFAULT_AVATAR_PATH);
411
+ const defaultAvatarBuffer = fs.readFileSync(defaultAvatarPath);
412
+
413
+ let imageBuffer = defaultAvatarBuffer;
414
+
415
+ const imageUrl = metadata.node?.max_res_url;
416
+
417
+ if (imageUrl) {
418
+ const downloadResult = await fetch(imageUrl);
419
+ if (downloadResult.ok) {
420
+ imageBuffer = Buffer.from(await downloadResult.arrayBuffer());
421
+ }
422
+ }
423
+
424
+ const buffer = write(imageBuffer, JSON.stringify(characterCard));
425
+ const fileName = `${sanitize(characterCard.data.name)}.png`;
426
+ const fileType = 'image/png';
427
+
428
+ return { buffer, fileName, fileType };
429
+ }
430
+
431
+ /**
432
+ * Downloads a character card from the Pygsite.
433
+ * @param {string} id UUID of the character
434
+ * @returns {Promise<{buffer: Buffer, fileName: string, fileType: string}>}
435
+ */
436
+ async function downloadPygmalionCharacter(id) {
437
+ const result = await fetch(`https://server.pygmalion.chat/api/export/character/${id}/v2`);
438
+
439
+ if (!result.ok) {
440
+ const text = await result.text();
441
+ console.error('Pygsite returned error', result.status, text);
442
+ throw new Error('Failed to download character');
443
+ }
444
+
445
+ /** @type {any} */
446
+ const jsonData = await result.json();
447
+ const characterData = jsonData?.character;
448
+
449
+ if (!characterData || typeof characterData !== 'object') {
450
+ console.error('Pygsite returned invalid character data', jsonData);
451
+ throw new Error('Failed to download character');
452
+ }
453
+
454
+ try {
455
+ const avatarUrl = characterData?.data?.avatar;
456
+
457
+ if (!avatarUrl) {
458
+ console.error('Pygsite character does not have an avatar', characterData);
459
+ throw new Error('Failed to download avatar');
460
+ }
461
+
462
+ const avatarResult = await fetch(avatarUrl);
463
+ const avatarBuffer = Buffer.from(await avatarResult.arrayBuffer());
464
+
465
+ const cardBuffer = write(avatarBuffer, JSON.stringify(characterData));
466
+
467
+ return {
468
+ buffer: cardBuffer,
469
+ fileName: `${sanitize(id)}.png`,
470
+ fileType: 'image/png',
471
+ };
472
+ } catch (e) {
473
+ console.error('Failed to download avatar, using JSON instead', e);
474
+ return {
475
+ buffer: Buffer.from(JSON.stringify(jsonData)),
476
+ fileName: `${sanitize(id)}.json`,
477
+ fileType: 'application/json',
478
+ };
479
+ }
480
+ }
481
+
482
+ /**
483
+ *
484
+ * @param {String} str
485
+ * @returns { { id: string, type: "character" | "lorebook" } | null }
486
+ */
487
+ function parseChubUrl(str) {
488
+ const splitStr = str.split('/');
489
+ const length = splitStr.length;
490
+
491
+ if (length < 2) {
492
+ return null;
493
+ }
494
+
495
+ let domainIndex = -1;
496
+
497
+ splitStr.forEach((part, index) => {
498
+ if (part === 'www.chub.ai' || part === 'chub.ai' || part === 'www.characterhub.org' || part === 'characterhub.org') {
499
+ domainIndex = index;
500
+ }
501
+ });
502
+
503
+ const lastTwo = domainIndex !== -1 ? splitStr.slice(domainIndex + 1) : splitStr;
504
+
505
+ const firstPart = lastTwo[0].toLowerCase();
506
+
507
+ if (firstPart === 'characters' || firstPart === 'lorebooks') {
508
+ const type = firstPart === 'characters' ? 'character' : 'lorebook';
509
+ const id = type === 'character' ? lastTwo.slice(1).join('/') : lastTwo.join('/');
510
+ return {
511
+ id: id,
512
+ type: type,
513
+ };
514
+ } else if (length === 2) {
515
+ return {
516
+ id: lastTwo.join('/'),
517
+ type: 'character',
518
+ };
519
+ }
520
+
521
+ return null;
522
+ }
523
+
524
+ // Warning: Some characters might not exist in JannyAI.me
525
+ async function downloadJannyCharacter(uuid) {
526
+ // This endpoint is being guarded behind Bot Fight Mode
527
+ // So hosted ST on Azure/AWS/GCP/Collab might get blocked by IP
528
+ // Should work normally on self-host PC/Android
529
+ const result = await fetch('https://api.jannyai.com/api/v1/download', {
530
+ method: 'POST',
531
+ headers: { 'Content-Type': 'application/json' },
532
+ body: JSON.stringify({
533
+ 'characterId': uuid,
534
+ }),
535
+ });
536
+
537
+ if (result.ok) {
538
+ /** @type {any} */
539
+ const downloadResult = await result.json();
540
+ if (downloadResult.status === 'ok') {
541
+ const imageResult = await fetch(downloadResult.downloadUrl);
542
+ const buffer = Buffer.from(await imageResult.arrayBuffer());
543
+ const fileName = `${sanitize(uuid)}.png`;
544
+ const fileType = imageResult.headers.get('content-type');
545
+
546
+ return { buffer, fileName, fileType };
547
+ } else {
548
+ console.error('Janny failed to download', downloadResult);
549
+ }
550
+ } else {
551
+ console.error('Janny returned error', result.statusText, await result.text());
552
+ }
553
+
554
+ throw new Error('Failed to download character');
555
+ }
556
+
557
+ //Download Character Cards from AICharactersCards.com (AICC) API.
558
+ async function downloadAICCCharacter(id) {
559
+ const apiURL = `https://aicharactercards.com/wp-json/pngapi/v1/image/${id}`;
560
+ try {
561
+ const response = await fetch(apiURL);
562
+ if (!response.ok) {
563
+ throw new Error(`Failed to download character: ${response.statusText}`);
564
+ }
565
+
566
+ const contentType = response.headers.get('content-type') || 'image/png'; // Default to 'image/png' if header is missing
567
+ const buffer = Buffer.from(await response.arrayBuffer());
568
+ const fileName = `${sanitize(id)}.png`; // Assuming PNG, but adjust based on actual content or headers
569
+
570
+ return {
571
+ buffer: buffer,
572
+ fileName: fileName,
573
+ fileType: contentType,
574
+ };
575
+ } catch (error) {
576
+ console.error('Error downloading character:', error);
577
+ throw error;
578
+ }
579
+ }
580
+
581
+ /**
582
+ * Parses an aicharactercards URL to extract the path.
583
+ * @param {string} url URL to parse
584
+ * @returns {string | null} AICC path
585
+ */
586
+ function parseAICC(url) {
587
+ const pattern = /^https?:\/\/aicharactercards\.com\/character-cards\/([^/]+)\/([^/]+)\/?$|([^/]+)\/([^/]+)$/;
588
+ const match = url.match(pattern);
589
+ if (match) {
590
+ // Match group 1 & 2 for full URL, 3 & 4 for relative path
591
+ return match[1] && match[2] ? `${match[1]}/${match[2]}` : `${match[3]}/${match[4]}`;
592
+ }
593
+ return null;
594
+ }
595
+
596
+ /**
597
+ * Download character card from generic url.
598
+ * @param {String} url
599
+ */
600
+ async function downloadGenericPng(url) {
601
+ try {
602
+ const result = await fetch(url);
603
+
604
+ if (result.ok) {
605
+ const buffer = Buffer.from(await result.arrayBuffer());
606
+ let fileName = sanitize(result.url.split('?')[0].split('/').reverse()[0]);
607
+ const contentType = result.headers.get('content-type') || 'image/png'; //yoink it from AICC function lol
608
+
609
+ // The `importCharacter()` function detects the MIME (content-type) of the file
610
+ // using its file extension. The problem is that not all third-party APIs serve
611
+ // their cards with a `.png` extension. To support more third-party sites,
612
+ // dynamically append the `.png` extension to the filename if it doesn't
613
+ // already have a file extension.
614
+ if (contentType === 'image/png') {
615
+ const ext = fileName.match(/\.(\w+)$/); // Same regex used by `importCharacter()`
616
+ if (!ext) {
617
+ fileName += '.png';
618
+ }
619
+ }
620
+
621
+ return {
622
+ buffer: buffer,
623
+ fileName: fileName,
624
+ fileType: contentType,
625
+ };
626
+ }
627
+ } catch (error) {
628
+ console.error('Error downloading file: ', error);
629
+ throw error;
630
+ }
631
+ return null;
632
+ }
633
+
634
+ /**
635
+ * Parse Risu Realm URL to extract the UUID.
636
+ * @param {string} url Risu Realm URL
637
+ * @returns {string | null} UUID of the character
638
+ */
639
+ function parseRisuUrl(url) {
640
+ // Example: https://realm.risuai.net/character/7adb0ed8d81855c820b3506980fb40f054ceef010ff0c4bab73730c0ebe92279
641
+ // or https://realm.risuai.net/character/7adb0ed8-d818-55c8-20b3-506980fb40f0
642
+ const pattern = /^https?:\/\/realm\.risuai\.net\/character\/([a-f0-9-]+)\/?$/i;
643
+ const match = url.match(pattern);
644
+ return match ? match[1] : null;
645
+ }
646
+
647
+ /**
648
+ * Download RisuAI character card
649
+ * @param {string} uuid UUID of the character
650
+ * @returns {Promise<{buffer: Buffer, fileName: string, fileType: string}>}
651
+ */
652
+ async function downloadRisuCharacter(uuid) {
653
+ const result = await fetch(`https://realm.risuai.net/api/v1/download/png-v3/${uuid}?non_commercial=true`);
654
+
655
+ if (!result.ok) {
656
+ const text = await result.text();
657
+ console.error('RisuAI returned error', result.statusText, text);
658
+ throw new Error('Failed to download character');
659
+ }
660
+
661
+ const buffer = Buffer.from(await result.arrayBuffer());
662
+ const fileName = `${sanitize(uuid)}.png`;
663
+ const fileType = 'image/png';
664
+
665
+ return { buffer, fileName, fileType };
666
+ }
667
+
668
+ /** * Check if the given string is a valid Perchance UUID.
669
+ * @param {string} uuid UUID string to check
670
+ * @returns {boolean} True if the UUID is valid, false otherwise
671
+ */
672
+ function isPerchanceUUID(uuid) {
673
+ if (!uuid) {
674
+ return false;
675
+ }
676
+
677
+ //example: Personality_Advisor~6903e991c90fd1dba52c036d917e99c6.gz
678
+ //charactername~uuid.gz
679
+
680
+ const uuidRegex = /^\w+~[a-f0-9]{32}\.gz$/;
681
+ return uuidRegex.test(uuid);
682
+ }
683
+
684
+ /**
685
+ * Parse Perchance URL to extract the character slug.
686
+ * @param {string} url Perchance character URL
687
+ * @returns {string} Slug of the character
688
+ */
689
+ function parsePerchanceSlug(url) {
690
+ // Example: https://perchance.org/ai-character-chat?data=Personality_Advisor~6903e991c90fd1dba52c036d917e99c6.gz
691
+ // or: Personality_Advisor~6903e991c90fd1dba52c036d917e99c6.gz
692
+ return url?.split('~')[1] || '';
693
+ }
694
+
695
+ /**
696
+ * Download Perchance character card
697
+ * @param {string} slug Slug of the character
698
+ * @returns {Promise<{buffer: Buffer, fileName: string, fileType: string} | null>}
699
+ */
700
+ async function downloadPerchanceCharacter(slug) {
701
+ // example of slug
702
+ // 6903e991c90fd1dba52c036d917e99c6.gz
703
+ const perchanceBaseURL = 'https://user.uploads.dev/file';
704
+
705
+ try {
706
+ const charURL = `${perchanceBaseURL}/${slug}`;
707
+ console.log('Downloading Perchance character from URL:', charURL);
708
+ const result = await fetch(charURL, {
709
+ headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
710
+ });
711
+
712
+ //decompress gzipped content
713
+ if (result.ok) {
714
+ const perchanceChar = await extractPerchanceCharacterFromGz(result);
715
+
716
+ const avatarUrl = perchanceChar.avatar?.url;
717
+
718
+ //check if avatarURL is a base64 of any image type
719
+ const isAvatarBase64 = avatarUrl && avatarUrl.startsWith('data:image/');
720
+
721
+ const charData = {
722
+ name: perchanceChar.name || 'Unnamed Perchance Character',
723
+ first_mes: '',
724
+ tags: [],
725
+ description: perchanceChar.roleInstruction || '',
726
+ creator: perchanceChar.metaTitle || '',
727
+ creator_notes: perchanceChar.metaDescription || '',
728
+ alternate_greetings: [],
729
+ character_version: '',
730
+ mes_example: '',
731
+ post_history_instructions: '',
732
+ system_prompt: '',
733
+ scenario: '',
734
+ personality: perchanceChar.reminderMessage || '',
735
+ extensions: {
736
+ perchance_data: {
737
+ slug: slug,
738
+ char_url: charURL,
739
+ uuid: perchanceChar.uuid || null,
740
+ avatar_url: isAvatarBase64 ? null : (avatarUrl || null),
741
+ folder_path: perchanceChar.folderPath || null,
742
+ folder_name: perchanceChar.folderName || null,
743
+ custom_data: perchanceChar.customData || {},
744
+ },
745
+ },
746
+ };
747
+
748
+ const avatarBuffer = await fetchPerchanceAvatar(avatarUrl, isAvatarBase64);
749
+
750
+ // Character card
751
+ const buffer = write(avatarBuffer, JSON.stringify({
752
+ 'spec': 'chara_card_v2',
753
+ 'spec_version': '2.0',
754
+ 'data': charData,
755
+ }));
756
+
757
+ const fileName = `${charData.name}.png`;
758
+ const fileType = 'image/png';
759
+
760
+ return { buffer, fileName, fileType };
761
+ }
762
+ } catch (error) {
763
+ console.error('Error downloading character:', error);
764
+ throw error;
765
+ }
766
+ return null;
767
+ }
768
+
769
+ /**
770
+ * Extracts Perchance character data from a gzipped response.
771
+ * @param {import('node-fetch').Response} result Fetch response containing gzipped character data
772
+ * @returns {Promise<Object>} Parsed Perchance character data
773
+ * @throws {Error} If the character data is invalid or missing required fields
774
+ */
775
+ async function extractPerchanceCharacterFromGz(result) {
776
+ const compressedBuffer = await result.arrayBuffer();
777
+ const decompressedBuffer = zlib.gunzipSync(compressedBuffer);
778
+
779
+ // inside the gz file, there is a file of the same name without extensions, but it is a json file
780
+
781
+ if (!decompressedBuffer || decompressedBuffer.length === 0) {
782
+ console.error('Perchance character data is empty or invalid');
783
+ throw new Error('Failed to download character: Invalid Perchance character data');
784
+ }
785
+
786
+ // Parse the decompressed JSON
787
+ const perchanceCharData = JSON.parse(decompressedBuffer.toString());
788
+
789
+ if (!perchanceCharData?.addCharacter) {
790
+ console.error('Perchance character data is missing addCharacter field', perchanceCharData);
791
+ throw new Error('Failed to download character: Invalid Perchance character data');
792
+ }
793
+
794
+ return perchanceCharData.addCharacter;
795
+ }
796
+
797
+ /** * Fetches the avatar from Perchance URL or uses a default avatar if not available.
798
+ * @param {string} avatarUrl URL of the avatar
799
+ * @param {boolean} isAvatarBase64 Flag indicating if the avatar URL is a base64 string
800
+ * @returns {Promise<Buffer>} Buffer containing the avatar image
801
+ */
802
+ async function fetchPerchanceAvatar(avatarUrl, isAvatarBase64) {
803
+ const defaultAvatarPath = path.join(serverDirectory, DEFAULT_AVATAR_PATH);
804
+ const defaultAvatarBuffer = fs.readFileSync(defaultAvatarPath);
805
+
806
+ if (!avatarUrl || (!isAvatarBase64 && !isValidUrl(avatarUrl))) {
807
+ console.warn('Perchance character does not have an avatar, it is not base64, or it is an invalid url, using default avatar');
808
+ return defaultAvatarBuffer;
809
+ }
810
+
811
+ if (isAvatarBase64) {
812
+ // check if avatarUrl is a png
813
+ const isPng = avatarUrl.startsWith('data:image/png;base64,');
814
+ const base64 = avatarUrl.split(',')[1];
815
+ const buffer = Buffer.from(base64, 'base64');
816
+
817
+ if (isPng) {
818
+ return buffer;
819
+ } else {
820
+ // use jimp to convert the base64 to PNG if it's not PNG
821
+ console.debug('Perchance character avatar is not PNG, converting to PNG...');
822
+ return await Jimp.read(buffer).then(image => image.getBuffer(JimpMime.png));
823
+ }
824
+ }
825
+
826
+ // Fetch avatar from URL
827
+ console.log('Fetching Perchance avatar from URL:', avatarUrl);
828
+ const avatarResponse = await fetch(avatarUrl, { headers: { 'User-Agent': USER_AGENT } });
829
+
830
+ if (avatarResponse.ok) {
831
+ const avatarContentType = avatarResponse.headers.get('content-type');
832
+ const avatarBuffer = Buffer.from(await avatarResponse.arrayBuffer());
833
+
834
+ if (avatarContentType === 'image/png') {
835
+ return avatarBuffer;
836
+ } else {
837
+ console.debug(`Perchance character avatar is not PNG: ${avatarContentType}. Converting to PNG...`);
838
+
839
+ // use jimp to convert the image to PNG if it's not PNG
840
+ return await Jimp.read(avatarBuffer)
841
+ .then(image => image.getBuffer(JimpMime.png));
842
+ }
843
+ }
844
+
845
+ console.error('Failed to fetch Perchance avatar:', avatarResponse.statusText);
846
+ const isPerchanceOrgFileUploader = avatarUrl.includes('https://user-uploads.perchance.org');
847
+
848
+ if (isPerchanceOrgFileUploader) {
849
+ console.warn('Files from https://user-uploads.perchance.org are sometimes blocked by the provider, try reuploading it in https://perchance.org/upload to get the new link from https://user-uploads.dev instead.');
850
+ }
851
+
852
+ console.warn('You can also download the avatar manually and assign it to the character:', avatarUrl);
853
+ return defaultAvatarBuffer;
854
+ }
855
+
856
+ /**
857
+ * @param {String} url
858
+ * @returns {String | null } UUID of the character
859
+ */
860
+ function getUuidFromUrl(url) {
861
+ // Extract UUID from URL
862
+ const uuidRegex = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/;
863
+ const matches = url.match(uuidRegex);
864
+
865
+ // Check if UUID is found
866
+ const uuid = matches ? matches[0] : null;
867
+ return uuid;
868
+ }
869
+
870
+ /**
871
+ * Filter to get the domain host of a url instead of a blanket string search.
872
+ * @param {String} url URL to strip
873
+ * @returns {String} Domain name
874
+ */
875
+ function getHostFromUrl(url) {
876
+ try {
877
+ const urlObj = new URL(url);
878
+ return urlObj.hostname;
879
+ } catch {
880
+ return '';
881
+ }
882
+ }
883
+
884
+ /**
885
+ * Checks if host is part of generic download source whitelist.
886
+ * @param {String} host Host to check
887
+ * @returns {boolean} If the host is on the whitelist.
888
+ */
889
+ function isHostWhitelisted(host) {
890
+ return WHITELIST_GENERIC_URL_DOWNLOAD_SOURCES.includes(host);
891
+ }
892
+
893
+ export const router = express.Router();
894
+
895
+ router.post('/importURL', async (request, response) => {
896
+ if (!request.body.url) {
897
+ return response.sendStatus(400);
898
+ }
899
+
900
+ try {
901
+ const url = request.body.url;
902
+ const host = getHostFromUrl(url);
903
+ let result;
904
+ let type;
905
+
906
+ const isChub = host.includes('chub.ai') || host.includes('characterhub.org');
907
+ const isJannnyContent = host.includes('janitorai');
908
+ const isPygmalionContent = host.includes('pygmalion.chat');
909
+ const isAICharacterCardsContent = host.includes('aicharactercards.com');
910
+ const isRisu = host.includes('realm.risuai.net');
911
+ const isPerchance = host.includes('perchance.org');
912
+ const isGeneric = isHostWhitelisted(host);
913
+
914
+ if (isPygmalionContent) {
915
+ const uuid = getUuidFromUrl(url);
916
+ if (!uuid) {
917
+ return response.sendStatus(404);
918
+ }
919
+
920
+ type = 'character';
921
+ result = await downloadPygmalionCharacter(uuid);
922
+ } else if (isJannnyContent) {
923
+ const uuid = getUuidFromUrl(url);
924
+ if (!uuid) {
925
+ return response.sendStatus(404);
926
+ }
927
+
928
+ type = 'character';
929
+ result = await downloadJannyCharacter(uuid);
930
+ } else if (isAICharacterCardsContent) {
931
+ const AICCParsed = parseAICC(url);
932
+ if (!AICCParsed) {
933
+ return response.sendStatus(404);
934
+ }
935
+ type = 'character';
936
+ result = await downloadAICCCharacter(AICCParsed);
937
+ } else if (isChub) {
938
+ const chubParsed = parseChubUrl(url);
939
+ type = chubParsed?.type;
940
+
941
+ if (chubParsed?.type === 'character') {
942
+ console.info('Downloading chub character:', chubParsed.id);
943
+ result = await downloadChubCharacter(chubParsed.id);
944
+ }
945
+ else if (chubParsed?.type === 'lorebook') {
946
+ console.info('Downloading chub lorebook:', chubParsed.id);
947
+ result = await downloadChubLorebook(chubParsed.id);
948
+ }
949
+ else {
950
+ return response.sendStatus(404);
951
+ }
952
+ } else if (isRisu) {
953
+ const uuid = parseRisuUrl(url);
954
+ if (!uuid) {
955
+ return response.sendStatus(404);
956
+ }
957
+
958
+ type = 'character';
959
+ result = await downloadRisuCharacter(uuid);
960
+ } else if (isPerchance) {
961
+ const perchanceSlug = parsePerchanceSlug(url);
962
+ if (!perchanceSlug) {
963
+ return response.sendStatus(404);
964
+ }
965
+ type = 'character';
966
+ result = await downloadPerchanceCharacter(perchanceSlug);
967
+ } else if (isGeneric) {
968
+ console.info('Downloading from generic url:', url);
969
+ type = 'character';
970
+ result = await downloadGenericPng(url);
971
+ } else {
972
+ 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.`);
973
+ return response.sendStatus(404);
974
+ }
975
+
976
+ if (!result) {
977
+ return response.sendStatus(404);
978
+ }
979
+
980
+ if (result.fileType) response.set('Content-Type', result.fileType);
981
+ response.set('Content-Disposition', `attachment; filename="${encodeURI(result.fileName)}"`);
982
+ response.set('X-Custom-Content-Type', type);
983
+ return response.send(result.buffer);
984
+ } catch (error) {
985
+ console.error('Importing custom content failed', error);
986
+ return response.sendStatus(500);
987
+ }
988
+ });
989
+
990
+ router.post('/importUUID', async (request, response) => {
991
+ if (!request.body.url) {
992
+ return response.sendStatus(400);
993
+ }
994
+
995
+ try {
996
+ const uuid = request.body.url;
997
+ let result;
998
+
999
+ const isJannny = uuid.includes('_character');
1000
+ const isPygmalion = (!isJannny && uuid.length == 36);
1001
+ const isAICC = uuid.startsWith('AICC/');
1002
+ const isPerchance = isPerchanceUUID(uuid);
1003
+ const uuidType = uuid.includes('lorebook') ? 'lorebook' : 'character';
1004
+
1005
+ if (isPygmalion) {
1006
+ console.info('Downloading Pygmalion character:', uuid);
1007
+ result = await downloadPygmalionCharacter(uuid);
1008
+ } else if (isJannny) {
1009
+ console.info('Downloading Janitor character:', uuid.split('_')[0]);
1010
+ result = await downloadJannyCharacter(uuid.split('_')[0]);
1011
+ } else if (isAICC) {
1012
+ const [, author, card] = uuid.split('/');
1013
+ console.info('Downloading AICC character:', `${author}/${card}`);
1014
+ result = await downloadAICCCharacter(`${author}/${card}`);
1015
+ } else if (isPerchance) {
1016
+ console.info('Downloading Perchance character:', uuid);
1017
+ const parsedUuid = parsePerchanceSlug(uuid);
1018
+ result = await downloadPerchanceCharacter(parsedUuid);
1019
+ } else {
1020
+ if (uuidType === 'character') {
1021
+ console.info('Downloading chub character:', uuid);
1022
+ result = await downloadChubCharacter(uuid);
1023
+ }
1024
+ else if (uuidType === 'lorebook') {
1025
+ console.info('Downloading chub lorebook:', uuid);
1026
+ result = await downloadChubLorebook(uuid);
1027
+ }
1028
+ else {
1029
+ return response.sendStatus(404);
1030
+ }
1031
+ }
1032
+
1033
+ if (!result) {
1034
+ throw new Error('Failed to download content');
1035
+ }
1036
+
1037
+ if (result.fileType) response.set('Content-Type', result.fileType);
1038
+ response.set('Content-Disposition', `attachment; filename="${result.fileName}"`);
1039
+ response.set('X-Custom-Content-Type', uuidType);
1040
+ return response.send(result.buffer);
1041
+ } catch (error) {
1042
+ console.error('Importing custom content failed', error);
1043
+ return response.sendStatus(500);
1044
+ }
1045
+ });