Nocigar commited on
Commit
1307964
1 Parent(s): b82d373

Upload 72 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. src/additional-headers.js +242 -0
  2. src/character-card-parser.js +100 -0
  3. src/constants.js +442 -0
  4. src/endpoints/anthropic.js +68 -0
  5. src/endpoints/assets.js +370 -0
  6. src/endpoints/avatars.js +62 -0
  7. src/endpoints/azure.js +92 -0
  8. src/endpoints/backends/chat-completions.js +1106 -0
  9. src/endpoints/backends/kobold.js +241 -0
  10. src/endpoints/backends/scale-alt.js +101 -0
  11. src/endpoints/backends/text-completions.js +641 -0
  12. src/endpoints/backgrounds.js +76 -0
  13. src/endpoints/caption.js +32 -0
  14. src/endpoints/characters.js +1230 -0
  15. src/endpoints/chats.js +461 -0
  16. src/endpoints/classify.js +58 -0
  17. src/endpoints/content-manager.js +725 -0
  18. src/endpoints/extensions.js +244 -0
  19. src/endpoints/files.js +101 -0
  20. src/endpoints/google.js +71 -0
  21. src/endpoints/groups.js +135 -0
  22. src/endpoints/horde.js +382 -0
  23. src/endpoints/images.js +93 -0
  24. src/endpoints/moving-ui.js +21 -0
  25. src/endpoints/novelai.js +381 -0
  26. src/endpoints/openai.js +329 -0
  27. src/endpoints/presets.js +129 -0
  28. src/endpoints/quick-replies.js +35 -0
  29. src/endpoints/search.js +246 -0
  30. src/endpoints/secrets.js +230 -0
  31. src/endpoints/settings.js +360 -0
  32. src/endpoints/speech.js +82 -0
  33. src/endpoints/sprites.js +266 -0
  34. src/endpoints/stable-diffusion.js +1042 -0
  35. src/endpoints/stats.js +474 -0
  36. src/endpoints/themes.js +40 -0
  37. src/endpoints/thumbnails.js +235 -0
  38. src/endpoints/tokenizers.js +885 -0
  39. src/endpoints/translate.js +398 -0
  40. src/endpoints/users-admin.js +255 -0
  41. src/endpoints/users-private.js +257 -0
  42. src/endpoints/users-public.js +199 -0
  43. src/endpoints/vectors.js +491 -0
  44. src/endpoints/worldinfo.js +126 -0
  45. src/express-common.js +28 -0
  46. src/middleware/basicAuth.js +37 -0
  47. src/middleware/multerMonkeyPatch.js +30 -0
  48. src/middleware/whitelist.js +86 -0
  49. src/plugin-loader.js +223 -0
  50. src/polyfill.js +10 -0
src/additional-headers.js ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { TEXTGEN_TYPES, OPENROUTER_HEADERS, FEATHERLESS_HEADERS } = require('./constants');
2
+ const { SECRET_KEYS, readSecret } = require('./endpoints/secrets');
3
+ const { getConfigValue } = require('./util');
4
+
5
+ /**
6
+ * Gets the headers for the Mancer API.
7
+ * @param {import('./users').UserDirectoryList} directories User directories
8
+ * @returns {object} Headers for the request
9
+ */
10
+ function getMancerHeaders(directories) {
11
+ const apiKey = readSecret(directories, SECRET_KEYS.MANCER);
12
+
13
+ return apiKey ? ({
14
+ 'X-API-KEY': apiKey,
15
+ 'Authorization': `Bearer ${apiKey}`,
16
+ }) : {};
17
+ }
18
+
19
+ /**
20
+ * Gets the headers for the TogetherAI API.
21
+ * @param {import('./users').UserDirectoryList} directories User directories
22
+ * @returns {object} Headers for the request
23
+ */
24
+ function getTogetherAIHeaders(directories) {
25
+ const apiKey = readSecret(directories, SECRET_KEYS.TOGETHERAI);
26
+
27
+ return apiKey ? ({
28
+ 'Authorization': `Bearer ${apiKey}`,
29
+ }) : {};
30
+ }
31
+
32
+ /**
33
+ * Gets the headers for the InfermaticAI API.
34
+ * @param {import('./users').UserDirectoryList} directories User directories
35
+ * @returns {object} Headers for the request
36
+ */
37
+ function getInfermaticAIHeaders(directories) {
38
+ const apiKey = readSecret(directories, SECRET_KEYS.INFERMATICAI);
39
+
40
+ return apiKey ? ({
41
+ 'Authorization': `Bearer ${apiKey}`,
42
+ }) : {};
43
+ }
44
+
45
+ /**
46
+ * Gets the headers for the DreamGen API.
47
+ * @param {import('./users').UserDirectoryList} directories User directories
48
+ * @returns {object} Headers for the request
49
+ */
50
+ function getDreamGenHeaders(directories) {
51
+ const apiKey = readSecret(directories, SECRET_KEYS.DREAMGEN);
52
+
53
+ return apiKey ? ({
54
+ 'Authorization': `Bearer ${apiKey}`,
55
+ }) : {};
56
+ }
57
+
58
+ /**
59
+ * Gets the headers for the OpenRouter API.
60
+ * @param {import('./users').UserDirectoryList} directories User directories
61
+ * @returns {object} Headers for the request
62
+ */
63
+ function getOpenRouterHeaders(directories) {
64
+ const apiKey = readSecret(directories, SECRET_KEYS.OPENROUTER);
65
+ const baseHeaders = { ...OPENROUTER_HEADERS };
66
+
67
+ return apiKey ? Object.assign(baseHeaders, { 'Authorization': `Bearer ${apiKey}` }) : baseHeaders;
68
+ }
69
+
70
+ /**
71
+ * Gets the headers for the vLLM API.
72
+ * @param {import('./users').UserDirectoryList} directories User directories
73
+ * @returns {object} Headers for the request
74
+ */
75
+ function getVllmHeaders(directories) {
76
+ const apiKey = readSecret(directories, SECRET_KEYS.VLLM);
77
+
78
+ return apiKey ? ({
79
+ 'Authorization': `Bearer ${apiKey}`,
80
+ }) : {};
81
+ }
82
+
83
+ /**
84
+ * Gets the headers for the Aphrodite API.
85
+ * @param {import('./users').UserDirectoryList} directories User directories
86
+ * @returns {object} Headers for the request
87
+ */
88
+ function getAphroditeHeaders(directories) {
89
+ const apiKey = readSecret(directories, SECRET_KEYS.APHRODITE);
90
+
91
+ return apiKey ? ({
92
+ 'X-API-KEY': apiKey,
93
+ 'Authorization': `Bearer ${apiKey}`,
94
+ }) : {};
95
+ }
96
+
97
+ /**
98
+ * Gets the headers for the Tabby API.
99
+ * @param {import('./users').UserDirectoryList} directories User directories
100
+ * @returns {object} Headers for the request
101
+ */
102
+ function getTabbyHeaders(directories) {
103
+ const apiKey = readSecret(directories, SECRET_KEYS.TABBY);
104
+
105
+ return apiKey ? ({
106
+ 'x-api-key': apiKey,
107
+ 'Authorization': `Bearer ${apiKey}`,
108
+ }) : {};
109
+ }
110
+
111
+ /**
112
+ * Gets the headers for the LlamaCPP API.
113
+ * @param {import('./users').UserDirectoryList} directories User directories
114
+ * @returns {object} Headers for the request
115
+ */
116
+ function getLlamaCppHeaders(directories) {
117
+ const apiKey = readSecret(directories, SECRET_KEYS.LLAMACPP);
118
+
119
+ return apiKey ? ({
120
+ 'Authorization': `Bearer ${apiKey}`,
121
+ }) : {};
122
+ }
123
+
124
+ /**
125
+ * Gets the headers for the Ooba API.
126
+ * @param {import('./users').UserDirectoryList} directories
127
+ * @returns {object} Headers for the request
128
+ */
129
+ function getOobaHeaders(directories) {
130
+ const apiKey = readSecret(directories, SECRET_KEYS.OOBA);
131
+
132
+ return apiKey ? ({
133
+ 'Authorization': `Bearer ${apiKey}`,
134
+ }) : {};
135
+ }
136
+
137
+ /**
138
+ * Gets the headers for the KoboldCpp API.
139
+ * @param {import('./users').UserDirectoryList} directories
140
+ * @returns {object} Headers for the request
141
+ */
142
+ function getKoboldCppHeaders(directories) {
143
+ const apiKey = readSecret(directories, SECRET_KEYS.KOBOLDCPP);
144
+
145
+ return apiKey ? ({
146
+ 'Authorization': `Bearer ${apiKey}`,
147
+ }) : {};
148
+ }
149
+
150
+ /**
151
+ * Gets the headers for the Featherless API.
152
+ * @param {import('./users').UserDirectoryList} directories
153
+ * @returns {object} Headers for the request
154
+ */
155
+ function getFeatherlessHeaders(directories) {
156
+ const apiKey = readSecret(directories, SECRET_KEYS.FEATHERLESS);
157
+ const baseHeaders = { ...FEATHERLESS_HEADERS };
158
+
159
+ return apiKey ? Object.assign(baseHeaders, { 'Authorization': `Bearer ${apiKey}` }) : baseHeaders;
160
+ }
161
+
162
+ /**
163
+ * Gets the headers for the HuggingFace API.
164
+ * @param {import('./users').UserDirectoryList} directories
165
+ * @returns {object} Headers for the request
166
+ */
167
+ function getHuggingFaceHeaders(directories) {
168
+ const apiKey = readSecret(directories, SECRET_KEYS.HUGGINGFACE);
169
+
170
+ return apiKey ? ({
171
+ 'Authorization': `Bearer ${apiKey}`,
172
+ }) : {};
173
+ }
174
+
175
+ function getOverrideHeaders(urlHost) {
176
+ const requestOverrides = getConfigValue('requestOverrides', []);
177
+ const overrideHeaders = requestOverrides?.find((e) => e.hosts?.includes(urlHost))?.headers;
178
+ if (overrideHeaders && urlHost) {
179
+ return overrideHeaders;
180
+ } else {
181
+ return {};
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Sets additional headers for the request.
187
+ * @param {import('express').Request} request Original request body
188
+ * @param {object} args New request arguments
189
+ * @param {string|null} server API server for new request
190
+ */
191
+ function setAdditionalHeaders(request, args, server) {
192
+ setAdditionalHeadersByType(args.headers, request.body.api_type, server, request.user.directories);
193
+ }
194
+
195
+ /**
196
+ *
197
+ * @param {object} requestHeaders Request headers
198
+ * @param {string} type API type
199
+ * @param {string|null} server API server for new request
200
+ * @param {import('./users').UserDirectoryList} directories User directories
201
+ */
202
+ function setAdditionalHeadersByType(requestHeaders, type, server, directories) {
203
+ const headerGetters = {
204
+ [TEXTGEN_TYPES.MANCER]: getMancerHeaders,
205
+ [TEXTGEN_TYPES.VLLM]: getVllmHeaders,
206
+ [TEXTGEN_TYPES.APHRODITE]: getAphroditeHeaders,
207
+ [TEXTGEN_TYPES.TABBY]: getTabbyHeaders,
208
+ [TEXTGEN_TYPES.TOGETHERAI]: getTogetherAIHeaders,
209
+ [TEXTGEN_TYPES.OOBA]: getOobaHeaders,
210
+ [TEXTGEN_TYPES.INFERMATICAI]: getInfermaticAIHeaders,
211
+ [TEXTGEN_TYPES.DREAMGEN]: getDreamGenHeaders,
212
+ [TEXTGEN_TYPES.OPENROUTER]: getOpenRouterHeaders,
213
+ [TEXTGEN_TYPES.KOBOLDCPP]: getKoboldCppHeaders,
214
+ [TEXTGEN_TYPES.LLAMACPP]: getLlamaCppHeaders,
215
+ [TEXTGEN_TYPES.FEATHERLESS]: getFeatherlessHeaders,
216
+ [TEXTGEN_TYPES.HUGGINGFACE]: getHuggingFaceHeaders,
217
+ };
218
+
219
+ const getHeaders = headerGetters[type];
220
+ const headers = getHeaders ? getHeaders(directories) : {};
221
+
222
+ if (typeof server === 'string' && server.length > 0) {
223
+ try {
224
+ const url = new URL(server);
225
+ const overrideHeaders = getOverrideHeaders(url.host);
226
+
227
+ if (overrideHeaders && Object.keys(overrideHeaders).length > 0) {
228
+ Object.assign(headers, overrideHeaders);
229
+ }
230
+ } catch {
231
+ // Do nothing
232
+ }
233
+ }
234
+
235
+ Object.assign(requestHeaders, headers);
236
+ }
237
+
238
+ module.exports = {
239
+ getOverrideHeaders,
240
+ setAdditionalHeaders,
241
+ setAdditionalHeadersByType,
242
+ };
src/character-card-parser.js ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require('fs');
2
+
3
+ const encode = require('png-chunks-encode');
4
+ const extract = require('png-chunks-extract');
5
+ const PNGtext = require('png-chunk-text');
6
+
7
+ /**
8
+ * Writes Character metadata to a PNG image buffer.
9
+ * Writes only 'chara', 'ccv3' is not supported and removed not to create a mismatch.
10
+ * @param {Buffer} image PNG image buffer
11
+ * @param {string} data Character data to write
12
+ * @returns {Buffer} PNG image buffer with metadata
13
+ */
14
+ const write = (image, data) => {
15
+ const chunks = extract(image);
16
+ const tEXtChunks = chunks.filter(chunk => chunk.name === 'tEXt');
17
+
18
+ // Remove existing tEXt chunks
19
+ for (const tEXtChunk of tEXtChunks) {
20
+ const data = PNGtext.decode(tEXtChunk.data);
21
+ if (data.keyword.toLowerCase() === 'chara' || data.keyword.toLowerCase() === 'ccv3') {
22
+ chunks.splice(chunks.indexOf(tEXtChunk), 1);
23
+ }
24
+ }
25
+
26
+ // Add new v2 chunk before the IEND chunk
27
+ const base64EncodedData = Buffer.from(data, 'utf8').toString('base64');
28
+ chunks.splice(-1, 0, PNGtext.encode('chara', base64EncodedData));
29
+
30
+ // Try adding v3 chunk before the IEND chunk
31
+ try {
32
+ //change v2 format to v3
33
+ const v3Data = JSON.parse(data);
34
+ v3Data.spec = 'chara_card_v3';
35
+ v3Data.spec_version = '3.0';
36
+
37
+ const base64EncodedData = Buffer.from(JSON.stringify(v3Data), 'utf8').toString('base64');
38
+ chunks.splice(-1, 0, PNGtext.encode('ccv3', base64EncodedData));
39
+ } catch (error) { }
40
+
41
+ const newBuffer = Buffer.from(encode(chunks));
42
+ return newBuffer;
43
+ };
44
+
45
+ /**
46
+ * Reads Character metadata from a PNG image buffer.
47
+ * Supports both V2 (chara) and V3 (ccv3). V3 (ccv3) takes precedence.
48
+ * @param {Buffer} image PNG image buffer
49
+ * @returns {string} Character data
50
+ */
51
+ const read = (image) => {
52
+ const chunks = extract(image);
53
+
54
+ const textChunks = chunks.filter((chunk) => chunk.name === 'tEXt').map((chunk) => PNGtext.decode(chunk.data));
55
+
56
+ if (textChunks.length === 0) {
57
+ console.error('PNG metadata does not contain any text chunks.');
58
+ throw new Error('No PNG metadata.');
59
+ }
60
+
61
+ const ccv3Index = textChunks.findIndex((chunk) => chunk.keyword.toLowerCase() === 'ccv3');
62
+
63
+ if (ccv3Index > -1) {
64
+ return Buffer.from(textChunks[ccv3Index].text, 'base64').toString('utf8');
65
+ }
66
+
67
+ const charaIndex = textChunks.findIndex((chunk) => chunk.keyword.toLowerCase() === 'chara');
68
+
69
+ if (charaIndex > -1) {
70
+ return Buffer.from(textChunks[charaIndex].text, 'base64').toString('utf8');
71
+ }
72
+
73
+ console.error('PNG metadata does not contain any character data.');
74
+ throw new Error('No PNG metadata.');
75
+ };
76
+
77
+ /**
78
+ * Parses a card image and returns the character metadata.
79
+ * @param {string} cardUrl Path to the card image
80
+ * @param {string} format File format
81
+ * @returns {string} Character data
82
+ */
83
+ const parse = (cardUrl, format) => {
84
+ let fileFormat = format === undefined ? 'png' : format;
85
+
86
+ switch (fileFormat) {
87
+ case 'png': {
88
+ const buffer = fs.readFileSync(cardUrl);
89
+ return read(buffer);
90
+ }
91
+ }
92
+
93
+ throw new Error('Unsupported format');
94
+ };
95
+
96
+ module.exports = {
97
+ parse,
98
+ write,
99
+ read,
100
+ };
src/constants.js ADDED
@@ -0,0 +1,442 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const PUBLIC_DIRECTORIES = {
2
+ images: 'public/img/',
3
+ backups: 'backups/',
4
+ sounds: 'public/sounds',
5
+ extensions: 'public/scripts/extensions',
6
+ };
7
+
8
+ const DEFAULT_AVATAR = '/img/ai4.png';
9
+ const SETTINGS_FILE = 'settings.json';
10
+
11
+ /**
12
+ * @type {import('./users').UserDirectoryList}
13
+ * @readonly
14
+ * @enum {string}
15
+ */
16
+ const USER_DIRECTORY_TEMPLATE = Object.freeze({
17
+ root: '',
18
+ thumbnails: 'thumbnails',
19
+ thumbnailsBg: 'thumbnails/bg',
20
+ thumbnailsAvatar: 'thumbnails/avatar',
21
+ worlds: 'worlds',
22
+ user: 'user',
23
+ avatars: 'User Avatars',
24
+ userImages: 'user/images',
25
+ groups: 'groups',
26
+ groupChats: 'group chats',
27
+ chats: 'chats',
28
+ characters: 'characters',
29
+ backgrounds: 'backgrounds',
30
+ novelAI_Settings: 'NovelAI Settings',
31
+ koboldAI_Settings: 'KoboldAI Settings',
32
+ openAI_Settings: 'OpenAI Settings',
33
+ textGen_Settings: 'TextGen Settings',
34
+ themes: 'themes',
35
+ movingUI: 'movingUI',
36
+ extensions: 'extensions',
37
+ instruct: 'instruct',
38
+ context: 'context',
39
+ quickreplies: 'QuickReplies',
40
+ assets: 'assets',
41
+ comfyWorkflows: 'user/workflows',
42
+ files: 'user/files',
43
+ vectors: 'vectors',
44
+ backups: 'backups',
45
+ });
46
+
47
+ /**
48
+ * @type {import('./users').User}
49
+ * @readonly
50
+ */
51
+ const DEFAULT_USER = Object.freeze({
52
+ handle: 'default-user',
53
+ name: 'User',
54
+ created: Date.now(),
55
+ password: '',
56
+ admin: true,
57
+ enabled: true,
58
+ salt: '',
59
+ });
60
+
61
+ const UNSAFE_EXTENSIONS = [
62
+ '.php',
63
+ '.exe',
64
+ '.com',
65
+ '.dll',
66
+ '.pif',
67
+ '.application',
68
+ '.gadget',
69
+ '.msi',
70
+ '.jar',
71
+ '.cmd',
72
+ '.bat',
73
+ '.reg',
74
+ '.sh',
75
+ '.py',
76
+ '.js',
77
+ '.jse',
78
+ '.jsp',
79
+ '.pdf',
80
+ '.html',
81
+ '.htm',
82
+ '.hta',
83
+ '.vb',
84
+ '.vbs',
85
+ '.vbe',
86
+ '.cpl',
87
+ '.msc',
88
+ '.scr',
89
+ '.sql',
90
+ '.iso',
91
+ '.img',
92
+ '.dmg',
93
+ '.ps1',
94
+ '.ps1xml',
95
+ '.ps2',
96
+ '.ps2xml',
97
+ '.psc1',
98
+ '.psc2',
99
+ '.msh',
100
+ '.msh1',
101
+ '.msh2',
102
+ '.mshxml',
103
+ '.msh1xml',
104
+ '.msh2xml',
105
+ '.scf',
106
+ '.lnk',
107
+ '.inf',
108
+ '.reg',
109
+ '.doc',
110
+ '.docm',
111
+ '.docx',
112
+ '.dot',
113
+ '.dotm',
114
+ '.dotx',
115
+ '.xls',
116
+ '.xlsm',
117
+ '.xlsx',
118
+ '.xlt',
119
+ '.xltm',
120
+ '.xltx',
121
+ '.xlam',
122
+ '.ppt',
123
+ '.pptm',
124
+ '.pptx',
125
+ '.pot',
126
+ '.potm',
127
+ '.potx',
128
+ '.ppam',
129
+ '.ppsx',
130
+ '.ppsm',
131
+ '.pps',
132
+ '.ppam',
133
+ '.sldx',
134
+ '.sldm',
135
+ '.ws',
136
+ ];
137
+
138
+ const GEMINI_SAFETY = [
139
+ {
140
+ category: 'HARM_CATEGORY_HARASSMENT',
141
+ threshold: 'BLOCK_NONE',
142
+ },
143
+ {
144
+ category: 'HARM_CATEGORY_HATE_SPEECH',
145
+ threshold: 'BLOCK_NONE',
146
+ },
147
+ {
148
+ category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
149
+ threshold: 'BLOCK_NONE',
150
+ },
151
+ {
152
+ category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
153
+ threshold: 'BLOCK_NONE',
154
+ },
155
+ ];
156
+
157
+ const BISON_SAFETY = [
158
+ {
159
+ category: 'HARM_CATEGORY_DEROGATORY',
160
+ threshold: 'BLOCK_NONE',
161
+ },
162
+ {
163
+ category: 'HARM_CATEGORY_TOXICITY',
164
+ threshold: 'BLOCK_NONE',
165
+ },
166
+ {
167
+ category: 'HARM_CATEGORY_VIOLENCE',
168
+ threshold: 'BLOCK_NONE',
169
+ },
170
+ {
171
+ category: 'HARM_CATEGORY_SEXUAL',
172
+ threshold: 'BLOCK_NONE',
173
+ },
174
+ {
175
+ category: 'HARM_CATEGORY_MEDICAL',
176
+ threshold: 'BLOCK_NONE',
177
+ },
178
+ {
179
+ category: 'HARM_CATEGORY_DANGEROUS',
180
+ threshold: 'BLOCK_NONE',
181
+ },
182
+ ];
183
+
184
+ const CHAT_COMPLETION_SOURCES = {
185
+ OPENAI: 'openai',
186
+ WINDOWAI: 'windowai',
187
+ CLAUDE: 'claude',
188
+ SCALE: 'scale',
189
+ OPENROUTER: 'openrouter',
190
+ AI21: 'ai21',
191
+ MAKERSUITE: 'makersuite',
192
+ MISTRALAI: 'mistralai',
193
+ CUSTOM: 'custom',
194
+ COHERE: 'cohere',
195
+ PERPLEXITY: 'perplexity',
196
+ GROQ: 'groq',
197
+ ZEROONEAI: '01ai',
198
+ BLOCKENTROPY: 'blockentropy',
199
+ };
200
+
201
+ /**
202
+ * Path to multer file uploads under the data root.
203
+ */
204
+ const UPLOADS_DIRECTORY = '_uploads';
205
+
206
+ // TODO: this is copied from the client code; there should be a way to de-duplicate it eventually
207
+ const TEXTGEN_TYPES = {
208
+ OOBA: 'ooba',
209
+ MANCER: 'mancer',
210
+ VLLM: 'vllm',
211
+ APHRODITE: 'aphrodite',
212
+ TABBY: 'tabby',
213
+ KOBOLDCPP: 'koboldcpp',
214
+ TOGETHERAI: 'togetherai',
215
+ LLAMACPP: 'llamacpp',
216
+ OLLAMA: 'ollama',
217
+ INFERMATICAI: 'infermaticai',
218
+ DREAMGEN: 'dreamgen',
219
+ OPENROUTER: 'openrouter',
220
+ FEATHERLESS: 'featherless',
221
+ HUGGINGFACE: 'huggingface',
222
+ };
223
+
224
+ const INFERMATICAI_KEYS = [
225
+ 'model',
226
+ 'prompt',
227
+ 'max_tokens',
228
+ 'temperature',
229
+ 'top_p',
230
+ 'top_k',
231
+ 'repetition_penalty',
232
+ 'stream',
233
+ 'stop',
234
+ 'presence_penalty',
235
+ 'frequency_penalty',
236
+ 'min_p',
237
+ 'seed',
238
+ 'ignore_eos',
239
+ 'n',
240
+ 'best_of',
241
+ 'min_tokens',
242
+ 'spaces_between_special_tokens',
243
+ 'skip_special_tokens',
244
+ 'logprobs',
245
+ ];
246
+
247
+ const FEATHERLESS_KEYS = [
248
+ 'model',
249
+ 'prompt',
250
+ 'best_of',
251
+ 'echo',
252
+ 'frequency_penalty',
253
+ 'logit_bias',
254
+ 'logprobs',
255
+ 'max_tokens',
256
+ 'n',
257
+ 'presence_penalty',
258
+ 'seed',
259
+ 'stop',
260
+ 'stream',
261
+ 'suffix',
262
+ 'temperature',
263
+ 'top_p',
264
+ 'user',
265
+
266
+ 'use_beam_search',
267
+ 'top_k',
268
+ 'min_p',
269
+ 'repetition_penalty',
270
+ 'length_penalty',
271
+ 'early_stopping',
272
+ 'stop_token_ids',
273
+ 'ignore_eos',
274
+ 'min_tokens',
275
+ 'skip_special_tokens',
276
+ 'spaces_between_special_tokens',
277
+ 'truncate_prompt_tokens',
278
+
279
+ 'include_stop_str_in_output',
280
+ 'response_format',
281
+ 'guided_json',
282
+ 'guided_regex',
283
+ 'guided_choice',
284
+ 'guided_grammar',
285
+ 'guided_decoding_backend',
286
+ 'guided_whitespace_pattern',
287
+ ];
288
+
289
+
290
+ // https://dreamgen.com/docs/api#openai-text
291
+ const DREAMGEN_KEYS = [
292
+ 'model',
293
+ 'prompt',
294
+ 'max_tokens',
295
+ 'temperature',
296
+ 'top_p',
297
+ 'top_k',
298
+ 'min_p',
299
+ 'repetition_penalty',
300
+ 'frequency_penalty',
301
+ 'presence_penalty',
302
+ 'stop',
303
+ 'stream',
304
+ 'minimum_message_content_tokens',
305
+ ];
306
+
307
+ // https://docs.together.ai/reference/completions
308
+ const TOGETHERAI_KEYS = [
309
+ 'model',
310
+ 'prompt',
311
+ 'max_tokens',
312
+ 'temperature',
313
+ 'top_p',
314
+ 'top_k',
315
+ 'repetition_penalty',
316
+ 'min_p',
317
+ 'presence_penalty',
318
+ 'frequency_penalty',
319
+ 'stream',
320
+ 'stop',
321
+ ];
322
+
323
+ // https://github.com/jmorganca/ollama/blob/main/docs/api.md#request-with-options
324
+ const OLLAMA_KEYS = [
325
+ 'num_predict',
326
+ 'num_ctx',
327
+ 'stop',
328
+ 'temperature',
329
+ 'repeat_penalty',
330
+ 'presence_penalty',
331
+ 'frequency_penalty',
332
+ 'top_k',
333
+ 'top_p',
334
+ 'tfs_z',
335
+ 'typical_p',
336
+ 'seed',
337
+ 'repeat_last_n',
338
+ 'mirostat',
339
+ 'mirostat_tau',
340
+ 'mirostat_eta',
341
+ 'min_p',
342
+ ];
343
+
344
+ const AVATAR_WIDTH = 512;
345
+ const AVATAR_HEIGHT = 768;
346
+
347
+ const OPENROUTER_HEADERS = {
348
+ 'HTTP-Referer': 'https://sillytavern.app',
349
+ 'X-Title': 'SillyTavern',
350
+ };
351
+
352
+ const FEATHERLESS_HEADERS = {
353
+ 'HTTP-Referer': 'https://sillytavern.app',
354
+ 'X-Title': 'SillyTavern',
355
+ };
356
+
357
+ const OPENROUTER_KEYS = [
358
+ 'max_tokens',
359
+ 'temperature',
360
+ 'top_k',
361
+ 'top_p',
362
+ 'presence_penalty',
363
+ 'frequency_penalty',
364
+ 'repetition_penalty',
365
+ 'min_p',
366
+ 'top_a',
367
+ 'seed',
368
+ 'logit_bias',
369
+ 'model',
370
+ 'stream',
371
+ 'prompt',
372
+ 'stop',
373
+ 'provider',
374
+ ];
375
+
376
+ // https://github.com/vllm-project/vllm/blob/0f8a91401c89ac0a8018def3756829611b57727f/vllm/entrypoints/openai/protocol.py#L220
377
+ const VLLM_KEYS = [
378
+ 'model',
379
+ 'prompt',
380
+ 'best_of',
381
+ 'echo',
382
+ 'frequency_penalty',
383
+ 'logit_bias',
384
+ 'logprobs',
385
+ 'max_tokens',
386
+ 'n',
387
+ 'presence_penalty',
388
+ 'seed',
389
+ 'stop',
390
+ 'stream',
391
+ 'suffix',
392
+ 'temperature',
393
+ 'top_p',
394
+ 'user',
395
+
396
+ 'use_beam_search',
397
+ 'top_k',
398
+ 'min_p',
399
+ 'repetition_penalty',
400
+ 'length_penalty',
401
+ 'early_stopping',
402
+ 'stop_token_ids',
403
+ 'ignore_eos',
404
+ 'min_tokens',
405
+ 'skip_special_tokens',
406
+ 'spaces_between_special_tokens',
407
+ 'truncate_prompt_tokens',
408
+
409
+ 'include_stop_str_in_output',
410
+ 'response_format',
411
+ 'guided_json',
412
+ 'guided_regex',
413
+ 'guided_choice',
414
+ 'guided_grammar',
415
+ 'guided_decoding_backend',
416
+ 'guided_whitespace_pattern',
417
+ ];
418
+
419
+ module.exports = {
420
+ DEFAULT_USER,
421
+ DEFAULT_AVATAR,
422
+ SETTINGS_FILE,
423
+ PUBLIC_DIRECTORIES,
424
+ USER_DIRECTORY_TEMPLATE,
425
+ UNSAFE_EXTENSIONS,
426
+ UPLOADS_DIRECTORY,
427
+ GEMINI_SAFETY,
428
+ BISON_SAFETY,
429
+ TEXTGEN_TYPES,
430
+ CHAT_COMPLETION_SOURCES,
431
+ AVATAR_WIDTH,
432
+ AVATAR_HEIGHT,
433
+ TOGETHERAI_KEYS,
434
+ OLLAMA_KEYS,
435
+ INFERMATICAI_KEYS,
436
+ DREAMGEN_KEYS,
437
+ OPENROUTER_HEADERS,
438
+ OPENROUTER_KEYS,
439
+ VLLM_KEYS,
440
+ FEATHERLESS_KEYS,
441
+ FEATHERLESS_HEADERS,
442
+ };
src/endpoints/anthropic.js ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { readSecret, SECRET_KEYS } = require('./secrets');
2
+ const fetch = require('node-fetch').default;
3
+ const express = require('express');
4
+ const { jsonParser } = require('../express-common');
5
+
6
+ const router = express.Router();
7
+
8
+ router.post('/caption-image', jsonParser, async (request, response) => {
9
+ try {
10
+ const mimeType = request.body.image.split(';')[0].split(':')[1];
11
+ const base64Data = request.body.image.split(',')[1];
12
+ const baseUrl = request.body.reverse_proxy ? request.body.reverse_proxy : 'https://api.anthropic.com/v1';
13
+ const url = `${baseUrl}/messages`;
14
+ const body = {
15
+ model: request.body.model,
16
+ messages: [
17
+ {
18
+ 'role': 'user', 'content': [
19
+ {
20
+ 'type': 'image',
21
+ 'source': {
22
+ 'type': 'base64',
23
+ 'media_type': mimeType,
24
+ 'data': base64Data,
25
+ },
26
+ },
27
+ { 'type': 'text', 'text': request.body.prompt },
28
+ ],
29
+ },
30
+ ],
31
+ max_tokens: 4096,
32
+ };
33
+
34
+ console.log('Multimodal captioning request', body);
35
+
36
+ const result = await fetch(url, {
37
+ body: JSON.stringify(body),
38
+ method: 'POST',
39
+ headers: {
40
+ 'Content-Type': 'application/json',
41
+ 'anthropic-version': '2023-06-01',
42
+ 'x-api-key': request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.CLAUDE),
43
+ },
44
+ timeout: 0,
45
+ });
46
+
47
+ if (!result.ok) {
48
+ const text = await result.text();
49
+ console.log(`Claude API returned error: ${result.status} ${result.statusText}`, text);
50
+ return response.status(result.status).send({ error: true });
51
+ }
52
+
53
+ const generateResponseJson = await result.json();
54
+ const caption = generateResponseJson.content[0].text;
55
+ console.log('Claude response:', generateResponseJson);
56
+
57
+ if (!caption) {
58
+ return response.status(500).send('No caption found');
59
+ }
60
+
61
+ return response.json({ caption });
62
+ } catch (error) {
63
+ console.error(error);
64
+ response.status(500).send('Internal server error');
65
+ }
66
+ });
67
+
68
+ module.exports = { router };
src/endpoints/assets.js ADDED
@@ -0,0 +1,370 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const mime = require('mime-types');
4
+ const express = require('express');
5
+ const sanitize = require('sanitize-filename');
6
+ const fetch = require('node-fetch').default;
7
+ const { finished } = require('stream/promises');
8
+ const { UNSAFE_EXTENSIONS } = require('../constants');
9
+ const { jsonParser } = require('../express-common');
10
+ const { clientRelativePath } = require('../util');
11
+
12
+ const VALID_CATEGORIES = ['bgm', 'ambient', 'blip', 'live2d', 'vrm', 'character', 'temp'];
13
+
14
+ /**
15
+ * Validates the input filename for the asset.
16
+ * @param {string} inputFilename Input filename
17
+ * @returns {{error: boolean, message?: string}} Whether validation failed, and why if so
18
+ */
19
+ function validateAssetFileName(inputFilename) {
20
+ if (!/^[a-zA-Z0-9_\-.]+$/.test(inputFilename)) {
21
+ return {
22
+ error: true,
23
+ message: 'Illegal character in filename; only alphanumeric, \'_\', \'-\' are accepted.',
24
+ };
25
+ }
26
+
27
+ const inputExtension = path.extname(inputFilename).toLowerCase();
28
+ if (UNSAFE_EXTENSIONS.some(ext => ext === inputExtension)) {
29
+ return {
30
+ error: true,
31
+ message: 'Forbidden file extension.',
32
+ };
33
+ }
34
+
35
+ if (inputFilename.startsWith('.')) {
36
+ return {
37
+ error: true,
38
+ message: 'Filename cannot start with \'.\'',
39
+ };
40
+ }
41
+
42
+ if (sanitize(inputFilename) !== inputFilename) {
43
+ return {
44
+ error: true,
45
+ message: 'Reserved or long filename.',
46
+ };
47
+ }
48
+
49
+ return { error: false };
50
+ }
51
+
52
+ /**
53
+ * Recursive function to get files
54
+ * @param {string} dir - The directory to search for files
55
+ * @param {string[]} files - The array of files to return
56
+ * @returns {string[]} - The array of files
57
+ */
58
+ function getFiles(dir, files = []) {
59
+ if (!fs.existsSync(dir)) return files;
60
+
61
+ // Get an array of all files and directories in the passed directory using fs.readdirSync
62
+ const fileList = fs.readdirSync(dir, { withFileTypes: true });
63
+ // Create the full path of the file/directory by concatenating the passed directory and file/directory name
64
+ for (const file of fileList) {
65
+ const name = path.join(dir, file.name);
66
+ // Check if the current file/directory is a directory using fs.statSync
67
+ if (file.isDirectory()) {
68
+ // If it is a directory, recursively call the getFiles function with the directory path and the files array
69
+ getFiles(name, files);
70
+ } else {
71
+ // If it is a file, push the full path to the files array
72
+ files.push(name);
73
+ }
74
+ }
75
+ return files;
76
+ }
77
+
78
+ /**
79
+ * Ensure that the asset folders exist.
80
+ * @param {import('../users').UserDirectoryList} directories - The user's directories
81
+ */
82
+ function ensureFoldersExist(directories) {
83
+ const folderPath = path.join(directories.assets);
84
+
85
+ for (const category of VALID_CATEGORIES) {
86
+ const assetCategoryPath = path.join(folderPath, category);
87
+ if (fs.existsSync(assetCategoryPath) && !fs.statSync(assetCategoryPath).isDirectory()) {
88
+ fs.unlinkSync(assetCategoryPath);
89
+ }
90
+ if (!fs.existsSync(assetCategoryPath)) {
91
+ fs.mkdirSync(assetCategoryPath, { recursive: true });
92
+ }
93
+ }
94
+ }
95
+
96
+ const router = express.Router();
97
+
98
+ /**
99
+ * HTTP POST handler function to retrieve name of all files of a given folder path.
100
+ *
101
+ * @param {Object} request - HTTP Request object. Require folder path in query
102
+ * @param {Object} response - HTTP Response object will contain a list of file path.
103
+ *
104
+ * @returns {void}
105
+ */
106
+ router.post('/get', jsonParser, async (request, response) => {
107
+ const folderPath = path.join(request.user.directories.assets);
108
+ let output = {};
109
+
110
+ try {
111
+ if (fs.existsSync(folderPath) && fs.statSync(folderPath).isDirectory()) {
112
+
113
+ ensureFoldersExist(request.user.directories);
114
+
115
+ const folders = fs.readdirSync(folderPath, { withFileTypes: true })
116
+ .filter(file => file.isDirectory());
117
+
118
+ for (const { name: folder } of folders) {
119
+ if (folder == 'temp')
120
+ continue;
121
+
122
+ // Live2d assets
123
+ if (folder == 'live2d') {
124
+ output[folder] = [];
125
+ const live2d_folder = path.normalize(path.join(folderPath, folder));
126
+ const files = getFiles(live2d_folder);
127
+ //console.debug("FILE FOUND:",files)
128
+ for (let file of files) {
129
+ if (file.includes('model') && file.endsWith('.json')) {
130
+ //console.debug("Asset live2d model found:",file)
131
+ output[folder].push(clientRelativePath(request.user.directories.root, file));
132
+ }
133
+ }
134
+ continue;
135
+ }
136
+
137
+ // VRM assets
138
+ if (folder == 'vrm') {
139
+ output[folder] = { 'model': [], 'animation': [] };
140
+ // Extract models
141
+ const vrm_model_folder = path.normalize(path.join(folderPath, 'vrm', 'model'));
142
+ let files = getFiles(vrm_model_folder);
143
+ //console.debug("FILE FOUND:",files)
144
+ for (let file of files) {
145
+ if (!file.endsWith('.placeholder')) {
146
+ //console.debug("Asset VRM model found:",file)
147
+ output['vrm']['model'].push(clientRelativePath(request.user.directories.root, file));
148
+ }
149
+ }
150
+
151
+ // Extract models
152
+ const vrm_animation_folder = path.normalize(path.join(folderPath, 'vrm', 'animation'));
153
+ files = getFiles(vrm_animation_folder);
154
+ //console.debug("FILE FOUND:",files)
155
+ for (let file of files) {
156
+ if (!file.endsWith('.placeholder')) {
157
+ //console.debug("Asset VRM animation found:",file)
158
+ output['vrm']['animation'].push(clientRelativePath(request.user.directories.root, file));
159
+ }
160
+ }
161
+ continue;
162
+ }
163
+
164
+ // Other assets (bgm/ambient/blip)
165
+ const files = fs.readdirSync(path.join(folderPath, folder))
166
+ .filter(filename => {
167
+ return filename != '.placeholder';
168
+ });
169
+ output[folder] = [];
170
+ for (const file of files) {
171
+ output[folder].push(`assets/${folder}/${file}`);
172
+ }
173
+ }
174
+ }
175
+ }
176
+ catch (err) {
177
+ console.log(err);
178
+ }
179
+ return response.send(output);
180
+ });
181
+
182
+ /**
183
+ * HTTP POST handler function to download the requested asset.
184
+ *
185
+ * @param {Object} request - HTTP Request object, expects a url, a category and a filename.
186
+ * @param {Object} response - HTTP Response only gives status.
187
+ *
188
+ * @returns {void}
189
+ */
190
+ router.post('/download', jsonParser, async (request, response) => {
191
+ const url = request.body.url;
192
+ const inputCategory = request.body.category;
193
+
194
+ // Check category
195
+ let category = null;
196
+ for (let i of VALID_CATEGORIES)
197
+ if (i == inputCategory)
198
+ category = i;
199
+
200
+ if (category === null) {
201
+ console.debug('Bad request: unsupported asset category.');
202
+ return response.sendStatus(400);
203
+ }
204
+
205
+ // Validate filename
206
+ ensureFoldersExist(request.user.directories);
207
+ const validation = validateAssetFileName(request.body.filename);
208
+ if (validation.error)
209
+ return response.status(400).send(validation.message);
210
+
211
+ const temp_path = path.join(request.user.directories.assets, 'temp', request.body.filename);
212
+ const file_path = path.join(request.user.directories.assets, category, request.body.filename);
213
+ console.debug('Request received to download', url, 'to', file_path);
214
+
215
+ try {
216
+ // Download to temp
217
+ const res = await fetch(url);
218
+ if (!res.ok || res.body === null) {
219
+ throw new Error(`Unexpected response ${res.statusText}`);
220
+ }
221
+ const destination = path.resolve(temp_path);
222
+ // Delete if previous download failed
223
+ if (fs.existsSync(temp_path)) {
224
+ fs.unlink(temp_path, (err) => {
225
+ if (err) throw err;
226
+ });
227
+ }
228
+ const fileStream = fs.createWriteStream(destination, { flags: 'wx' });
229
+ // @ts-ignore
230
+ await finished(res.body.pipe(fileStream));
231
+
232
+ if (category === 'character') {
233
+ const fileContent = fs.readFileSync(temp_path);
234
+ const contentType = mime.lookup(temp_path) || 'application/octet-stream';
235
+ response.setHeader('Content-Type', contentType);
236
+ response.send(fileContent);
237
+ fs.rmSync(temp_path);
238
+ return;
239
+ }
240
+
241
+ // Move into asset place
242
+ console.debug('Download finished, moving file from', temp_path, 'to', file_path);
243
+ fs.copyFileSync(temp_path, file_path);
244
+ fs.rmSync(temp_path);
245
+ response.sendStatus(200);
246
+ }
247
+ catch (error) {
248
+ console.log(error);
249
+ response.sendStatus(500);
250
+ }
251
+ });
252
+
253
+ /**
254
+ * HTTP POST handler function to delete the requested asset.
255
+ *
256
+ * @param {Object} request - HTTP Request object, expects a category and a filename
257
+ * @param {Object} response - HTTP Response only gives stats.
258
+ *
259
+ * @returns {void}
260
+ */
261
+ router.post('/delete', jsonParser, async (request, response) => {
262
+ const inputCategory = request.body.category;
263
+
264
+ // Check category
265
+ let category = null;
266
+ for (let i of VALID_CATEGORIES)
267
+ if (i == inputCategory)
268
+ category = i;
269
+
270
+ if (category === null) {
271
+ console.debug('Bad request: unsupported asset category.');
272
+ return response.sendStatus(400);
273
+ }
274
+
275
+ // Validate filename
276
+ const validation = validateAssetFileName(request.body.filename);
277
+ if (validation.error)
278
+ return response.status(400).send(validation.message);
279
+
280
+ const file_path = path.join(request.user.directories.assets, category, request.body.filename);
281
+ console.debug('Request received to delete', category, file_path);
282
+
283
+ try {
284
+ // Delete if previous download failed
285
+ if (fs.existsSync(file_path)) {
286
+ fs.unlink(file_path, (err) => {
287
+ if (err) throw err;
288
+ });
289
+ console.debug('Asset deleted.');
290
+ }
291
+ else {
292
+ console.debug('Asset not found.');
293
+ response.sendStatus(400);
294
+ }
295
+ // Move into asset place
296
+ response.sendStatus(200);
297
+ }
298
+ catch (error) {
299
+ console.log(error);
300
+ response.sendStatus(500);
301
+ }
302
+ });
303
+
304
+ ///////////////////////////////
305
+ /**
306
+ * HTTP POST handler function to retrieve a character background music list.
307
+ *
308
+ * @param {Object} request - HTTP Request object, expects a character name in the query.
309
+ * @param {Object} response - HTTP Response object will contain a list of audio file path.
310
+ *
311
+ * @returns {void}
312
+ */
313
+ router.post('/character', jsonParser, async (request, response) => {
314
+ if (request.query.name === undefined) return response.sendStatus(400);
315
+ // For backwards compatibility, don't reject invalid character names, just sanitize them
316
+ const name = sanitize(request.query.name.toString());
317
+ const inputCategory = request.query.category;
318
+
319
+ // Check category
320
+ let category = null;
321
+ for (let i of VALID_CATEGORIES)
322
+ if (i == inputCategory)
323
+ category = i;
324
+
325
+ if (category === null) {
326
+ console.debug('Bad request: unsupported asset category.');
327
+ return response.sendStatus(400);
328
+ }
329
+
330
+ const folderPath = path.join(request.user.directories.characters, name, category);
331
+
332
+ let output = [];
333
+ try {
334
+ if (fs.existsSync(folderPath) && fs.statSync(folderPath).isDirectory()) {
335
+
336
+ // Live2d assets
337
+ if (category == 'live2d') {
338
+ const folders = fs.readdirSync(folderPath, { withFileTypes: true });
339
+ for (const folderInfo of folders) {
340
+ if (!folderInfo.isDirectory()) continue;
341
+
342
+ const modelFolder = folderInfo.name;
343
+ const live2dModelPath = path.join(folderPath, modelFolder);
344
+ for (let file of fs.readdirSync(live2dModelPath)) {
345
+ //console.debug("Character live2d model found:", file)
346
+ if (file.includes('model') && file.endsWith('.json'))
347
+ output.push(path.join('characters', name, category, modelFolder, file));
348
+ }
349
+ }
350
+ return response.send(output);
351
+ }
352
+
353
+ // Other assets
354
+ const files = fs.readdirSync(folderPath)
355
+ .filter(filename => {
356
+ return filename != '.placeholder';
357
+ });
358
+
359
+ for (let i of files)
360
+ output.push(`/characters/${name}/${category}/${i}`);
361
+ }
362
+ return response.send(output);
363
+ }
364
+ catch (err) {
365
+ console.log(err);
366
+ return response.sendStatus(500);
367
+ }
368
+ });
369
+
370
+ module.exports = { router, validateAssetFileName };
src/endpoints/avatars.js ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+ const sanitize = require('sanitize-filename');
5
+ const writeFileAtomicSync = require('write-file-atomic').sync;
6
+ const { jsonParser, urlencodedParser } = require('../express-common');
7
+ const { AVATAR_WIDTH, AVATAR_HEIGHT } = require('../constants');
8
+ const { getImages, tryParse } = require('../util');
9
+
10
+ // image processing related library imports
11
+ const jimp = require('jimp');
12
+
13
+ const router = express.Router();
14
+
15
+ router.post('/get', jsonParser, function (request, response) {
16
+ var images = getImages(request.user.directories.avatars);
17
+ response.send(JSON.stringify(images));
18
+ });
19
+
20
+ router.post('/delete', jsonParser, function (request, response) {
21
+ if (!request.body) return response.sendStatus(400);
22
+
23
+ if (request.body.avatar !== sanitize(request.body.avatar)) {
24
+ console.error('Malicious avatar name prevented');
25
+ return response.sendStatus(403);
26
+ }
27
+
28
+ const fileName = path.join(request.user.directories.avatars, sanitize(request.body.avatar));
29
+
30
+ if (fs.existsSync(fileName)) {
31
+ fs.rmSync(fileName);
32
+ return response.send({ result: 'ok' });
33
+ }
34
+
35
+ return response.sendStatus(404);
36
+ });
37
+
38
+ router.post('/upload', urlencodedParser, async (request, response) => {
39
+ if (!request.file) return response.sendStatus(400);
40
+
41
+ try {
42
+ const pathToUpload = path.join(request.file.destination, request.file.filename);
43
+ const crop = tryParse(request.query.crop);
44
+ let rawImg = await jimp.read(pathToUpload);
45
+
46
+ if (typeof crop == 'object' && [crop.x, crop.y, crop.width, crop.height].every(x => typeof x === 'number')) {
47
+ rawImg = rawImg.crop(crop.x, crop.y, crop.width, crop.height);
48
+ }
49
+
50
+ const image = await rawImg.cover(AVATAR_WIDTH, AVATAR_HEIGHT).getBufferAsync(jimp.MIME_PNG);
51
+
52
+ const filename = request.body.overwrite_name || `${Date.now()}.png`;
53
+ const pathToNewFile = path.join(request.user.directories.avatars, filename);
54
+ writeFileAtomicSync(pathToNewFile, image);
55
+ fs.rmSync(pathToUpload);
56
+ return response.send({ path: filename });
57
+ } catch (err) {
58
+ return response.status(400).send('Is not a valid image');
59
+ }
60
+ });
61
+
62
+ module.exports = { router };
src/endpoints/azure.js ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { readSecret, SECRET_KEYS } = require('./secrets');
2
+ const fetch = require('node-fetch').default;
3
+ const express = require('express');
4
+ const { jsonParser } = require('../express-common');
5
+
6
+ const router = express.Router();
7
+
8
+ router.post('/list', jsonParser, async (req, res) => {
9
+ try {
10
+ const key = readSecret(req.user.directories, SECRET_KEYS.AZURE_TTS);
11
+
12
+ if (!key) {
13
+ console.error('Azure TTS API Key not set');
14
+ return res.sendStatus(403);
15
+ }
16
+
17
+ const region = req.body.region;
18
+
19
+ if (!region) {
20
+ console.error('Azure TTS region not set');
21
+ return res.sendStatus(400);
22
+ }
23
+
24
+ const url = `https://${region}.tts.speech.microsoft.com/cognitiveservices/voices/list`;
25
+
26
+ const response = await fetch(url, {
27
+ method: 'GET',
28
+ headers: {
29
+ 'Ocp-Apim-Subscription-Key': key,
30
+ },
31
+ });
32
+
33
+ if (!response.ok) {
34
+ console.error('Azure Request failed', response.status, response.statusText);
35
+ return res.sendStatus(500);
36
+ }
37
+
38
+ const voices = await response.json();
39
+ return res.json(voices);
40
+ } catch (error) {
41
+ console.error('Azure Request failed', error);
42
+ return res.sendStatus(500);
43
+ }
44
+ });
45
+
46
+ router.post('/generate', jsonParser, async (req, res) => {
47
+ try {
48
+ const key = readSecret(req.user.directories, SECRET_KEYS.AZURE_TTS);
49
+
50
+ if (!key) {
51
+ console.error('Azure TTS API Key not set');
52
+ return res.sendStatus(403);
53
+ }
54
+
55
+ const { text, voice, region } = req.body;
56
+ if (!text || !voice || !region) {
57
+ console.error('Missing required parameters');
58
+ return res.sendStatus(400);
59
+ }
60
+
61
+ const url = `https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`;
62
+ const lang = String(voice).split('-').slice(0, 2).join('-');
63
+ const escapedText = String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
64
+ const ssml = `<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='${lang}'><voice xml:lang='${lang}' name='${voice}'>${escapedText}</voice></speak>`;
65
+
66
+ const response = await fetch(url, {
67
+ method: 'POST',
68
+ headers: {
69
+ 'Ocp-Apim-Subscription-Key': key,
70
+ 'Content-Type': 'application/ssml+xml',
71
+ 'X-Microsoft-OutputFormat': 'ogg-48khz-16bit-mono-opus',
72
+ },
73
+ body: ssml,
74
+ });
75
+
76
+ if (!response.ok) {
77
+ console.error('Azure Request failed', response.status, response.statusText);
78
+ return res.sendStatus(500);
79
+ }
80
+
81
+ const audio = await response.buffer();
82
+ res.set('Content-Type', 'audio/ogg');
83
+ return res.send(audio);
84
+ } catch (error) {
85
+ console.error('Azure Request failed', error);
86
+ return res.sendStatus(500);
87
+ }
88
+ });
89
+
90
+ module.exports = {
91
+ router,
92
+ };
src/endpoints/backends/chat-completions.js ADDED
@@ -0,0 +1,1106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const fetch = require('node-fetch').default;
3
+ const Readable = require('stream').Readable;
4
+
5
+ const { jsonParser } = require('../../express-common');
6
+ const { CHAT_COMPLETION_SOURCES, GEMINI_SAFETY, BISON_SAFETY, OPENROUTER_HEADERS } = require('../../constants');
7
+ const { forwardFetchResponse, getConfigValue, tryParse, uuidv4, mergeObjectWithYaml, excludeKeysByYaml, color } = require('../../util');
8
+ const { convertClaudeMessages, convertGooglePrompt, convertTextCompletionPrompt, convertCohereMessages, convertMistralMessages, convertCohereTools } = require('../../prompt-converters');
9
+
10
+ const { readSecret, SECRET_KEYS } = require('../secrets');
11
+ const { getTokenizerModel, getSentencepiceTokenizer, getTiktokenTokenizer, sentencepieceTokenizers, TEXT_COMPLETION_MODELS } = require('../tokenizers');
12
+
13
+ const API_OPENAI = 'https://api.openai.com/v1';
14
+ const API_CLAUDE = 'https://api.anthropic.com/v1';
15
+ const API_MISTRAL = 'https://api.mistral.ai/v1';
16
+ const API_COHERE = 'https://api.cohere.ai/v1';
17
+ const API_PERPLEXITY = 'https://api.perplexity.ai';
18
+ const API_GROQ = 'https://api.groq.com/openai/v1';
19
+ const API_MAKERSUITE = 'https://generativelanguage.googleapis.com';
20
+ const API_01AI = 'https://api.01.ai/v1';
21
+ const API_BLOCKENTROPY = 'https://api.blockentropy.ai/v1';
22
+
23
+ /**
24
+ * Applies a post-processing step to the generated messages.
25
+ * @param {object[]} messages Messages to post-process
26
+ * @param {string} type Prompt conversion type
27
+ * @param {string} charName Character name
28
+ * @param {string} userName User name
29
+ * @returns
30
+ */
31
+ function postProcessPrompt(messages, type, charName, userName) {
32
+ switch (type) {
33
+ case 'claude':
34
+ return convertClaudeMessages(messages, '', false, '', charName, userName).messages;
35
+ default:
36
+ return messages;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Ollama strikes back. Special boy #2's steaming routine.
42
+ * Wrap this abomination into proper SSE stream, again.
43
+ * @param {import('node-fetch').Response} jsonStream JSON stream
44
+ * @param {import('express').Request} request Express request
45
+ * @param {import('express').Response} response Express response
46
+ * @returns {Promise<any>} Nothing valuable
47
+ */
48
+ async function parseCohereStream(jsonStream, request, response) {
49
+ try {
50
+ let partialData = '';
51
+ jsonStream.body.on('data', (data) => {
52
+ const chunk = data.toString();
53
+ partialData += chunk;
54
+ while (true) {
55
+ let json;
56
+ try {
57
+ json = JSON.parse(partialData);
58
+ } catch (e) {
59
+ break;
60
+ }
61
+ if (json.message) {
62
+ const message = json.message || 'Unknown error';
63
+ const chunk = { error: { message: message } };
64
+ response.write(`data: ${JSON.stringify(chunk)}\n\n`);
65
+ partialData = '';
66
+ break;
67
+ } else if (json.event_type === 'text-generation') {
68
+ const text = json.text || '';
69
+ const chunk = { choices: [{ text }] };
70
+ response.write(`data: ${JSON.stringify(chunk)}\n\n`);
71
+ partialData = '';
72
+ } else {
73
+ partialData = '';
74
+ break;
75
+ }
76
+ }
77
+ });
78
+
79
+ request.socket.on('close', function () {
80
+ if (jsonStream.body instanceof Readable) jsonStream.body.destroy();
81
+ response.end();
82
+ });
83
+
84
+ jsonStream.body.on('end', () => {
85
+ console.log('Streaming request finished');
86
+ response.write('data: [DONE]\n\n');
87
+ response.end();
88
+ });
89
+ } catch (error) {
90
+ console.log('Error forwarding streaming response:', error);
91
+ if (!response.headersSent) {
92
+ return response.status(500).send({ error: true });
93
+ } else {
94
+ return response.end();
95
+ }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Sends a request to Claude API.
101
+ * @param {express.Request} request Express request
102
+ * @param {express.Response} response Express response
103
+ */
104
+ async function sendClaudeRequest(request, response) {
105
+ const apiUrl = new URL(request.body.reverse_proxy || API_CLAUDE).toString();
106
+ const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.CLAUDE);
107
+ const divider = '-'.repeat(process.stdout.columns);
108
+ const enableSystemPromptCache = getConfigValue('claude.enableSystemPromptCache', false);
109
+
110
+ if (!apiKey) {
111
+ console.log(color.red(`Claude API key is missing.\n${divider}`));
112
+ return response.status(400).send({ error: true });
113
+ }
114
+
115
+ try {
116
+ const controller = new AbortController();
117
+ request.socket.removeAllListeners('close');
118
+ request.socket.on('close', function () {
119
+ controller.abort();
120
+ });
121
+ const additionalHeaders = {};
122
+ const useSystemPrompt = (request.body.model.startsWith('claude-2') || request.body.model.startsWith('claude-3')) && request.body.claude_use_sysprompt;
123
+ const convertedPrompt = convertClaudeMessages(request.body.messages, request.body.assistant_prefill, useSystemPrompt, request.body.human_sysprompt_message, request.body.char_name, request.body.user_name);
124
+ // Add custom stop sequences
125
+ const stopSequences = [];
126
+ if (Array.isArray(request.body.stop)) {
127
+ stopSequences.push(...request.body.stop);
128
+ }
129
+
130
+ const requestBody = {
131
+ messages: convertedPrompt.messages,
132
+ model: request.body.model,
133
+ max_tokens: request.body.max_tokens,
134
+ stop_sequences: stopSequences,
135
+ temperature: request.body.temperature,
136
+ top_p: request.body.top_p,
137
+ top_k: request.body.top_k,
138
+ stream: request.body.stream,
139
+ };
140
+ if (useSystemPrompt) {
141
+ requestBody.system = enableSystemPromptCache
142
+ ? [{ type: 'text', text: convertedPrompt.systemPrompt, cache_control: { type: 'ephemeral' } }]
143
+ : convertedPrompt.systemPrompt;
144
+ }
145
+ if (Array.isArray(request.body.tools) && request.body.tools.length > 0) {
146
+ // Claude doesn't do prefills on function calls, and doesn't allow empty messages
147
+ if (convertedPrompt.messages.length && convertedPrompt.messages[convertedPrompt.messages.length - 1].role === 'assistant') {
148
+ convertedPrompt.messages.push({ role: 'user', content: '.' });
149
+ }
150
+ additionalHeaders['anthropic-beta'] = 'tools-2024-05-16';
151
+ requestBody.tool_choice = { type: request.body.tool_choice === 'required' ? 'any' : 'auto' };
152
+ requestBody.tools = request.body.tools
153
+ .filter(tool => tool.type === 'function')
154
+ .map(tool => tool.function)
155
+ .map(fn => ({ name: fn.name, description: fn.description, input_schema: fn.parameters }));
156
+ }
157
+ if (enableSystemPromptCache) {
158
+ additionalHeaders['anthropic-beta'] = 'prompt-caching-2024-07-31';
159
+ }
160
+ console.log('Claude request:', requestBody);
161
+
162
+ const generateResponse = await fetch(apiUrl + '/messages', {
163
+ method: 'POST',
164
+ signal: controller.signal,
165
+ body: JSON.stringify(requestBody),
166
+ headers: {
167
+ 'Content-Type': 'application/json',
168
+ 'anthropic-version': '2023-06-01',
169
+ 'x-api-key': apiKey,
170
+ ...additionalHeaders,
171
+ },
172
+ timeout: 0,
173
+ });
174
+
175
+ if (request.body.stream) {
176
+ // Pipe remote SSE stream to Express response
177
+ forwardFetchResponse(generateResponse, response);
178
+ } else {
179
+ if (!generateResponse.ok) {
180
+ console.log(color.red(`Claude API returned error: ${generateResponse.status} ${generateResponse.statusText}\n${await generateResponse.text()}\n${divider}`));
181
+ return response.status(generateResponse.status).send({ error: true });
182
+ }
183
+
184
+ const generateResponseJson = await generateResponse.json();
185
+ const responseText = generateResponseJson.content[0].text;
186
+ console.log('Claude response:', generateResponseJson);
187
+
188
+ // Wrap it back to OAI format + save the original content
189
+ const reply = { choices: [{ 'message': { 'content': responseText } }], content: generateResponseJson.content };
190
+ return response.send(reply);
191
+ }
192
+ } catch (error) {
193
+ console.log(color.red(`Error communicating with Claude: ${error}\n${divider}`));
194
+ if (!response.headersSent) {
195
+ return response.status(500).send({ error: true });
196
+ }
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Sends a request to Scale Spellbook API.
202
+ * @param {import("express").Request} request Express request
203
+ * @param {import("express").Response} response Express response
204
+ */
205
+ async function sendScaleRequest(request, response) {
206
+ const apiUrl = new URL(request.body.api_url_scale).toString();
207
+ const apiKey = readSecret(request.user.directories, SECRET_KEYS.SCALE);
208
+
209
+ if (!apiKey) {
210
+ console.log('Scale API key is missing.');
211
+ return response.status(400).send({ error: true });
212
+ }
213
+
214
+ const requestPrompt = convertTextCompletionPrompt(request.body.messages);
215
+ console.log('Scale request:', requestPrompt);
216
+
217
+ try {
218
+ const controller = new AbortController();
219
+ request.socket.removeAllListeners('close');
220
+ request.socket.on('close', function () {
221
+ controller.abort();
222
+ });
223
+
224
+ const generateResponse = await fetch(apiUrl, {
225
+ method: 'POST',
226
+ body: JSON.stringify({ input: { input: requestPrompt } }),
227
+ headers: {
228
+ 'Content-Type': 'application/json',
229
+ 'Authorization': `Basic ${apiKey}`,
230
+ },
231
+ timeout: 0,
232
+ });
233
+
234
+ if (!generateResponse.ok) {
235
+ console.log(`Scale API returned error: ${generateResponse.status} ${generateResponse.statusText} ${await generateResponse.text()}`);
236
+ return response.status(generateResponse.status).send({ error: true });
237
+ }
238
+
239
+ const generateResponseJson = await generateResponse.json();
240
+ console.log('Scale response:', generateResponseJson);
241
+
242
+ const reply = { choices: [{ 'message': { 'content': generateResponseJson.output } }] };
243
+ return response.send(reply);
244
+ } catch (error) {
245
+ console.log(error);
246
+ if (!response.headersSent) {
247
+ return response.status(500).send({ error: true });
248
+ }
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Sends a request to Google AI API.
254
+ * @param {express.Request} request Express request
255
+ * @param {express.Response} response Express response
256
+ */
257
+ async function sendMakerSuiteRequest(request, response) {
258
+ const apiUrl = new URL(request.body.reverse_proxy || API_MAKERSUITE);
259
+ const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE);
260
+
261
+ if (!request.body.reverse_proxy && !apiKey) {
262
+ console.log('Google AI Studio API key is missing.');
263
+ return response.status(400).send({ error: true });
264
+ }
265
+
266
+ const model = String(request.body.model);
267
+ const isGemini = model.includes('gemini');
268
+ const isText = model.includes('text');
269
+ const stream = Boolean(request.body.stream) && isGemini;
270
+
271
+ const generationConfig = {
272
+ stopSequences: request.body.stop,
273
+ candidateCount: 1,
274
+ maxOutputTokens: request.body.max_tokens,
275
+ temperature: request.body.temperature,
276
+ topP: request.body.top_p,
277
+ topK: request.body.top_k || undefined,
278
+ };
279
+
280
+ function getGeminiBody() {
281
+ const should_use_system_prompt = (model.includes('gemini-1.5-flash') || model.includes('gemini-1.5-pro')) && request.body.use_makersuite_sysprompt;
282
+ const prompt = convertGooglePrompt(request.body.messages, model, should_use_system_prompt, request.body.char_name, request.body.user_name);
283
+ let body = {
284
+ contents: prompt.contents,
285
+ safetySettings: GEMINI_SAFETY,
286
+ generationConfig: generationConfig,
287
+ };
288
+
289
+ if (should_use_system_prompt) {
290
+ body.system_instruction = prompt.system_instruction;
291
+ }
292
+
293
+ return body;
294
+ }
295
+
296
+ function getBisonBody() {
297
+ const prompt = isText
298
+ ? ({ text: convertTextCompletionPrompt(request.body.messages) })
299
+ : ({ messages: convertGooglePrompt(request.body.messages, model).contents });
300
+
301
+ /** @type {any} Shut the lint up */
302
+ const bisonBody = {
303
+ ...generationConfig,
304
+ safetySettings: BISON_SAFETY,
305
+ candidate_count: 1, // lewgacy spelling
306
+ prompt: prompt,
307
+ };
308
+
309
+ if (!isText) {
310
+ delete bisonBody.stopSequences;
311
+ delete bisonBody.maxOutputTokens;
312
+ delete bisonBody.safetySettings;
313
+
314
+ if (Array.isArray(prompt.messages)) {
315
+ for (const msg of prompt.messages) {
316
+ msg.author = msg.role;
317
+ msg.content = msg.parts[0].text;
318
+ delete msg.parts;
319
+ delete msg.role;
320
+ }
321
+ }
322
+ }
323
+
324
+ delete bisonBody.candidateCount;
325
+ return bisonBody;
326
+ }
327
+
328
+ const body = isGemini ? getGeminiBody() : getBisonBody();
329
+ console.log('Google AI Studio request:', body);
330
+
331
+ try {
332
+ const controller = new AbortController();
333
+ request.socket.removeAllListeners('close');
334
+ request.socket.on('close', function () {
335
+ controller.abort();
336
+ });
337
+
338
+ const apiVersion = isGemini ? 'v1beta' : 'v1beta2';
339
+ const responseType = isGemini
340
+ ? (stream ? 'streamGenerateContent' : 'generateContent')
341
+ : (isText ? 'generateText' : 'generateMessage');
342
+
343
+ const generateResponse = await fetch(`${apiUrl.origin}/${apiVersion}/models/${model}:${responseType}?key=${apiKey}${stream ? '&alt=sse' : ''}`, {
344
+ body: JSON.stringify(body),
345
+ method: 'POST',
346
+ headers: {
347
+ 'Content-Type': 'application/json',
348
+ },
349
+ signal: controller.signal,
350
+ timeout: 0,
351
+ });
352
+ // have to do this because of their busted ass streaming endpoint
353
+ if (stream) {
354
+ try {
355
+ // Pipe remote SSE stream to Express response
356
+ forwardFetchResponse(generateResponse, response);
357
+ } catch (error) {
358
+ console.log('Error forwarding streaming response:', error);
359
+ if (!response.headersSent) {
360
+ return response.status(500).send({ error: true });
361
+ }
362
+ }
363
+ } else {
364
+ if (!generateResponse.ok) {
365
+ console.log(`Google AI Studio API returned error: ${generateResponse.status} ${generateResponse.statusText} ${await generateResponse.text()}`);
366
+ return response.status(generateResponse.status).send({ error: true });
367
+ }
368
+
369
+ const generateResponseJson = await generateResponse.json();
370
+
371
+ const candidates = generateResponseJson?.candidates;
372
+ if (!candidates || candidates.length === 0) {
373
+ let message = 'Google AI Studio API returned no candidate';
374
+ console.log(message, generateResponseJson);
375
+ if (generateResponseJson?.promptFeedback?.blockReason) {
376
+ message += `\nPrompt was blocked due to : ${generateResponseJson.promptFeedback.blockReason}`;
377
+ }
378
+ return response.send({ error: { message } });
379
+ }
380
+
381
+ const responseContent = candidates[0].content ?? candidates[0].output;
382
+ const responseText = typeof responseContent === 'string' ? responseContent : responseContent?.parts?.[0]?.text;
383
+ if (!responseText) {
384
+ let message = 'Google AI Studio Candidate text empty';
385
+ console.log(message, generateResponseJson);
386
+ return response.send({ error: { message } });
387
+ }
388
+
389
+ console.log('Google AI Studio response:', responseText);
390
+
391
+ // Wrap it back to OAI format
392
+ const reply = { choices: [{ 'message': { 'content': responseText } }] };
393
+ return response.send(reply);
394
+ }
395
+ } catch (error) {
396
+ console.log('Error communicating with Google AI Studio API: ', error);
397
+ if (!response.headersSent) {
398
+ return response.status(500).send({ error: true });
399
+ }
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Sends a request to AI21 API.
405
+ * @param {express.Request} request Express request
406
+ * @param {express.Response} response Express response
407
+ */
408
+ async function sendAI21Request(request, response) {
409
+ if (!request.body) return response.sendStatus(400);
410
+ const controller = new AbortController();
411
+ console.log(request.body.messages);
412
+ request.socket.removeAllListeners('close');
413
+ request.socket.on('close', function () {
414
+ controller.abort();
415
+ });
416
+ const options = {
417
+ method: 'POST',
418
+ headers: {
419
+ accept: 'application/json',
420
+ 'content-type': 'application/json',
421
+ Authorization: `Bearer ${readSecret(request.user.directories, SECRET_KEYS.AI21)}`,
422
+ },
423
+ body: JSON.stringify({
424
+ numResults: 1,
425
+ maxTokens: request.body.max_tokens,
426
+ minTokens: 0,
427
+ temperature: request.body.temperature,
428
+ topP: request.body.top_p,
429
+ stopSequences: request.body.stop_tokens,
430
+ topKReturn: request.body.top_k,
431
+ frequencyPenalty: {
432
+ scale: request.body.frequency_penalty * 100,
433
+ applyToWhitespaces: false,
434
+ applyToPunctuations: false,
435
+ applyToNumbers: false,
436
+ applyToStopwords: false,
437
+ applyToEmojis: false,
438
+ },
439
+ presencePenalty: {
440
+ scale: request.body.presence_penalty,
441
+ applyToWhitespaces: false,
442
+ applyToPunctuations: false,
443
+ applyToNumbers: false,
444
+ applyToStopwords: false,
445
+ applyToEmojis: false,
446
+ },
447
+ countPenalty: {
448
+ scale: request.body.count_pen,
449
+ applyToWhitespaces: false,
450
+ applyToPunctuations: false,
451
+ applyToNumbers: false,
452
+ applyToStopwords: false,
453
+ applyToEmojis: false,
454
+ },
455
+ prompt: request.body.messages,
456
+ }),
457
+ signal: controller.signal,
458
+ };
459
+
460
+ fetch(`https://api.ai21.com/studio/v1/${request.body.model}/complete`, options)
461
+ .then(r => r.json())
462
+ .then(r => {
463
+ if (r.completions === undefined) {
464
+ console.log(r);
465
+ } else {
466
+ console.log(r.completions[0].data.text);
467
+ }
468
+ const reply = { choices: [{ 'message': { 'content': r.completions?.[0]?.data?.text } }] };
469
+ return response.send(reply);
470
+ })
471
+ .catch(err => {
472
+ console.error(err);
473
+ return response.send({ error: true });
474
+ });
475
+
476
+ }
477
+
478
+ /**
479
+ * Sends a request to MistralAI API.
480
+ * @param {express.Request} request Express request
481
+ * @param {express.Response} response Express response
482
+ */
483
+ async function sendMistralAIRequest(request, response) {
484
+ const apiUrl = new URL(request.body.reverse_proxy || API_MISTRAL).toString();
485
+ const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MISTRALAI);
486
+
487
+ if (!apiKey) {
488
+ console.log('MistralAI API key is missing.');
489
+ return response.status(400).send({ error: true });
490
+ }
491
+
492
+ try {
493
+ const messages = convertMistralMessages(request.body.messages, request.body.char_name, request.body.user_name);
494
+ const controller = new AbortController();
495
+ request.socket.removeAllListeners('close');
496
+ request.socket.on('close', function () {
497
+ controller.abort();
498
+ });
499
+
500
+ const requestBody = {
501
+ 'model': request.body.model,
502
+ 'messages': messages,
503
+ 'temperature': request.body.temperature,
504
+ 'top_p': request.body.top_p,
505
+ 'max_tokens': request.body.max_tokens,
506
+ 'stream': request.body.stream,
507
+ 'safe_prompt': request.body.safe_prompt,
508
+ 'random_seed': request.body.seed === -1 ? undefined : request.body.seed,
509
+ };
510
+
511
+ if (Array.isArray(request.body.tools) && request.body.tools.length > 0) {
512
+ requestBody['tools'] = request.body.tools;
513
+ requestBody['tool_choice'] = request.body.tool_choice === 'required' ? 'any' : 'auto';
514
+ }
515
+
516
+ const config = {
517
+ method: 'POST',
518
+ headers: {
519
+ 'Content-Type': 'application/json',
520
+ 'Authorization': 'Bearer ' + apiKey,
521
+ },
522
+ body: JSON.stringify(requestBody),
523
+ signal: controller.signal,
524
+ timeout: 0,
525
+ };
526
+
527
+ console.log('MisralAI request:', requestBody);
528
+
529
+ const generateResponse = await fetch(apiUrl + '/chat/completions', config);
530
+ if (request.body.stream) {
531
+ forwardFetchResponse(generateResponse, response);
532
+ } else {
533
+ if (!generateResponse.ok) {
534
+ console.log(`MistralAI API returned error: ${generateResponse.status} ${generateResponse.statusText} ${await generateResponse.text()}`);
535
+ // a 401 unauthorized response breaks the frontend auth, so return a 500 instead. prob a better way of dealing with this.
536
+ // 401s are already handled by the streaming processor and dont pop up an error toast, that should probably be fixed too.
537
+ return response.status(generateResponse.status === 401 ? 500 : generateResponse.status).send({ error: true });
538
+ }
539
+ const generateResponseJson = await generateResponse.json();
540
+ console.log('MistralAI response:', generateResponseJson);
541
+ return response.send(generateResponseJson);
542
+ }
543
+ } catch (error) {
544
+ console.log('Error communicating with MistralAI API: ', error);
545
+ if (!response.headersSent) {
546
+ response.send({ error: true });
547
+ } else {
548
+ response.end();
549
+ }
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Sends a request to Cohere API.
555
+ * @param {express.Request} request Express request
556
+ * @param {express.Response} response Express response
557
+ */
558
+ async function sendCohereRequest(request, response) {
559
+ const apiKey = readSecret(request.user.directories, SECRET_KEYS.COHERE);
560
+ const controller = new AbortController();
561
+ request.socket.removeAllListeners('close');
562
+ request.socket.on('close', function () {
563
+ controller.abort();
564
+ });
565
+
566
+ if (!apiKey) {
567
+ console.log('Cohere API key is missing.');
568
+ return response.status(400).send({ error: true });
569
+ }
570
+
571
+ try {
572
+ const convertedHistory = convertCohereMessages(request.body.messages, request.body.char_name, request.body.user_name);
573
+ const connectors = [];
574
+ const tools = [];
575
+
576
+ if (request.body.websearch) {
577
+ connectors.push({
578
+ id: 'web-search',
579
+ });
580
+ }
581
+
582
+ if (Array.isArray(request.body.tools) && request.body.tools.length > 0) {
583
+ tools.push(...convertCohereTools(request.body.tools));
584
+ // Can't have both connectors and tools in the same request
585
+ connectors.splice(0, connectors.length);
586
+ }
587
+
588
+ // https://docs.cohere.com/reference/chat
589
+ const requestBody = {
590
+ stream: Boolean(request.body.stream),
591
+ model: request.body.model,
592
+ message: convertedHistory.userPrompt,
593
+ preamble: convertedHistory.systemPrompt,
594
+ chat_history: convertedHistory.chatHistory,
595
+ temperature: request.body.temperature,
596
+ max_tokens: request.body.max_tokens,
597
+ k: request.body.top_k,
598
+ p: request.body.top_p,
599
+ seed: request.body.seed,
600
+ stop_sequences: request.body.stop,
601
+ frequency_penalty: request.body.frequency_penalty,
602
+ presence_penalty: request.body.presence_penalty,
603
+ prompt_truncation: 'AUTO_PRESERVE_ORDER',
604
+ connectors: connectors,
605
+ documents: [],
606
+ tools: tools,
607
+ search_queries_only: false,
608
+ };
609
+
610
+ console.log('Cohere request:', requestBody);
611
+
612
+ const config = {
613
+ method: 'POST',
614
+ headers: {
615
+ 'Content-Type': 'application/json',
616
+ 'Authorization': 'Bearer ' + apiKey,
617
+ },
618
+ body: JSON.stringify(requestBody),
619
+ signal: controller.signal,
620
+ timeout: 0,
621
+ };
622
+
623
+ const apiUrl = API_COHERE + '/chat';
624
+
625
+ if (request.body.stream) {
626
+ const stream = await fetch(apiUrl, config);
627
+ parseCohereStream(stream, request, response);
628
+ } else {
629
+ const generateResponse = await fetch(apiUrl, config);
630
+ if (!generateResponse.ok) {
631
+ console.log(`Cohere API returned error: ${generateResponse.status} ${generateResponse.statusText} ${await generateResponse.text()}`);
632
+ // a 401 unauthorized response breaks the frontend auth, so return a 500 instead. prob a better way of dealing with this.
633
+ // 401s are already handled by the streaming processor and dont pop up an error toast, that should probably be fixed too.
634
+ return response.status(generateResponse.status === 401 ? 500 : generateResponse.status).send({ error: true });
635
+ }
636
+ const generateResponseJson = await generateResponse.json();
637
+ console.log('Cohere response:', generateResponseJson);
638
+ return response.send(generateResponseJson);
639
+ }
640
+ } catch (error) {
641
+ console.log('Error communicating with Cohere API: ', error);
642
+ if (!response.headersSent) {
643
+ response.send({ error: true });
644
+ } else {
645
+ response.end();
646
+ }
647
+ }
648
+ }
649
+
650
+ const router = express.Router();
651
+
652
+ router.post('/status', jsonParser, async function (request, response_getstatus_openai) {
653
+ if (!request.body) return response_getstatus_openai.sendStatus(400);
654
+
655
+ let api_url;
656
+ let api_key_openai;
657
+ let headers;
658
+
659
+ if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.OPENAI) {
660
+ api_url = new URL(request.body.reverse_proxy || API_OPENAI).toString();
661
+ api_key_openai = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.OPENAI);
662
+ headers = {};
663
+ } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.OPENROUTER) {
664
+ api_url = 'https://openrouter.ai/api/v1';
665
+ api_key_openai = readSecret(request.user.directories, SECRET_KEYS.OPENROUTER);
666
+ // OpenRouter needs to pass the Referer and X-Title: https://openrouter.ai/docs#requests
667
+ headers = { ...OPENROUTER_HEADERS };
668
+ } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.MISTRALAI) {
669
+ api_url = new URL(request.body.reverse_proxy || API_MISTRAL).toString();
670
+ api_key_openai = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MISTRALAI);
671
+ headers = {};
672
+ } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.CUSTOM) {
673
+ api_url = request.body.custom_url;
674
+ api_key_openai = readSecret(request.user.directories, SECRET_KEYS.CUSTOM);
675
+ headers = {};
676
+ mergeObjectWithYaml(headers, request.body.custom_include_headers);
677
+ } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.COHERE) {
678
+ api_url = API_COHERE;
679
+ api_key_openai = readSecret(request.user.directories, SECRET_KEYS.COHERE);
680
+ headers = {};
681
+ } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.ZEROONEAI) {
682
+ api_url = API_01AI;
683
+ api_key_openai = readSecret(request.user.directories, SECRET_KEYS.ZEROONEAI);
684
+ headers = {};
685
+ } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.BLOCKENTROPY) {
686
+ api_url = API_BLOCKENTROPY;
687
+ api_key_openai = readSecret(request.user.directories, SECRET_KEYS.BLOCKENTROPY);
688
+ headers = {};
689
+ } else {
690
+ console.log('This chat completion source is not supported yet.');
691
+ return response_getstatus_openai.status(400).send({ error: true });
692
+ }
693
+
694
+ if (!api_key_openai && !request.body.reverse_proxy && request.body.chat_completion_source !== CHAT_COMPLETION_SOURCES.CUSTOM) {
695
+ console.log('OpenAI API key is missing.');
696
+ return response_getstatus_openai.status(400).send({ error: true });
697
+ }
698
+
699
+ try {
700
+ const response = await fetch(api_url + '/models', {
701
+ method: 'GET',
702
+ headers: {
703
+ 'Authorization': 'Bearer ' + api_key_openai,
704
+ ...headers,
705
+ },
706
+ });
707
+
708
+ if (response.ok) {
709
+ const data = await response.json();
710
+ response_getstatus_openai.send(data);
711
+
712
+ if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.COHERE && Array.isArray(data?.models)) {
713
+ data.data = data.models.map(model => ({ id: model.name, ...model }));
714
+ }
715
+
716
+ if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.OPENROUTER && Array.isArray(data?.data)) {
717
+ let models = [];
718
+
719
+ data.data.forEach(model => {
720
+ const context_length = model.context_length;
721
+ const tokens_dollar = Number(1 / (1000 * model.pricing?.prompt));
722
+ const tokens_rounded = (Math.round(tokens_dollar * 1000) / 1000).toFixed(0);
723
+ models[model.id] = {
724
+ tokens_per_dollar: tokens_rounded + 'k',
725
+ context_length: context_length,
726
+ };
727
+ });
728
+
729
+ console.log('Available OpenRouter models:', models);
730
+ } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.MISTRALAI) {
731
+ const models = data?.data;
732
+ console.log(models);
733
+ } else {
734
+ const models = data?.data;
735
+
736
+ if (Array.isArray(models)) {
737
+ const modelIds = models.filter(x => x && typeof x === 'object').map(x => x.id).sort();
738
+ console.log('Available OpenAI models:', modelIds);
739
+ } else {
740
+ console.log('OpenAI endpoint did not return a list of models.');
741
+ }
742
+ }
743
+ }
744
+ else {
745
+ console.log('OpenAI status check failed. Either Access Token is incorrect or API endpoint is down.');
746
+ response_getstatus_openai.send({ error: true, can_bypass: true, data: { data: [] } });
747
+ }
748
+ } catch (e) {
749
+ console.error(e);
750
+
751
+ if (!response_getstatus_openai.headersSent) {
752
+ response_getstatus_openai.send({ error: true });
753
+ } else {
754
+ response_getstatus_openai.end();
755
+ }
756
+ }
757
+ });
758
+
759
+ router.post('/bias', jsonParser, async function (request, response) {
760
+ if (!request.body || !Array.isArray(request.body))
761
+ return response.sendStatus(400);
762
+
763
+ try {
764
+ const result = {};
765
+ const model = getTokenizerModel(String(request.query.model || ''));
766
+
767
+ // no bias for claude
768
+ if (model == 'claude') {
769
+ return response.send(result);
770
+ }
771
+
772
+ let encodeFunction;
773
+
774
+ if (sentencepieceTokenizers.includes(model)) {
775
+ const tokenizer = getSentencepiceTokenizer(model);
776
+ const instance = await tokenizer?.get();
777
+ if (!instance) {
778
+ console.warn('Tokenizer not initialized:', model);
779
+ return response.send({});
780
+ }
781
+ encodeFunction = (text) => new Uint32Array(instance.encodeIds(text));
782
+ } else {
783
+ const tokenizer = getTiktokenTokenizer(model);
784
+ encodeFunction = (tokenizer.encode.bind(tokenizer));
785
+ }
786
+
787
+ for (const entry of request.body) {
788
+ if (!entry || !entry.text) {
789
+ continue;
790
+ }
791
+
792
+ try {
793
+ const tokens = getEntryTokens(entry.text, encodeFunction);
794
+
795
+ for (const token of tokens) {
796
+ result[token] = entry.value;
797
+ }
798
+ } catch {
799
+ console.warn('Tokenizer failed to encode:', entry.text);
800
+ }
801
+ }
802
+
803
+ // not needed for cached tokenizers
804
+ //tokenizer.free();
805
+ return response.send(result);
806
+
807
+ /**
808
+ * Gets tokenids for a given entry
809
+ * @param {string} text Entry text
810
+ * @param {(string) => Uint32Array} encode Function to encode text to token ids
811
+ * @returns {Uint32Array} Array of token ids
812
+ */
813
+ function getEntryTokens(text, encode) {
814
+ // Get raw token ids from JSON array
815
+ if (text.trim().startsWith('[') && text.trim().endsWith(']')) {
816
+ try {
817
+ const json = JSON.parse(text);
818
+ if (Array.isArray(json) && json.every(x => typeof x === 'number')) {
819
+ return new Uint32Array(json);
820
+ }
821
+ } catch {
822
+ // ignore
823
+ }
824
+ }
825
+
826
+ // Otherwise, get token ids from tokenizer
827
+ return encode(text);
828
+ }
829
+ } catch (error) {
830
+ console.error(error);
831
+ return response.send({});
832
+ }
833
+ });
834
+
835
+
836
+ router.post('/generate', jsonParser, function (request, response) {
837
+ if (!request.body) return response.status(400).send({ error: true });
838
+
839
+ switch (request.body.chat_completion_source) {
840
+ case CHAT_COMPLETION_SOURCES.CLAUDE: return sendClaudeRequest(request, response);
841
+ case CHAT_COMPLETION_SOURCES.SCALE: return sendScaleRequest(request, response);
842
+ case CHAT_COMPLETION_SOURCES.AI21: return sendAI21Request(request, response);
843
+ case CHAT_COMPLETION_SOURCES.MAKERSUITE: return sendMakerSuiteRequest(request, response);
844
+ case CHAT_COMPLETION_SOURCES.MISTRALAI: return sendMistralAIRequest(request, response);
845
+ case CHAT_COMPLETION_SOURCES.COHERE: return sendCohereRequest(request, response);
846
+ }
847
+
848
+ let apiUrl;
849
+ let apiKey;
850
+ let headers;
851
+ let bodyParams;
852
+ const isTextCompletion = Boolean(request.body.model && TEXT_COMPLETION_MODELS.includes(request.body.model)) || typeof request.body.messages === 'string';
853
+
854
+ if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.OPENAI) {
855
+ apiUrl = new URL(request.body.reverse_proxy || API_OPENAI).toString();
856
+ apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.OPENAI);
857
+ headers = {};
858
+ bodyParams = {
859
+ logprobs: request.body.logprobs,
860
+ top_logprobs: undefined,
861
+ };
862
+
863
+ // Adjust logprobs params for Chat Completions API, which expects { top_logprobs: number; logprobs: boolean; }
864
+ if (!isTextCompletion && bodyParams.logprobs > 0) {
865
+ bodyParams.top_logprobs = bodyParams.logprobs;
866
+ bodyParams.logprobs = true;
867
+ }
868
+
869
+ if (getConfigValue('openai.randomizeUserId', false)) {
870
+ bodyParams['user'] = uuidv4();
871
+ }
872
+ } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.OPENROUTER) {
873
+ apiUrl = 'https://openrouter.ai/api/v1';
874
+ apiKey = readSecret(request.user.directories, SECRET_KEYS.OPENROUTER);
875
+ // OpenRouter needs to pass the Referer and X-Title: https://openrouter.ai/docs#requests
876
+ headers = { ...OPENROUTER_HEADERS };
877
+ bodyParams = { 'transforms': ['middle-out'] };
878
+
879
+ if (request.body.min_p !== undefined) {
880
+ bodyParams['min_p'] = request.body.min_p;
881
+ }
882
+
883
+ if (request.body.top_a !== undefined) {
884
+ bodyParams['top_a'] = request.body.top_a;
885
+ }
886
+
887
+ if (request.body.repetition_penalty !== undefined) {
888
+ bodyParams['repetition_penalty'] = request.body.repetition_penalty;
889
+ }
890
+
891
+ if (Array.isArray(request.body.provider) && request.body.provider.length > 0) {
892
+ bodyParams['provider'] = {
893
+ allow_fallbacks: request.body.allow_fallbacks ?? true,
894
+ order: request.body.provider ?? [],
895
+ };
896
+ }
897
+
898
+ if (request.body.use_fallback) {
899
+ bodyParams['route'] = 'fallback';
900
+ }
901
+ } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.CUSTOM) {
902
+ apiUrl = request.body.custom_url;
903
+ apiKey = readSecret(request.user.directories, SECRET_KEYS.CUSTOM);
904
+ headers = {};
905
+ bodyParams = {
906
+ logprobs: request.body.logprobs,
907
+ top_logprobs: undefined,
908
+ };
909
+
910
+ // Adjust logprobs params for Chat Completions API, which expects { top_logprobs: number; logprobs: boolean; }
911
+ if (!isTextCompletion && bodyParams.logprobs > 0) {
912
+ bodyParams.top_logprobs = bodyParams.logprobs;
913
+ bodyParams.logprobs = true;
914
+ }
915
+
916
+ mergeObjectWithYaml(bodyParams, request.body.custom_include_body);
917
+ mergeObjectWithYaml(headers, request.body.custom_include_headers);
918
+
919
+ if (request.body.custom_prompt_post_processing) {
920
+ console.log('Applying custom prompt post-processing of type', request.body.custom_prompt_post_processing);
921
+ request.body.messages = postProcessPrompt(
922
+ request.body.messages,
923
+ request.body.custom_prompt_post_processing,
924
+ request.body.char_name,
925
+ request.body.user_name);
926
+ }
927
+ } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.PERPLEXITY) {
928
+ apiUrl = API_PERPLEXITY;
929
+ apiKey = readSecret(request.user.directories, SECRET_KEYS.PERPLEXITY);
930
+ headers = {};
931
+ bodyParams = {};
932
+ request.body.messages = postProcessPrompt(request.body.messages, 'claude', request.body.char_name, request.body.user_name);
933
+ } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.GROQ) {
934
+ apiUrl = API_GROQ;
935
+ apiKey = readSecret(request.user.directories, SECRET_KEYS.GROQ);
936
+ headers = {};
937
+ bodyParams = {};
938
+
939
+ // 'required' tool choice is not supported by Groq
940
+ if (request.body.tool_choice === 'required') {
941
+ if (Array.isArray(request.body.tools) && request.body.tools.length > 0) {
942
+ request.body.tool_choice = request.body.tools.length > 1
943
+ ? 'auto' :
944
+ { type: 'function', function: { name: request.body.tools[0]?.function?.name } };
945
+
946
+ } else {
947
+ request.body.tool_choice = 'none';
948
+ }
949
+ }
950
+ } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.ZEROONEAI) {
951
+ apiUrl = API_01AI;
952
+ apiKey = readSecret(request.user.directories, SECRET_KEYS.ZEROONEAI);
953
+ headers = {};
954
+ bodyParams = {};
955
+ } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.BLOCKENTROPY) {
956
+ apiUrl = API_BLOCKENTROPY;
957
+ apiKey = readSecret(request.user.directories, SECRET_KEYS.BLOCKENTROPY);
958
+ headers = {};
959
+ bodyParams = {};
960
+ } else {
961
+ console.log('This chat completion source is not supported yet.');
962
+ return response.status(400).send({ error: true });
963
+ }
964
+
965
+ if (!apiKey && !request.body.reverse_proxy && request.body.chat_completion_source !== CHAT_COMPLETION_SOURCES.CUSTOM) {
966
+ console.log('OpenAI API key is missing.');
967
+ return response.status(400).send({ error: true });
968
+ }
969
+
970
+ // Add custom stop sequences
971
+ if (Array.isArray(request.body.stop) && request.body.stop.length > 0) {
972
+ bodyParams['stop'] = request.body.stop;
973
+ }
974
+
975
+ const textPrompt = isTextCompletion ? convertTextCompletionPrompt(request.body.messages) : '';
976
+ const endpointUrl = isTextCompletion && request.body.chat_completion_source !== CHAT_COMPLETION_SOURCES.OPENROUTER ?
977
+ `${apiUrl}/completions` :
978
+ `${apiUrl}/chat/completions`;
979
+
980
+ const controller = new AbortController();
981
+ request.socket.removeAllListeners('close');
982
+ request.socket.on('close', function () {
983
+ controller.abort();
984
+ });
985
+
986
+ if (!isTextCompletion) {
987
+ bodyParams['tools'] = request.body.tools;
988
+ bodyParams['tool_choice'] = request.body.tool_choice;
989
+ }
990
+
991
+ const requestBody = {
992
+ 'messages': isTextCompletion === false ? request.body.messages : undefined,
993
+ 'prompt': isTextCompletion === true ? textPrompt : undefined,
994
+ 'model': request.body.model,
995
+ 'temperature': request.body.temperature,
996
+ 'max_tokens': request.body.max_tokens,
997
+ 'stream': request.body.stream,
998
+ 'presence_penalty': request.body.presence_penalty,
999
+ 'frequency_penalty': request.body.frequency_penalty,
1000
+ 'top_p': request.body.top_p,
1001
+ 'top_k': request.body.top_k,
1002
+ 'stop': isTextCompletion === false ? request.body.stop : undefined,
1003
+ 'logit_bias': request.body.logit_bias,
1004
+ 'seed': request.body.seed,
1005
+ 'n': request.body.n,
1006
+ ...bodyParams,
1007
+ };
1008
+
1009
+ if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.CUSTOM) {
1010
+ excludeKeysByYaml(requestBody, request.body.custom_exclude_body);
1011
+ }
1012
+
1013
+ /** @type {import('node-fetch').RequestInit} */
1014
+ const config = {
1015
+ method: 'post',
1016
+ headers: {
1017
+ 'Content-Type': 'application/json',
1018
+ 'Authorization': 'Bearer ' + apiKey,
1019
+ ...headers,
1020
+ },
1021
+ body: JSON.stringify(requestBody),
1022
+ signal: controller.signal,
1023
+ timeout: 0,
1024
+ };
1025
+
1026
+ console.log(requestBody);
1027
+
1028
+ makeRequest(config, response, request);
1029
+
1030
+ /**
1031
+ * Makes a fetch request to the OpenAI API endpoint.
1032
+ * @param {import('node-fetch').RequestInit} config Fetch config
1033
+ * @param {express.Response} response Express response
1034
+ * @param {express.Request} request Express request
1035
+ * @param {Number} retries Number of retries left
1036
+ * @param {Number} timeout Request timeout in ms
1037
+ */
1038
+ async function makeRequest(config, response, request, retries = 5, timeout = 5000) {
1039
+ try {
1040
+ const fetchResponse = await fetch(endpointUrl, config);
1041
+
1042
+ if (request.body.stream) {
1043
+ console.log('Streaming request in progress');
1044
+ forwardFetchResponse(fetchResponse, response);
1045
+ return;
1046
+ }
1047
+
1048
+ if (fetchResponse.ok) {
1049
+ let json = await fetchResponse.json();
1050
+ response.send(json);
1051
+ console.log(json);
1052
+ console.log(json?.choices?.[0]?.message);
1053
+ } else if (fetchResponse.status === 429 && retries > 0) {
1054
+ console.log(`Out of quota, retrying in ${Math.round(timeout / 1000)}s`);
1055
+ setTimeout(() => {
1056
+ timeout *= 2;
1057
+ makeRequest(config, response, request, retries - 1, timeout);
1058
+ }, timeout);
1059
+ } else {
1060
+ await handleErrorResponse(fetchResponse);
1061
+ }
1062
+ } catch (error) {
1063
+ console.log('Generation failed', error);
1064
+ if (!response.headersSent) {
1065
+ response.send({ error: true });
1066
+ } else {
1067
+ response.end();
1068
+ }
1069
+ }
1070
+ }
1071
+
1072
+ /**
1073
+ * @param {import("node-fetch").Response} errorResponse
1074
+ */
1075
+ async function handleErrorResponse(errorResponse) {
1076
+ const responseText = await errorResponse.text();
1077
+ const errorData = tryParse(responseText);
1078
+
1079
+ const statusMessages = {
1080
+ 400: 'Bad request',
1081
+ 401: 'Unauthorized',
1082
+ 402: 'Credit limit reached',
1083
+ 403: 'Forbidden',
1084
+ 404: 'Not found',
1085
+ 429: 'Too many requests',
1086
+ 451: 'Unavailable for legal reasons',
1087
+ 502: 'Bad gateway',
1088
+ };
1089
+
1090
+ const message = errorData?.error?.message || statusMessages[errorResponse.status] || 'Unknown error occurred';
1091
+ const quota_error = errorResponse.status === 429 && errorData?.error?.type === 'insufficient_quota';
1092
+ console.log(message);
1093
+
1094
+ if (!response.headersSent) {
1095
+ response.send({ error: { message }, quota_error: quota_error });
1096
+ } else if (!response.writableEnded) {
1097
+ response.write(errorResponse);
1098
+ } else {
1099
+ response.end();
1100
+ }
1101
+ }
1102
+ });
1103
+
1104
+ module.exports = {
1105
+ router,
1106
+ };
src/endpoints/backends/kobold.js ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const fetch = require('node-fetch').default;
3
+ const fs = require('fs');
4
+
5
+ const { jsonParser, urlencodedParser } = require('../../express-common');
6
+ const { forwardFetchResponse, delay } = require('../../util');
7
+ const { getOverrideHeaders, setAdditionalHeaders, setAdditionalHeadersByType } = require('../../additional-headers');
8
+ const { TEXTGEN_TYPES } = require('../../constants');
9
+
10
+ const router = express.Router();
11
+
12
+ router.post('/generate', jsonParser, async function (request, response_generate) {
13
+ if (!request.body) return response_generate.sendStatus(400);
14
+
15
+ if (request.body.api_server.indexOf('localhost') != -1) {
16
+ request.body.api_server = request.body.api_server.replace('localhost', '127.0.0.1');
17
+ }
18
+
19
+ const request_prompt = request.body.prompt;
20
+ const controller = new AbortController();
21
+ request.socket.removeAllListeners('close');
22
+ request.socket.on('close', async function () {
23
+ if (request.body.can_abort && !response_generate.writableEnded) {
24
+ try {
25
+ console.log('Aborting Kobold generation...');
26
+ // send abort signal to koboldcpp
27
+ const abortResponse = await fetch(`${request.body.api_server}/extra/abort`, {
28
+ method: 'POST',
29
+ });
30
+
31
+ if (!abortResponse.ok) {
32
+ console.log('Error sending abort request to Kobold:', abortResponse.status);
33
+ }
34
+ } catch (error) {
35
+ console.log(error);
36
+ }
37
+ }
38
+ controller.abort();
39
+ });
40
+
41
+ let this_settings = {
42
+ prompt: request_prompt,
43
+ use_story: false,
44
+ use_memory: false,
45
+ use_authors_note: false,
46
+ use_world_info: false,
47
+ max_context_length: request.body.max_context_length,
48
+ max_length: request.body.max_length,
49
+ };
50
+
51
+ if (!request.body.gui_settings) {
52
+ this_settings = {
53
+ prompt: request_prompt,
54
+ use_story: false,
55
+ use_memory: false,
56
+ use_authors_note: false,
57
+ use_world_info: false,
58
+ max_context_length: request.body.max_context_length,
59
+ max_length: request.body.max_length,
60
+ rep_pen: request.body.rep_pen,
61
+ rep_pen_range: request.body.rep_pen_range,
62
+ rep_pen_slope: request.body.rep_pen_slope,
63
+ temperature: request.body.temperature,
64
+ tfs: request.body.tfs,
65
+ top_a: request.body.top_a,
66
+ top_k: request.body.top_k,
67
+ top_p: request.body.top_p,
68
+ min_p: request.body.min_p,
69
+ typical: request.body.typical,
70
+ sampler_order: request.body.sampler_order,
71
+ singleline: !!request.body.singleline,
72
+ use_default_badwordsids: request.body.use_default_badwordsids,
73
+ mirostat: request.body.mirostat,
74
+ mirostat_eta: request.body.mirostat_eta,
75
+ mirostat_tau: request.body.mirostat_tau,
76
+ grammar: request.body.grammar,
77
+ sampler_seed: request.body.sampler_seed,
78
+ };
79
+ if (request.body.stop_sequence) {
80
+ this_settings['stop_sequence'] = request.body.stop_sequence;
81
+ }
82
+ }
83
+
84
+ console.log(this_settings);
85
+ const args = {
86
+ body: JSON.stringify(this_settings),
87
+ headers: Object.assign(
88
+ { 'Content-Type': 'application/json' },
89
+ getOverrideHeaders((new URL(request.body.api_server))?.host),
90
+ ),
91
+ signal: controller.signal,
92
+ };
93
+
94
+ const MAX_RETRIES = 50;
95
+ const delayAmount = 2500;
96
+ for (let i = 0; i < MAX_RETRIES; i++) {
97
+ try {
98
+ const url = request.body.streaming ? `${request.body.api_server}/extra/generate/stream` : `${request.body.api_server}/v1/generate`;
99
+ const response = await fetch(url, { method: 'POST', timeout: 0, ...args });
100
+
101
+ if (request.body.streaming) {
102
+ // Pipe remote SSE stream to Express response
103
+ forwardFetchResponse(response, response_generate);
104
+ return;
105
+ } else {
106
+ if (!response.ok) {
107
+ const errorText = await response.text();
108
+ console.log(`Kobold returned error: ${response.status} ${response.statusText} ${errorText}`);
109
+
110
+ try {
111
+ const errorJson = JSON.parse(errorText);
112
+ const message = errorJson?.detail?.msg || errorText;
113
+ return response_generate.status(400).send({ error: { message } });
114
+ } catch {
115
+ return response_generate.status(400).send({ error: { message: errorText } });
116
+ }
117
+ }
118
+
119
+ const data = await response.json();
120
+ console.log('Endpoint response:', data);
121
+ return response_generate.send(data);
122
+ }
123
+ } catch (error) {
124
+ // response
125
+ switch (error?.status) {
126
+ case 403:
127
+ case 503: // retry in case of temporary service issue, possibly caused by a queue failure?
128
+ console.debug(`KoboldAI is busy. Retry attempt ${i + 1} of ${MAX_RETRIES}...`);
129
+ await delay(delayAmount);
130
+ break;
131
+ default:
132
+ if ('status' in error) {
133
+ console.log('Status Code from Kobold:', error.status);
134
+ }
135
+ return response_generate.send({ error: true });
136
+ }
137
+ }
138
+ }
139
+
140
+ console.log('Max retries exceeded. Giving up.');
141
+ return response_generate.send({ error: true });
142
+ });
143
+
144
+ router.post('/status', jsonParser, async function (request, response) {
145
+ if (!request.body) return response.sendStatus(400);
146
+ let api_server = request.body.api_server;
147
+ if (api_server.indexOf('localhost') != -1) {
148
+ api_server = api_server.replace('localhost', '127.0.0.1');
149
+ }
150
+
151
+ const args = {
152
+ headers: { 'Content-Type': 'application/json' },
153
+ };
154
+
155
+ setAdditionalHeaders(request, args, api_server);
156
+
157
+ const result = {};
158
+
159
+ const [koboldUnitedResponse, koboldExtraResponse, koboldModelResponse] = await Promise.all([
160
+ // We catch errors both from the response not having a successful HTTP status and from JSON parsing failing
161
+
162
+ // Kobold United API version
163
+ fetch(`${api_server}/v1/info/version`).then(response => {
164
+ if (!response.ok) throw new Error(`Kobold API error: ${response.status, response.statusText}`);
165
+ return response.json();
166
+ }).catch(() => ({ result: '0.0.0' })),
167
+
168
+ // KoboldCpp version
169
+ fetch(`${api_server}/extra/version`).then(response => {
170
+ if (!response.ok) throw new Error(`Kobold API error: ${response.status, response.statusText}`);
171
+ return response.json();
172
+ }).catch(() => ({ version: '0.0' })),
173
+
174
+ // Current model
175
+ fetch(`${api_server}/v1/model`).then(response => {
176
+ if (!response.ok) throw new Error(`Kobold API error: ${response.status, response.statusText}`);
177
+ return response.json();
178
+ }).catch(() => null),
179
+ ]);
180
+
181
+ result.koboldUnitedVersion = koboldUnitedResponse.result;
182
+ result.koboldCppVersion = koboldExtraResponse.result;
183
+ result.model = !koboldModelResponse || koboldModelResponse.result === 'ReadOnly' ?
184
+ 'no_connection' :
185
+ koboldModelResponse.result;
186
+
187
+ response.send(result);
188
+ });
189
+
190
+ router.post('/transcribe-audio', urlencodedParser, async function (request, response) {
191
+ try {
192
+ const server = request.body.server;
193
+
194
+ if (!server) {
195
+ console.log('Server is not set');
196
+ return response.sendStatus(400);
197
+ }
198
+
199
+ if (!request.file) {
200
+ console.log('No audio file found');
201
+ return response.sendStatus(400);
202
+ }
203
+
204
+ console.log('Transcribing audio with KoboldCpp', server);
205
+
206
+ const fileBase64 = fs.readFileSync(request.file.path).toString('base64');
207
+ fs.rmSync(request.file.path);
208
+
209
+ const headers = {};
210
+ setAdditionalHeadersByType(headers, TEXTGEN_TYPES.KOBOLDCPP, server, request.user.directories);
211
+
212
+ const url = new URL(server);
213
+ url.pathname = '/api/extra/transcribe';
214
+
215
+ const result = await fetch(url, {
216
+ method: 'POST',
217
+ headers: {
218
+ ...headers,
219
+ },
220
+ body: JSON.stringify({
221
+ prompt: '',
222
+ audio_data: fileBase64,
223
+ }),
224
+ });
225
+
226
+ if (!result.ok) {
227
+ const text = await result.text();
228
+ console.log('KoboldCpp request failed', result.statusText, text);
229
+ return response.status(500).send(text);
230
+ }
231
+
232
+ const data = await result.json();
233
+ console.log('KoboldCpp transcription response', data);
234
+ return response.json(data);
235
+ } catch (error) {
236
+ console.error('KoboldCpp transcription failed', error);
237
+ response.status(500).send('Internal server error');
238
+ }
239
+ });
240
+
241
+ module.exports = { router };
src/endpoints/backends/scale-alt.js ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const fetch = require('node-fetch').default;
3
+
4
+ const { jsonParser } = require('../../express-common');
5
+
6
+ const { readSecret, SECRET_KEYS } = require('../secrets');
7
+
8
+ const router = express.Router();
9
+
10
+ router.post('/generate', jsonParser, async function (request, response) {
11
+ if (!request.body) return response.sendStatus(400);
12
+
13
+ try {
14
+ const cookie = readSecret(request.user.directories, SECRET_KEYS.SCALE_COOKIE);
15
+
16
+ if (!cookie) {
17
+ console.log('No Scale cookie found');
18
+ return response.sendStatus(400);
19
+ }
20
+
21
+ const body = {
22
+ json: {
23
+ variant: {
24
+ name: 'New Variant',
25
+ appId: '',
26
+ taxonomy: null,
27
+ },
28
+ prompt: {
29
+ id: '',
30
+ template: '{{input}}\n',
31
+ exampleVariables: {},
32
+ variablesSourceDataId: null,
33
+ systemMessage: request.body.sysprompt,
34
+ },
35
+ modelParameters: {
36
+ id: '',
37
+ modelId: 'GPT4',
38
+ modelType: 'OpenAi',
39
+ maxTokens: request.body.max_tokens,
40
+ temperature: request.body.temp,
41
+ stop: 'user:',
42
+ suffix: null,
43
+ topP: request.body.top_p,
44
+ logprobs: null,
45
+ logitBias: request.body.logit_bias,
46
+ },
47
+ inputs: [
48
+ {
49
+ index: '-1',
50
+ valueByName: {
51
+ input: request.body.prompt,
52
+ },
53
+ },
54
+ ],
55
+ },
56
+ meta: {
57
+ values: {
58
+ 'variant.taxonomy': ['undefined'],
59
+ 'prompt.variablesSourceDataId': ['undefined'],
60
+ 'modelParameters.suffix': ['undefined'],
61
+ 'modelParameters.logprobs': ['undefined'],
62
+ },
63
+ },
64
+ };
65
+
66
+ console.log('Scale request:', body);
67
+
68
+ const result = await fetch('https://dashboard.scale.com/spellbook/api/trpc/v2.variant.run', {
69
+ method: 'POST',
70
+ headers: {
71
+ 'Content-Type': 'application/json',
72
+ 'cookie': `_jwt=${cookie}`,
73
+ },
74
+ timeout: 0,
75
+ body: JSON.stringify(body),
76
+ });
77
+
78
+ if (!result.ok) {
79
+ const text = await result.text();
80
+ console.log('Scale request failed', result.statusText, text);
81
+ return response.status(500).send({ error: { message: result.statusText } });
82
+ }
83
+
84
+ const data = await result.json();
85
+ const output = data?.result?.data?.json?.outputs?.[0] || '';
86
+
87
+ console.log('Scale response:', data);
88
+
89
+ if (!output) {
90
+ console.warn('Scale response is empty');
91
+ return response.sendStatus(500).send({ error: { message: 'Empty response' } });
92
+ }
93
+
94
+ return response.json({ output });
95
+ } catch (error) {
96
+ console.log(error);
97
+ return response.sendStatus(500);
98
+ }
99
+ });
100
+
101
+ module.exports = { router };
src/endpoints/backends/text-completions.js ADDED
@@ -0,0 +1,641 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const fetch = require('node-fetch').default;
3
+ const _ = require('lodash');
4
+ const Readable = require('stream').Readable;
5
+
6
+ const { jsonParser } = require('../../express-common');
7
+ const { TEXTGEN_TYPES, TOGETHERAI_KEYS, OLLAMA_KEYS, INFERMATICAI_KEYS, OPENROUTER_KEYS, VLLM_KEYS, DREAMGEN_KEYS, FEATHERLESS_KEYS } = require('../../constants');
8
+ const { forwardFetchResponse, trimV1, getConfigValue } = require('../../util');
9
+ const { setAdditionalHeaders } = require('../../additional-headers');
10
+
11
+ const router = express.Router();
12
+
13
+ /**
14
+ * Special boy's steaming routine. Wrap this abomination into proper SSE stream.
15
+ * @param {import('node-fetch').Response} jsonStream JSON stream
16
+ * @param {import('express').Request} request Express request
17
+ * @param {import('express').Response} response Express response
18
+ * @returns {Promise<any>} Nothing valuable
19
+ */
20
+ async function parseOllamaStream(jsonStream, request, response) {
21
+ try {
22
+ let partialData = '';
23
+ jsonStream.body.on('data', (data) => {
24
+ const chunk = data.toString();
25
+ partialData += chunk;
26
+ while (true) {
27
+ let json;
28
+ try {
29
+ json = JSON.parse(partialData);
30
+ } catch (e) {
31
+ break;
32
+ }
33
+ const text = json.response || '';
34
+ const chunk = { choices: [{ text }] };
35
+ response.write(`data: ${JSON.stringify(chunk)}\n\n`);
36
+ partialData = '';
37
+ }
38
+ });
39
+
40
+ request.socket.on('close', function () {
41
+ if (jsonStream.body instanceof Readable) jsonStream.body.destroy();
42
+ response.end();
43
+ });
44
+
45
+ jsonStream.body.on('end', () => {
46
+ console.log('Streaming request finished');
47
+ response.write('data: [DONE]\n\n');
48
+ response.end();
49
+ });
50
+ } catch (error) {
51
+ console.log('Error forwarding streaming response:', error);
52
+ if (!response.headersSent) {
53
+ return response.status(500).send({ error: true });
54
+ } else {
55
+ return response.end();
56
+ }
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Abort KoboldCpp generation request.
62
+ * @param {string} url Server base URL
63
+ * @returns {Promise<void>} Promise resolving when we are done
64
+ */
65
+ async function abortKoboldCppRequest(url) {
66
+ try {
67
+ console.log('Aborting Kobold generation...');
68
+ const abortResponse = await fetch(`${url}/api/extra/abort`, {
69
+ method: 'POST',
70
+ });
71
+
72
+ if (!abortResponse.ok) {
73
+ console.log('Error sending abort request to Kobold:', abortResponse.status, abortResponse.statusText);
74
+ }
75
+ } catch (error) {
76
+ console.log(error);
77
+ }
78
+ }
79
+
80
+ //************** Ooba/OpenAI text completions API
81
+ router.post('/status', jsonParser, async function (request, response) {
82
+ if (!request.body) return response.sendStatus(400);
83
+
84
+ try {
85
+ if (request.body.api_server.indexOf('localhost') !== -1) {
86
+ request.body.api_server = request.body.api_server.replace('localhost', '127.0.0.1');
87
+ }
88
+
89
+ console.log('Trying to connect to API:', request.body);
90
+ const baseUrl = trimV1(request.body.api_server);
91
+
92
+ const args = {
93
+ headers: { 'Content-Type': 'application/json' },
94
+ };
95
+
96
+ setAdditionalHeaders(request, args, baseUrl);
97
+
98
+ const apiType = request.body.api_type;
99
+ let url = baseUrl;
100
+ let result = '';
101
+
102
+ if (request.body.legacy_api) {
103
+ url += '/v1/model';
104
+ } else {
105
+ switch (apiType) {
106
+ case TEXTGEN_TYPES.OOBA:
107
+ case TEXTGEN_TYPES.VLLM:
108
+ case TEXTGEN_TYPES.APHRODITE:
109
+ case TEXTGEN_TYPES.KOBOLDCPP:
110
+ case TEXTGEN_TYPES.LLAMACPP:
111
+ case TEXTGEN_TYPES.INFERMATICAI:
112
+ case TEXTGEN_TYPES.OPENROUTER:
113
+ url += '/v1/models';
114
+ break;
115
+ case TEXTGEN_TYPES.DREAMGEN:
116
+ url += '/api/openai/v1/models';
117
+ break;
118
+ case TEXTGEN_TYPES.MANCER:
119
+ url += '/oai/v1/models';
120
+ break;
121
+ case TEXTGEN_TYPES.TABBY:
122
+ url += '/v1/model/list';
123
+ break;
124
+ case TEXTGEN_TYPES.TOGETHERAI:
125
+ url += '/api/models?&info';
126
+ break;
127
+ case TEXTGEN_TYPES.OLLAMA:
128
+ url += '/api/tags';
129
+ break;
130
+ case TEXTGEN_TYPES.FEATHERLESS:
131
+ url += '/v1/models';
132
+ break;
133
+ case TEXTGEN_TYPES.HUGGINGFACE:
134
+ url += '/info';
135
+ break;
136
+ }
137
+ }
138
+
139
+ const modelsReply = await fetch(url, args);
140
+
141
+ if (!modelsReply.ok) {
142
+ console.log('Models endpoint is offline.');
143
+ return response.status(400);
144
+ }
145
+
146
+ let data = await modelsReply.json();
147
+
148
+ if (request.body.legacy_api) {
149
+ console.log('Legacy API response:', data);
150
+ return response.send({ result: data?.result });
151
+ }
152
+
153
+ // Rewrap to OAI-like response
154
+ if (apiType === TEXTGEN_TYPES.TOGETHERAI && Array.isArray(data)) {
155
+ data = { data: data.map(x => ({ id: x.name, ...x })) };
156
+ }
157
+
158
+ if (apiType === TEXTGEN_TYPES.OLLAMA && Array.isArray(data.models)) {
159
+ data = { data: data.models.map(x => ({ id: x.name, ...x })) };
160
+ }
161
+
162
+ if (apiType === TEXTGEN_TYPES.HUGGINGFACE) {
163
+ data = { data: [] };
164
+ }
165
+
166
+ if (!Array.isArray(data.data)) {
167
+ console.log('Models response is not an array.');
168
+ return response.status(400);
169
+ }
170
+
171
+ const modelIds = data.data.map(x => x.id);
172
+ console.log('Models available:', modelIds);
173
+
174
+ // Set result to the first model ID
175
+ result = modelIds[0] || 'Valid';
176
+
177
+ if (apiType === TEXTGEN_TYPES.OOBA) {
178
+ try {
179
+ const modelInfoUrl = baseUrl + '/v1/internal/model/info';
180
+ const modelInfoReply = await fetch(modelInfoUrl, args);
181
+
182
+ if (modelInfoReply.ok) {
183
+ const modelInfo = await modelInfoReply.json();
184
+ console.log('Ooba model info:', modelInfo);
185
+
186
+ const modelName = modelInfo?.model_name;
187
+ result = modelName || result;
188
+ }
189
+ } catch (error) {
190
+ console.error(`Failed to get Ooba model info: ${error}`);
191
+ }
192
+ } else if (apiType === TEXTGEN_TYPES.TABBY) {
193
+ try {
194
+ const modelInfoUrl = baseUrl + '/v1/model';
195
+ const modelInfoReply = await fetch(modelInfoUrl, args);
196
+
197
+ if (modelInfoReply.ok) {
198
+ const modelInfo = await modelInfoReply.json();
199
+ console.log('Tabby model info:', modelInfo);
200
+
201
+ const modelName = modelInfo?.id;
202
+ result = modelName || result;
203
+ } else {
204
+ // TabbyAPI returns an error 400 if a model isn't loaded
205
+
206
+ result = 'None';
207
+ }
208
+ } catch (error) {
209
+ console.error(`Failed to get TabbyAPI model info: ${error}`);
210
+ }
211
+ }
212
+
213
+ return response.send({ result, data: data.data });
214
+ } catch (error) {
215
+ console.error(error);
216
+ return response.status(500);
217
+ }
218
+ });
219
+
220
+ router.post('/generate', jsonParser, async function (request, response) {
221
+ if (!request.body) return response.sendStatus(400);
222
+
223
+ try {
224
+ if (request.body.api_server.indexOf('localhost') !== -1) {
225
+ request.body.api_server = request.body.api_server.replace('localhost', '127.0.0.1');
226
+ }
227
+
228
+ const apiType = request.body.api_type;
229
+ const baseUrl = request.body.api_server;
230
+ console.log(request.body);
231
+
232
+ const controller = new AbortController();
233
+ request.socket.removeAllListeners('close');
234
+ request.socket.on('close', async function () {
235
+ if (request.body.api_type === TEXTGEN_TYPES.KOBOLDCPP && !response.writableEnded) {
236
+ await abortKoboldCppRequest(trimV1(baseUrl));
237
+ }
238
+
239
+ controller.abort();
240
+ });
241
+
242
+ let url = trimV1(baseUrl);
243
+
244
+ if (request.body.legacy_api) {
245
+ url += '/v1/generate';
246
+ } else {
247
+ switch (request.body.api_type) {
248
+ case TEXTGEN_TYPES.VLLM:
249
+ case TEXTGEN_TYPES.FEATHERLESS:
250
+ case TEXTGEN_TYPES.APHRODITE:
251
+ case TEXTGEN_TYPES.OOBA:
252
+ case TEXTGEN_TYPES.TABBY:
253
+ case TEXTGEN_TYPES.KOBOLDCPP:
254
+ case TEXTGEN_TYPES.TOGETHERAI:
255
+ case TEXTGEN_TYPES.INFERMATICAI:
256
+ case TEXTGEN_TYPES.HUGGINGFACE:
257
+ url += '/v1/completions';
258
+ break;
259
+ case TEXTGEN_TYPES.DREAMGEN:
260
+ url += '/api/openai/v1/completions';
261
+ break;
262
+ case TEXTGEN_TYPES.MANCER:
263
+ url += '/oai/v1/completions';
264
+ break;
265
+ case TEXTGEN_TYPES.LLAMACPP:
266
+ url += '/completion';
267
+ break;
268
+ case TEXTGEN_TYPES.OLLAMA:
269
+ url += '/api/generate';
270
+ break;
271
+ case TEXTGEN_TYPES.OPENROUTER:
272
+ url += '/v1/chat/completions';
273
+ break;
274
+ }
275
+ }
276
+
277
+ const args = {
278
+ method: 'POST',
279
+ body: JSON.stringify(request.body),
280
+ headers: { 'Content-Type': 'application/json' },
281
+ signal: controller.signal,
282
+ timeout: 0,
283
+ };
284
+
285
+ setAdditionalHeaders(request, args, baseUrl);
286
+
287
+ if (request.body.api_type === TEXTGEN_TYPES.TOGETHERAI) {
288
+ request.body = _.pickBy(request.body, (_, key) => TOGETHERAI_KEYS.includes(key));
289
+ args.body = JSON.stringify(request.body);
290
+ }
291
+
292
+ if (request.body.api_type === TEXTGEN_TYPES.INFERMATICAI) {
293
+ request.body = _.pickBy(request.body, (_, key) => INFERMATICAI_KEYS.includes(key));
294
+ args.body = JSON.stringify(request.body);
295
+ }
296
+
297
+ if (request.body.api_type === TEXTGEN_TYPES.FEATHERLESS) {
298
+ request.body = _.pickBy(request.body, (_, key) => FEATHERLESS_KEYS.includes(key));
299
+ args.body = JSON.stringify(request.body);
300
+ }
301
+
302
+ if (request.body.api_type === TEXTGEN_TYPES.DREAMGEN) {
303
+ request.body = _.pickBy(request.body, (_, key) => DREAMGEN_KEYS.includes(key));
304
+ // NOTE: DreamGen sometimes get confused by the unusual formatting in the character cards.
305
+ request.body.stop?.push('### User', '## User');
306
+ args.body = JSON.stringify(request.body);
307
+ }
308
+
309
+ if (request.body.api_type === TEXTGEN_TYPES.OPENROUTER) {
310
+ if (Array.isArray(request.body.provider) && request.body.provider.length > 0) {
311
+ request.body.provider = {
312
+ allow_fallbacks: request.body.allow_fallbacks ?? true,
313
+ order: request.body.provider,
314
+ };
315
+ } else {
316
+ delete request.body.provider;
317
+ }
318
+ request.body = _.pickBy(request.body, (_, key) => OPENROUTER_KEYS.includes(key));
319
+ args.body = JSON.stringify(request.body);
320
+ }
321
+
322
+ if (request.body.api_type === TEXTGEN_TYPES.VLLM) {
323
+ request.body = _.pickBy(request.body, (_, key) => VLLM_KEYS.includes(key));
324
+ args.body = JSON.stringify(request.body);
325
+ }
326
+
327
+ if (request.body.api_type === TEXTGEN_TYPES.OLLAMA) {
328
+ const keepAlive = getConfigValue('ollama.keepAlive', -1);
329
+ args.body = JSON.stringify({
330
+ model: request.body.model,
331
+ prompt: request.body.prompt,
332
+ stream: request.body.stream ?? false,
333
+ keep_alive: keepAlive,
334
+ raw: true,
335
+ options: _.pickBy(request.body, (_, key) => OLLAMA_KEYS.includes(key)),
336
+ });
337
+ }
338
+
339
+ if (request.body.api_type === TEXTGEN_TYPES.OLLAMA && request.body.stream) {
340
+ const stream = await fetch(url, args);
341
+ parseOllamaStream(stream, request, response);
342
+ } else if (request.body.stream) {
343
+ const completionsStream = await fetch(url, args);
344
+ // Pipe remote SSE stream to Express response
345
+ forwardFetchResponse(completionsStream, response);
346
+ }
347
+ else {
348
+ const completionsReply = await fetch(url, args);
349
+
350
+ if (completionsReply.ok) {
351
+ const data = await completionsReply.json();
352
+ console.log('Endpoint response:', data);
353
+
354
+ // Wrap legacy response to OAI completions format
355
+ if (request.body.legacy_api) {
356
+ const text = data?.results[0]?.text;
357
+ data['choices'] = [{ text }];
358
+ }
359
+
360
+ // Map InfermaticAI response to OAI completions format
361
+ if (apiType === TEXTGEN_TYPES.INFERMATICAI) {
362
+ data['choices'] = (data?.choices || []).map(choice => ({ text: choice?.message?.content || choice.text, logprobs: choice?.logprobs, index: choice?.index }));
363
+ }
364
+
365
+ return response.send(data);
366
+ } else {
367
+ const text = await completionsReply.text();
368
+ const errorBody = { error: true, status: completionsReply.status, response: text };
369
+
370
+ if (!response.headersSent) {
371
+ return response.send(errorBody);
372
+ }
373
+
374
+ return response.end();
375
+ }
376
+ }
377
+ } catch (error) {
378
+ let value = { error: true, status: error?.status, response: error?.statusText };
379
+ console.log('Endpoint error:', error);
380
+
381
+ if (!response.headersSent) {
382
+ return response.send(value);
383
+ }
384
+
385
+ return response.end();
386
+ }
387
+ });
388
+
389
+ const ollama = express.Router();
390
+
391
+ ollama.post('/download', jsonParser, async function (request, response) {
392
+ try {
393
+ if (!request.body.name || !request.body.api_server) return response.sendStatus(400);
394
+
395
+ const name = request.body.name;
396
+ const url = String(request.body.api_server).replace(/\/$/, '');
397
+
398
+ const fetchResponse = await fetch(`${url}/api/pull`, {
399
+ method: 'POST',
400
+ headers: { 'Content-Type': 'application/json' },
401
+ body: JSON.stringify({
402
+ name: name,
403
+ stream: false,
404
+ }),
405
+ timeout: 0,
406
+ });
407
+
408
+ if (!fetchResponse.ok) {
409
+ console.log('Download error:', fetchResponse.status, fetchResponse.statusText);
410
+ return response.status(fetchResponse.status).send({ error: true });
411
+ }
412
+
413
+ return response.send({ ok: true });
414
+ } catch (error) {
415
+ console.error(error);
416
+ return response.status(500);
417
+ }
418
+ });
419
+
420
+ ollama.post('/caption-image', jsonParser, async function (request, response) {
421
+ try {
422
+ if (!request.body.server_url || !request.body.model) {
423
+ return response.sendStatus(400);
424
+ }
425
+
426
+ console.log('Ollama caption request:', request.body);
427
+ const baseUrl = trimV1(request.body.server_url);
428
+
429
+ const fetchResponse = await fetch(`${baseUrl}/api/generate`, {
430
+ method: 'POST',
431
+ headers: { 'Content-Type': 'application/json' },
432
+ body: JSON.stringify({
433
+ model: request.body.model,
434
+ prompt: request.body.prompt,
435
+ images: [request.body.image],
436
+ stream: false,
437
+ }),
438
+ timeout: 0,
439
+ });
440
+
441
+ if (!fetchResponse.ok) {
442
+ console.log('Ollama caption error:', fetchResponse.status, fetchResponse.statusText);
443
+ return response.status(500).send({ error: true });
444
+ }
445
+
446
+ const data = await fetchResponse.json();
447
+ console.log('Ollama caption response:', data);
448
+
449
+ const caption = data?.response || '';
450
+
451
+ if (!caption) {
452
+ console.log('Ollama caption is empty.');
453
+ return response.status(500).send({ error: true });
454
+ }
455
+
456
+ return response.send({ caption });
457
+ } catch (error) {
458
+ console.error(error);
459
+ return response.status(500);
460
+ }
461
+ });
462
+
463
+ const llamacpp = express.Router();
464
+
465
+ llamacpp.post('/caption-image', jsonParser, async function (request, response) {
466
+ try {
467
+ if (!request.body.server_url) {
468
+ return response.sendStatus(400);
469
+ }
470
+
471
+ console.log('LlamaCpp caption request:', request.body);
472
+ const baseUrl = trimV1(request.body.server_url);
473
+
474
+ const fetchResponse = await fetch(`${baseUrl}/completion`, {
475
+ method: 'POST',
476
+ headers: { 'Content-Type': 'application/json' },
477
+ timeout: 0,
478
+ body: JSON.stringify({
479
+ prompt: `USER:[img-1]${String(request.body.prompt).trim()}\nASSISTANT:`,
480
+ image_data: [{ data: request.body.image, id: 1 }],
481
+ temperature: 0.1,
482
+ stream: false,
483
+ stop: ['USER:', '</s>'],
484
+ }),
485
+ });
486
+
487
+ if (!fetchResponse.ok) {
488
+ console.log('LlamaCpp caption error:', fetchResponse.status, fetchResponse.statusText);
489
+ return response.status(500).send({ error: true });
490
+ }
491
+
492
+ const data = await fetchResponse.json();
493
+ console.log('LlamaCpp caption response:', data);
494
+
495
+ const caption = data?.content || '';
496
+
497
+ if (!caption) {
498
+ console.log('LlamaCpp caption is empty.');
499
+ return response.status(500).send({ error: true });
500
+ }
501
+
502
+ return response.send({ caption });
503
+
504
+ } catch (error) {
505
+ console.error(error);
506
+ return response.status(500);
507
+ }
508
+ });
509
+
510
+ llamacpp.post('/props', jsonParser, async function (request, response) {
511
+ try {
512
+ if (!request.body.server_url) {
513
+ return response.sendStatus(400);
514
+ }
515
+
516
+ console.log('LlamaCpp props request:', request.body);
517
+ const baseUrl = trimV1(request.body.server_url);
518
+
519
+ const fetchResponse = await fetch(`${baseUrl}/props`, {
520
+ method: 'GET',
521
+ timeout: 0,
522
+ });
523
+
524
+ if (!fetchResponse.ok) {
525
+ console.log('LlamaCpp props error:', fetchResponse.status, fetchResponse.statusText);
526
+ return response.status(500).send({ error: true });
527
+ }
528
+
529
+ const data = await fetchResponse.json();
530
+ console.log('LlamaCpp props response:', data);
531
+
532
+ return response.send(data);
533
+
534
+ } catch (error) {
535
+ console.error(error);
536
+ return response.status(500);
537
+ }
538
+ });
539
+
540
+ llamacpp.post('/slots', jsonParser, async function (request, response) {
541
+ try {
542
+ if (!request.body.server_url) {
543
+ return response.sendStatus(400);
544
+ }
545
+ if (!/^(erase|info|restore|save)$/.test(request.body.action)) {
546
+ return response.sendStatus(400);
547
+ }
548
+
549
+ console.log('LlamaCpp slots request:', request.body);
550
+ const baseUrl = trimV1(request.body.server_url);
551
+
552
+ let fetchResponse;
553
+ if (request.body.action === 'info') {
554
+ fetchResponse = await fetch(`${baseUrl}/slots`, {
555
+ method: 'GET',
556
+ timeout: 0,
557
+ });
558
+ } else {
559
+ if (!/^\d+$/.test(request.body.id_slot)) {
560
+ return response.sendStatus(400);
561
+ }
562
+ if (request.body.action !== 'erase' && !request.body.filename) {
563
+ return response.sendStatus(400);
564
+ }
565
+
566
+ fetchResponse = await fetch(`${baseUrl}/slots/${request.body.id_slot}?action=${request.body.action}`, {
567
+ method: 'POST',
568
+ headers: { 'Content-Type': 'application/json' },
569
+ timeout: 0,
570
+ body: JSON.stringify({
571
+ filename: request.body.action !== 'erase' ? `${request.body.filename}` : undefined,
572
+ }),
573
+ });
574
+ }
575
+
576
+ if (!fetchResponse.ok) {
577
+ console.log('LlamaCpp slots error:', fetchResponse.status, fetchResponse.statusText);
578
+ return response.status(500).send({ error: true });
579
+ }
580
+
581
+ const data = await fetchResponse.json();
582
+ console.log('LlamaCpp slots response:', data);
583
+
584
+ return response.send(data);
585
+
586
+ } catch (error) {
587
+ console.error(error);
588
+ return response.status(500);
589
+ }
590
+ });
591
+
592
+ const tabby = express.Router();
593
+
594
+ tabby.post('/download', jsonParser, async function (request, response) {
595
+ try {
596
+ const baseUrl = String(request.body.api_server).replace(/\/$/, '');
597
+
598
+ const args = {
599
+ method: 'POST',
600
+ headers: { 'Content-Type': 'application/json' },
601
+ body: JSON.stringify(request.body),
602
+ timeout: 0,
603
+ };
604
+
605
+ setAdditionalHeaders(request, args, baseUrl);
606
+
607
+ // Check key permissions
608
+ const permissionResponse = await fetch(`${baseUrl}/v1/auth/permission`, {
609
+ headers: args.headers,
610
+ });
611
+
612
+ if (permissionResponse.ok) {
613
+ const permissionJson = await permissionResponse.json();
614
+
615
+ if (permissionJson['permission'] !== 'admin') {
616
+ return response.status(403).send({ error: true });
617
+ }
618
+ } else {
619
+ console.log('API Permission error:', permissionResponse.status, permissionResponse.statusText);
620
+ return response.status(permissionResponse.status).send({ error: true });
621
+ }
622
+
623
+ const fetchResponse = await fetch(`${baseUrl}/v1/download`, args);
624
+
625
+ if (!fetchResponse.ok) {
626
+ console.log('Download error:', fetchResponse.status, fetchResponse.statusText);
627
+ return response.status(fetchResponse.status).send({ error: true });
628
+ }
629
+
630
+ return response.send({ ok: true });
631
+ } catch (error) {
632
+ console.error(error);
633
+ return response.status(500);
634
+ }
635
+ });
636
+
637
+ router.use('/ollama', ollama);
638
+ router.use('/llamacpp', llamacpp);
639
+ router.use('/tabby', tabby);
640
+
641
+ module.exports = { router };
src/endpoints/backgrounds.js ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const express = require('express');
4
+ const sanitize = require('sanitize-filename');
5
+
6
+ const { jsonParser, urlencodedParser } = require('../express-common');
7
+ const { invalidateThumbnail } = require('./thumbnails');
8
+ const { getImages } = require('../util');
9
+
10
+ const router = express.Router();
11
+
12
+ router.post('/all', jsonParser, function (request, response) {
13
+ var images = getImages(request.user.directories.backgrounds);
14
+ response.send(JSON.stringify(images));
15
+ });
16
+
17
+ router.post('/delete', jsonParser, function (request, response) {
18
+ if (!request.body) return response.sendStatus(400);
19
+
20
+ if (request.body.bg !== sanitize(request.body.bg)) {
21
+ console.error('Malicious bg name prevented');
22
+ return response.sendStatus(403);
23
+ }
24
+
25
+ const fileName = path.join(request.user.directories.backgrounds, sanitize(request.body.bg));
26
+
27
+ if (!fs.existsSync(fileName)) {
28
+ console.log('BG file not found');
29
+ return response.sendStatus(400);
30
+ }
31
+
32
+ fs.rmSync(fileName);
33
+ invalidateThumbnail(request.user.directories, 'bg', request.body.bg);
34
+ return response.send('ok');
35
+ });
36
+
37
+ router.post('/rename', jsonParser, function (request, response) {
38
+ if (!request.body) return response.sendStatus(400);
39
+
40
+ const oldFileName = path.join(request.user.directories.backgrounds, sanitize(request.body.old_bg));
41
+ const newFileName = path.join(request.user.directories.backgrounds, sanitize(request.body.new_bg));
42
+
43
+ if (!fs.existsSync(oldFileName)) {
44
+ console.log('BG file not found');
45
+ return response.sendStatus(400);
46
+ }
47
+
48
+ if (fs.existsSync(newFileName)) {
49
+ console.log('New BG file already exists');
50
+ return response.sendStatus(400);
51
+ }
52
+
53
+ fs.copyFileSync(oldFileName, newFileName);
54
+ fs.rmSync(oldFileName);
55
+ invalidateThumbnail(request.user.directories, 'bg', request.body.old_bg);
56
+ return response.send('ok');
57
+ });
58
+
59
+ router.post('/upload', urlencodedParser, function (request, response) {
60
+ if (!request.body || !request.file) return response.sendStatus(400);
61
+
62
+ const img_path = path.join(request.file.destination, request.file.filename);
63
+ const filename = request.file.originalname;
64
+
65
+ try {
66
+ fs.copyFileSync(img_path, path.join(request.user.directories.backgrounds, filename));
67
+ fs.rmSync(img_path);
68
+ invalidateThumbnail(request.user.directories, 'bg', filename);
69
+ response.send(filename);
70
+ } catch (err) {
71
+ console.error(err);
72
+ response.sendStatus(500);
73
+ }
74
+ });
75
+
76
+ module.exports = { router };
src/endpoints/caption.js ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const { jsonParser } = require('../express-common');
3
+
4
+ const TASK = 'image-to-text';
5
+
6
+ const router = express.Router();
7
+
8
+ router.post('/', jsonParser, async (req, res) => {
9
+ try {
10
+ const { image } = req.body;
11
+
12
+ const module = await import('../transformers.mjs');
13
+ const rawImage = await module.default.getRawImage(image);
14
+
15
+ if (!rawImage) {
16
+ console.log('Failed to parse captioned image');
17
+ return res.sendStatus(400);
18
+ }
19
+
20
+ const pipe = await module.default.getPipeline(TASK);
21
+ const result = await pipe(rawImage);
22
+ const text = result[0].generated_text;
23
+ console.log('Image caption:', text);
24
+
25
+ return res.json({ caption: text });
26
+ } catch (error) {
27
+ console.error(error);
28
+ return res.sendStatus(500);
29
+ }
30
+ });
31
+
32
+ module.exports = { router };
src/endpoints/characters.js ADDED
@@ -0,0 +1,1230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const fsPromises = require('fs').promises;
4
+ const readline = require('readline');
5
+ const express = require('express');
6
+ const sanitize = require('sanitize-filename');
7
+ const writeFileAtomicSync = require('write-file-atomic').sync;
8
+ const yaml = require('yaml');
9
+ const _ = require('lodash');
10
+ const mime = require('mime-types');
11
+
12
+ const jimp = require('jimp');
13
+
14
+ const { AVATAR_WIDTH, AVATAR_HEIGHT } = require('../constants');
15
+ const { jsonParser, urlencodedParser } = require('../express-common');
16
+ const { deepMerge, humanizedISO8601DateTime, tryParse, extractFileFromZipBuffer } = require('../util');
17
+ const { TavernCardValidator } = require('../validator/TavernCardValidator');
18
+ const characterCardParser = require('../character-card-parser.js');
19
+ const { readWorldInfoFile } = require('./worldinfo');
20
+ const { invalidateThumbnail } = require('./thumbnails');
21
+ const { importRisuSprites } = require('./sprites');
22
+ const defaultAvatarPath = './public/img/ai4.png';
23
+
24
+ // KV-store for parsed character data
25
+ const characterDataCache = new Map();
26
+
27
+ /**
28
+ * Reads the character card from the specified image file.
29
+ * @param {string} inputFile - Path to the image file
30
+ * @param {string} inputFormat - 'png'
31
+ * @returns {Promise<string | undefined>} - Character card data
32
+ */
33
+ async function readCharacterData(inputFile, inputFormat = 'png') {
34
+ const stat = fs.statSync(inputFile);
35
+ const cacheKey = `${inputFile}-${stat.mtimeMs}`;
36
+ if (characterDataCache.has(cacheKey)) {
37
+ return characterDataCache.get(cacheKey);
38
+ }
39
+
40
+ const result = characterCardParser.parse(inputFile, inputFormat);
41
+ characterDataCache.set(cacheKey, result);
42
+ return result;
43
+ }
44
+
45
+ /**
46
+ * Writes the character card to the specified image file.
47
+ * @param {string|Buffer} inputFile - Path to the image file or image buffer
48
+ * @param {string} data - Character card data
49
+ * @param {string} outputFile - Target image file name
50
+ * @param {import('express').Request} request - Express request obejct
51
+ * @param {Crop|undefined} crop - Crop parameters
52
+ * @returns {Promise<boolean>} - True if the operation was successful
53
+ */
54
+ async function writeCharacterData(inputFile, data, outputFile, request, crop = undefined) {
55
+ try {
56
+ // Reset the cache
57
+ for (const key of characterDataCache.keys()) {
58
+ if (key.startsWith(inputFile)) {
59
+ characterDataCache.delete(key);
60
+ break;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Read the image, resize, and save it as a PNG into the buffer.
66
+ * @returns {Promise<Buffer>} Image buffer
67
+ */
68
+ function getInputImage() {
69
+ if (Buffer.isBuffer(inputFile)) {
70
+ return parseImageBuffer(inputFile, crop);
71
+ }
72
+
73
+ return tryReadImage(inputFile, crop);
74
+ }
75
+
76
+ const inputImage = await getInputImage();
77
+
78
+ // Get the chunks
79
+ const outputImage = characterCardParser.write(inputImage, data);
80
+ const outputImagePath = path.join(request.user.directories.characters, `${outputFile}.png`);
81
+
82
+ writeFileAtomicSync(outputImagePath, outputImage);
83
+ return true;
84
+ } catch (err) {
85
+ console.log(err);
86
+ return false;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * @typedef {Object} Crop
92
+ * @property {number} x X-coordinate
93
+ * @property {number} y Y-coordinate
94
+ * @property {number} width Width
95
+ * @property {number} height Height
96
+ * @property {boolean} want_resize Resize the image to the standard avatar size
97
+ */
98
+
99
+ /**
100
+ * Parses an image buffer and applies crop if defined.
101
+ * @param {Buffer} buffer Buffer of the image
102
+ * @param {Crop|undefined} [crop] Crop parameters
103
+ * @returns {Promise<Buffer>} Image buffer
104
+ */
105
+ async function parseImageBuffer(buffer, crop) {
106
+ const image = await jimp.read(buffer);
107
+ let finalWidth = image.bitmap.width, finalHeight = image.bitmap.height;
108
+
109
+ // Apply crop if defined
110
+ if (typeof crop == 'object' && [crop.x, crop.y, crop.width, crop.height].every(x => typeof x === 'number')) {
111
+ image.crop(crop.x, crop.y, crop.width, crop.height);
112
+ // Apply standard resize if requested
113
+ if (crop.want_resize) {
114
+ finalWidth = AVATAR_WIDTH;
115
+ finalHeight = AVATAR_HEIGHT;
116
+ } else {
117
+ finalWidth = crop.width;
118
+ finalHeight = crop.height;
119
+ }
120
+ }
121
+
122
+ return image.cover(finalWidth, finalHeight).getBufferAsync(jimp.MIME_PNG);
123
+ }
124
+
125
+ /**
126
+ * Reads an image file and applies crop if defined.
127
+ * @param {string} imgPath Path to the image file
128
+ * @param {Crop|undefined} crop Crop parameters
129
+ * @returns {Promise<Buffer>} Image buffer
130
+ */
131
+ async function tryReadImage(imgPath, crop) {
132
+ try {
133
+ let rawImg = await jimp.read(imgPath);
134
+ let finalWidth = rawImg.bitmap.width, finalHeight = rawImg.bitmap.height;
135
+
136
+ // Apply crop if defined
137
+ if (typeof crop == 'object' && [crop.x, crop.y, crop.width, crop.height].every(x => typeof x === 'number')) {
138
+ rawImg = rawImg.crop(crop.x, crop.y, crop.width, crop.height);
139
+ // Apply standard resize if requested
140
+ if (crop.want_resize) {
141
+ finalWidth = AVATAR_WIDTH;
142
+ finalHeight = AVATAR_HEIGHT;
143
+ } else {
144
+ finalWidth = crop.width;
145
+ finalHeight = crop.height;
146
+ }
147
+ }
148
+
149
+ const image = await rawImg.cover(finalWidth, finalHeight).getBufferAsync(jimp.MIME_PNG);
150
+ return image;
151
+ }
152
+ // If it's an unsupported type of image (APNG) - just read the file as buffer
153
+ catch {
154
+ return fs.readFileSync(imgPath);
155
+ }
156
+ }
157
+
158
+ /**
159
+ * calculateChatSize - Calculates the total chat size for a given character.
160
+ *
161
+ * @param {string} charDir The directory where the chats are stored.
162
+ * @return { {chatSize: number, dateLastChat: number} } The total chat size.
163
+ */
164
+ const calculateChatSize = (charDir) => {
165
+ let chatSize = 0;
166
+ let dateLastChat = 0;
167
+
168
+ if (fs.existsSync(charDir)) {
169
+ const chats = fs.readdirSync(charDir);
170
+ if (Array.isArray(chats) && chats.length) {
171
+ for (const chat of chats) {
172
+ const chatStat = fs.statSync(path.join(charDir, chat));
173
+ chatSize += chatStat.size;
174
+ dateLastChat = Math.max(dateLastChat, chatStat.mtimeMs);
175
+ }
176
+ }
177
+ }
178
+
179
+ return { chatSize, dateLastChat };
180
+ };
181
+
182
+ // Calculate the total string length of the data object
183
+ const calculateDataSize = (data) => {
184
+ return typeof data === 'object' ? Object.values(data).reduce((acc, val) => acc + new String(val).length, 0) : 0;
185
+ };
186
+
187
+ /**
188
+ * processCharacter - Process a given character, read its data and calculate its statistics.
189
+ *
190
+ * @param {string} item The name of the character.
191
+ * @param {import('../users').UserDirectoryList} directories User directories
192
+ * @return {Promise<object>} A Promise that resolves when the character processing is done.
193
+ */
194
+ const processCharacter = async (item, directories) => {
195
+ try {
196
+ const imgFile = path.join(directories.characters, item);
197
+ const imgData = await readCharacterData(imgFile);
198
+ if (imgData === undefined) throw new Error('Failed to read character file');
199
+
200
+ let jsonObject = getCharaCardV2(JSON.parse(imgData), directories, false);
201
+ jsonObject.avatar = item;
202
+ const character = jsonObject;
203
+ character['json_data'] = imgData;
204
+ const charStat = fs.statSync(path.join(directories.characters, item));
205
+ character['date_added'] = charStat.ctimeMs;
206
+ character['create_date'] = jsonObject['create_date'] || humanizedISO8601DateTime(charStat.ctimeMs);
207
+ const chatsDirectory = path.join(directories.chats, item.replace('.png', ''));
208
+
209
+ const { chatSize, dateLastChat } = calculateChatSize(chatsDirectory);
210
+ character['chat_size'] = chatSize;
211
+ character['date_last_chat'] = dateLastChat;
212
+ character['data_size'] = calculateDataSize(jsonObject?.data);
213
+ return character;
214
+ }
215
+ catch (err) {
216
+ console.log(`Could not process character: ${item}`);
217
+
218
+ if (err instanceof SyntaxError) {
219
+ console.log(`${item} does not contain a valid JSON object.`);
220
+ } else {
221
+ console.log('An unexpected error occurred: ', err);
222
+ }
223
+
224
+ return {
225
+ date_added: 0,
226
+ date_last_chat: 0,
227
+ chat_size: 0,
228
+ };
229
+ }
230
+ };
231
+
232
+ /**
233
+ * Convert a character object to Spec V2 format.
234
+ * @param {object} jsonObject Character object
235
+ * @param {import('../users').UserDirectoryList} directories User directories
236
+ * @param {boolean} hoistDate Will set the chat and create_date fields to the current date if they are missing
237
+ * @returns {object} Character object in Spec V2 format
238
+ */
239
+ function getCharaCardV2(jsonObject, directories, hoistDate = true) {
240
+ if (jsonObject.spec === undefined) {
241
+ jsonObject = convertToV2(jsonObject, directories);
242
+
243
+ if (hoistDate && !jsonObject.create_date) {
244
+ jsonObject.create_date = humanizedISO8601DateTime();
245
+ }
246
+ } else {
247
+ jsonObject = readFromV2(jsonObject);
248
+ }
249
+ return jsonObject;
250
+ }
251
+
252
+ /**
253
+ * Convert a character object to Spec V2 format.
254
+ * @param {object} char Character object
255
+ * @param {import('../users').UserDirectoryList} directories User directories
256
+ * @returns {object} Character object in Spec V2 format
257
+ */
258
+ function convertToV2(char, directories) {
259
+ // Simulate incoming data from frontend form
260
+ const result = charaFormatData({
261
+ json_data: JSON.stringify(char),
262
+ ch_name: char.name,
263
+ description: char.description,
264
+ personality: char.personality,
265
+ scenario: char.scenario,
266
+ first_mes: char.first_mes,
267
+ mes_example: char.mes_example,
268
+ creator_notes: char.creatorcomment,
269
+ talkativeness: char.talkativeness,
270
+ fav: char.fav,
271
+ creator: char.creator,
272
+ tags: char.tags,
273
+ depth_prompt_prompt: char.depth_prompt_prompt,
274
+ depth_prompt_depth: char.depth_prompt_depth,
275
+ depth_prompt_role: char.depth_prompt_role,
276
+ }, directories);
277
+
278
+ result.chat = char.chat ?? humanizedISO8601DateTime();
279
+ result.create_date = char.create_date;
280
+
281
+ return result;
282
+ }
283
+
284
+
285
+ function unsetFavFlag(char) {
286
+ _.set(char, 'fav', false);
287
+ _.set(char, 'data.extensions.fav', false);
288
+ }
289
+
290
+ function readFromV2(char) {
291
+ if (_.isUndefined(char.data)) {
292
+ console.warn(`Char ${char['name']} has Spec v2 data missing`);
293
+ return char;
294
+ }
295
+
296
+ const fieldMappings = {
297
+ name: 'name',
298
+ description: 'description',
299
+ personality: 'personality',
300
+ scenario: 'scenario',
301
+ first_mes: 'first_mes',
302
+ mes_example: 'mes_example',
303
+ talkativeness: 'extensions.talkativeness',
304
+ fav: 'extensions.fav',
305
+ tags: 'tags',
306
+ };
307
+
308
+ _.forEach(fieldMappings, (v2Path, charField) => {
309
+ //console.log(`Migrating field: ${charField} from ${v2Path}`);
310
+ const v2Value = _.get(char.data, v2Path);
311
+ if (_.isUndefined(v2Value)) {
312
+ let defaultValue = undefined;
313
+
314
+ // Backfill default values for missing ST extension fields
315
+ if (v2Path === 'extensions.talkativeness') {
316
+ defaultValue = 0.5;
317
+ }
318
+
319
+ if (v2Path === 'extensions.fav') {
320
+ defaultValue = false;
321
+ }
322
+
323
+ if (!_.isUndefined(defaultValue)) {
324
+ //console.debug(`Spec v2 extension data missing for field: ${charField}, using default value: ${defaultValue}`);
325
+ char[charField] = defaultValue;
326
+ } else {
327
+ console.debug(`Char ${char['name']} has Spec v2 data missing for unknown field: ${charField}`);
328
+ return;
329
+ }
330
+ }
331
+ if (!_.isUndefined(char[charField]) && !_.isUndefined(v2Value) && String(char[charField]) !== String(v2Value)) {
332
+ console.debug(`Char ${char['name']} has Spec v2 data mismatch with Spec v1 for field: ${charField}`, char[charField], v2Value);
333
+ }
334
+ char[charField] = v2Value;
335
+ });
336
+
337
+ char['chat'] = char['chat'] ?? humanizedISO8601DateTime();
338
+
339
+ return char;
340
+ }
341
+
342
+ /**
343
+ * Format character data to Spec V2 format.
344
+ * @param {object} data Character data
345
+ * @param {import('../users').UserDirectoryList} directories User directories
346
+ * @returns
347
+ */
348
+ function charaFormatData(data, directories) {
349
+ // This is supposed to save all the foreign keys that ST doesn't care about
350
+ const char = tryParse(data.json_data) || {};
351
+
352
+ // Checks if data.alternate_greetings is an array, a string, or neither, and acts accordingly. (expected to be an array of strings)
353
+ const getAlternateGreetings = data => {
354
+ if (Array.isArray(data.alternate_greetings)) return data.alternate_greetings;
355
+ if (typeof data.alternate_greetings === 'string') return [data.alternate_greetings];
356
+ return [];
357
+ };
358
+
359
+ // Spec V1 fields
360
+ _.set(char, 'name', data.ch_name);
361
+ _.set(char, 'description', data.description || '');
362
+ _.set(char, 'personality', data.personality || '');
363
+ _.set(char, 'scenario', data.scenario || '');
364
+ _.set(char, 'first_mes', data.first_mes || '');
365
+ _.set(char, 'mes_example', data.mes_example || '');
366
+
367
+ // Old ST extension fields (for backward compatibility, will be deprecated)
368
+ _.set(char, 'creatorcomment', data.creator_notes);
369
+ _.set(char, 'avatar', 'none');
370
+ _.set(char, 'chat', data.ch_name + ' - ' + humanizedISO8601DateTime());
371
+ _.set(char, 'talkativeness', data.talkativeness);
372
+ _.set(char, 'fav', data.fav == 'true');
373
+ _.set(char, 'tags', typeof data.tags == 'string' ? (data.tags.split(',').map(x => x.trim()).filter(x => x)) : data.tags || []);
374
+
375
+ // Spec V2 fields
376
+ _.set(char, 'spec', 'chara_card_v2');
377
+ _.set(char, 'spec_version', '2.0');
378
+ _.set(char, 'data.name', data.ch_name);
379
+ _.set(char, 'data.description', data.description || '');
380
+ _.set(char, 'data.personality', data.personality || '');
381
+ _.set(char, 'data.scenario', data.scenario || '');
382
+ _.set(char, 'data.first_mes', data.first_mes || '');
383
+ _.set(char, 'data.mes_example', data.mes_example || '');
384
+
385
+ // New V2 fields
386
+ _.set(char, 'data.creator_notes', data.creator_notes || '');
387
+ _.set(char, 'data.system_prompt', data.system_prompt || '');
388
+ _.set(char, 'data.post_history_instructions', data.post_history_instructions || '');
389
+ _.set(char, 'data.tags', typeof data.tags == 'string' ? (data.tags.split(',').map(x => x.trim()).filter(x => x)) : data.tags || []);
390
+ _.set(char, 'data.creator', data.creator || '');
391
+ _.set(char, 'data.character_version', data.character_version || '');
392
+ _.set(char, 'data.alternate_greetings', getAlternateGreetings(data));
393
+
394
+ // ST extension fields to V2 object
395
+ _.set(char, 'data.extensions.talkativeness', data.talkativeness);
396
+ _.set(char, 'data.extensions.fav', data.fav == 'true');
397
+ _.set(char, 'data.extensions.world', data.world || '');
398
+
399
+ // Spec extension: depth prompt
400
+ const depth_default = 4;
401
+ const role_default = 'system';
402
+ const depth_value = !isNaN(Number(data.depth_prompt_depth)) ? Number(data.depth_prompt_depth) : depth_default;
403
+ const role_value = data.depth_prompt_role ?? role_default;
404
+ _.set(char, 'data.extensions.depth_prompt.prompt', data.depth_prompt_prompt ?? '');
405
+ _.set(char, 'data.extensions.depth_prompt.depth', depth_value);
406
+ _.set(char, 'data.extensions.depth_prompt.role', role_value);
407
+ //_.set(char, 'data.extensions.create_date', humanizedISO8601DateTime());
408
+ //_.set(char, 'data.extensions.avatar', 'none');
409
+ //_.set(char, 'data.extensions.chat', data.ch_name + ' - ' + humanizedISO8601DateTime());
410
+
411
+ // V3 fields
412
+ _.set(char, 'data.group_only_greetings', data.group_only_greetings ?? []);
413
+
414
+ if (data.world) {
415
+ try {
416
+ const file = readWorldInfoFile(directories, data.world, false);
417
+
418
+ // File was imported - save it to the character book
419
+ if (file && file.originalData) {
420
+ _.set(char, 'data.character_book', file.originalData);
421
+ }
422
+
423
+ // File was not imported - convert the world info to the character book
424
+ if (file && file.entries) {
425
+ _.set(char, 'data.character_book', convertWorldInfoToCharacterBook(data.world, file.entries));
426
+ }
427
+
428
+ } catch {
429
+ console.debug(`Failed to read world info file: ${data.world}. Character book will not be available.`);
430
+ }
431
+ }
432
+
433
+ if (data.extensions) {
434
+ try {
435
+ const extensions = JSON.parse(data.extensions);
436
+ // Deep merge the extensions object
437
+ _.set(char, 'data.extensions', deepMerge(char.data.extensions, extensions));
438
+ } catch {
439
+ console.debug(`Failed to parse extensions JSON: ${data.extensions}`);
440
+ }
441
+ }
442
+
443
+ return char;
444
+ }
445
+
446
+ /**
447
+ * @param {string} name Name of World Info file
448
+ * @param {object} entries Entries object
449
+ */
450
+ function convertWorldInfoToCharacterBook(name, entries) {
451
+ /** @type {{ entries: object[]; name: string }} */
452
+ const result = { entries: [], name };
453
+
454
+ for (const index in entries) {
455
+ const entry = entries[index];
456
+
457
+ const originalEntry = {
458
+ id: entry.uid,
459
+ keys: entry.key,
460
+ secondary_keys: entry.keysecondary,
461
+ comment: entry.comment,
462
+ content: entry.content,
463
+ constant: entry.constant,
464
+ selective: entry.selective,
465
+ insertion_order: entry.order,
466
+ enabled: !entry.disable,
467
+ position: entry.position == 0 ? 'before_char' : 'after_char',
468
+ use_regex: true, // ST keys are always regex
469
+ extensions: {
470
+ position: entry.position,
471
+ exclude_recursion: entry.excludeRecursion,
472
+ display_index: entry.displayIndex,
473
+ probability: entry.probability ?? null,
474
+ useProbability: entry.useProbability ?? false,
475
+ depth: entry.depth ?? 4,
476
+ selectiveLogic: entry.selectiveLogic ?? 0,
477
+ group: entry.group ?? '',
478
+ group_override: entry.groupOverride ?? false,
479
+ group_weight: entry.groupWeight ?? null,
480
+ prevent_recursion: entry.preventRecursion ?? false,
481
+ delay_until_recursion: entry.delayUntilRecursion ?? false,
482
+ scan_depth: entry.scanDepth ?? null,
483
+ match_whole_words: entry.matchWholeWords ?? null,
484
+ use_group_scoring: entry.useGroupScoring ?? false,
485
+ case_sensitive: entry.caseSensitive ?? null,
486
+ automation_id: entry.automationId ?? '',
487
+ role: entry.role ?? 0,
488
+ vectorized: entry.vectorized ?? false,
489
+ sticky: entry.sticky ?? null,
490
+ cooldown: entry.cooldown ?? null,
491
+ delay: entry.delay ?? null,
492
+ },
493
+ };
494
+
495
+ result.entries.push(originalEntry);
496
+ }
497
+
498
+ return result;
499
+ }
500
+
501
+ /**
502
+ * Import a character from a YAML file.
503
+ * @param {string} uploadPath Path to the uploaded file
504
+ * @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects
505
+ * @param {string|undefined} preservedFileName Preserved file name
506
+ * @returns {Promise<string>} Internal name of the character
507
+ */
508
+ async function importFromYaml(uploadPath, context, preservedFileName) {
509
+ const fileText = fs.readFileSync(uploadPath, 'utf8');
510
+ fs.rmSync(uploadPath);
511
+ const yamlData = yaml.parse(fileText);
512
+ console.log('Importing from YAML');
513
+ yamlData.name = sanitize(yamlData.name);
514
+ const fileName = preservedFileName || getPngName(yamlData.name, context.request.user.directories);
515
+ let char = convertToV2({
516
+ 'name': yamlData.name,
517
+ 'description': yamlData.context ?? '',
518
+ 'first_mes': yamlData.greeting ?? '',
519
+ 'create_date': humanizedISO8601DateTime(),
520
+ 'chat': `${yamlData.name} - ${humanizedISO8601DateTime()}`,
521
+ 'personality': '',
522
+ 'creatorcomment': '',
523
+ 'avatar': 'none',
524
+ 'mes_example': '',
525
+ 'scenario': '',
526
+ 'talkativeness': 0.5,
527
+ 'creator': '',
528
+ 'tags': '',
529
+ }, context.request.user.directories);
530
+ const result = await writeCharacterData(defaultAvatarPath, JSON.stringify(char), fileName, context.request);
531
+ return result ? fileName : '';
532
+ }
533
+
534
+ /**
535
+ * Imports a character card from CharX (ZIP) file.
536
+ * @param {string} uploadPath
537
+ * @param {object} params
538
+ * @param {import('express').Request} params.request
539
+ * @param {string|undefined} preservedFileName Preserved file name
540
+ * @returns {Promise<string>} Internal name of the character
541
+ */
542
+ async function importFromCharX(uploadPath, { request }, preservedFileName) {
543
+ const data = fs.readFileSync(uploadPath);
544
+ fs.rmSync(uploadPath);
545
+ console.log('Importing from CharX');
546
+ const cardBuffer = await extractFileFromZipBuffer(data, 'card.json');
547
+
548
+ if (!cardBuffer) {
549
+ throw new Error('Failed to extract card.json from CharX file');
550
+ }
551
+
552
+ const card = readFromV2(JSON.parse(cardBuffer.toString()));
553
+
554
+ if (card.spec === undefined) {
555
+ throw new Error('Invalid CharX card file: missing spec field');
556
+ }
557
+
558
+ /** @type {string|Buffer} */
559
+ let avatar = defaultAvatarPath;
560
+ const assets = _.get(card, 'data.assets');
561
+ if (Array.isArray(assets) && assets.length) {
562
+ for (const asset of assets.filter(x => x.type === 'icon' && typeof x.uri === 'string')) {
563
+ const pathNoProtocol = String(asset.uri.replace(/^(?:\/\/|[^/]+)*\//, ''));
564
+ const buffer = await extractFileFromZipBuffer(data, pathNoProtocol);
565
+ if (buffer) {
566
+ avatar = buffer;
567
+ break;
568
+ }
569
+ }
570
+ }
571
+
572
+ unsetFavFlag(card);
573
+ card['create_date'] = humanizedISO8601DateTime();
574
+ card.name = sanitize(card.name);
575
+ const fileName = preservedFileName || getPngName(card.name, request.user.directories);
576
+ const result = await writeCharacterData(avatar, JSON.stringify(card), fileName, request);
577
+ return result ? fileName : '';
578
+ }
579
+
580
+ /**
581
+ * Import a character from a JSON file.
582
+ * @param {string} uploadPath Path to the uploaded file
583
+ * @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects
584
+ * @param {string|undefined} preservedFileName Preserved file name
585
+ * @returns {Promise<string>} Internal name of the character
586
+ */
587
+ async function importFromJson(uploadPath, { request }, preservedFileName) {
588
+ const data = fs.readFileSync(uploadPath, 'utf8');
589
+ fs.unlinkSync(uploadPath);
590
+
591
+ let jsonData = JSON.parse(data);
592
+
593
+ if (jsonData.spec !== undefined) {
594
+ console.log(`Importing from ${jsonData.spec} json`);
595
+ importRisuSprites(request.user.directories, jsonData);
596
+ unsetFavFlag(jsonData);
597
+ jsonData = readFromV2(jsonData);
598
+ jsonData['create_date'] = humanizedISO8601DateTime();
599
+ const pngName = preservedFileName || getPngName(jsonData.data?.name || jsonData.name, request.user.directories);
600
+ const char = JSON.stringify(jsonData);
601
+ const result = await writeCharacterData(defaultAvatarPath, char, pngName, request);
602
+ return result ? pngName : '';
603
+ } else if (jsonData.name !== undefined) {
604
+ console.log('Importing from v1 json');
605
+ jsonData.name = sanitize(jsonData.name);
606
+ if (jsonData.creator_notes) {
607
+ jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', '');
608
+ }
609
+ const pngName = preservedFileName || getPngName(jsonData.name, request.user.directories);
610
+ let char = {
611
+ 'name': jsonData.name,
612
+ 'description': jsonData.description ?? '',
613
+ 'creatorcomment': jsonData.creatorcomment ?? jsonData.creator_notes ?? '',
614
+ 'personality': jsonData.personality ?? '',
615
+ 'first_mes': jsonData.first_mes ?? '',
616
+ 'avatar': 'none',
617
+ 'chat': jsonData.name + ' - ' + humanizedISO8601DateTime(),
618
+ 'mes_example': jsonData.mes_example ?? '',
619
+ 'scenario': jsonData.scenario ?? '',
620
+ 'create_date': humanizedISO8601DateTime(),
621
+ 'talkativeness': jsonData.talkativeness ?? 0.5,
622
+ 'creator': jsonData.creator ?? '',
623
+ 'tags': jsonData.tags ?? '',
624
+ };
625
+ char = convertToV2(char, request.user.directories);
626
+ let charJSON = JSON.stringify(char);
627
+ const result = await writeCharacterData(defaultAvatarPath, charJSON, pngName, request);
628
+ return result ? pngName : '';
629
+ } else if (jsonData.char_name !== undefined) {//json Pygmalion notepad
630
+ console.log('Importing from gradio json');
631
+ jsonData.char_name = sanitize(jsonData.char_name);
632
+ if (jsonData.creator_notes) {
633
+ jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', '');
634
+ }
635
+ const pngName = preservedFileName || getPngName(jsonData.char_name, request.user.directories);
636
+ let char = {
637
+ 'name': jsonData.char_name,
638
+ 'description': jsonData.char_persona ?? '',
639
+ 'creatorcomment': jsonData.creatorcomment ?? jsonData.creator_notes ?? '',
640
+ 'personality': '',
641
+ 'first_mes': jsonData.char_greeting ?? '',
642
+ 'avatar': 'none',
643
+ 'chat': jsonData.name + ' - ' + humanizedISO8601DateTime(),
644
+ 'mes_example': jsonData.example_dialogue ?? '',
645
+ 'scenario': jsonData.world_scenario ?? '',
646
+ 'create_date': humanizedISO8601DateTime(),
647
+ 'talkativeness': jsonData.talkativeness ?? 0.5,
648
+ 'creator': jsonData.creator ?? '',
649
+ 'tags': jsonData.tags ?? '',
650
+ };
651
+ char = convertToV2(char, request.user.directories);
652
+ const charJSON = JSON.stringify(char);
653
+ const result = await writeCharacterData(defaultAvatarPath, charJSON, pngName, request);
654
+ return result ? pngName : '';
655
+ }
656
+
657
+ return '';
658
+ }
659
+
660
+ /**
661
+ * Import a character from a PNG file.
662
+ * @param {string} uploadPath Path to the uploaded file
663
+ * @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects
664
+ * @param {string|undefined} preservedFileName Preserved file name
665
+ * @returns {Promise<string>} Internal name of the character
666
+ */
667
+ async function importFromPng(uploadPath, { request }, preservedFileName) {
668
+ const imgData = await readCharacterData(uploadPath);
669
+ if (imgData === undefined) throw new Error('Failed to read character data');
670
+
671
+ let jsonData = JSON.parse(imgData);
672
+
673
+ jsonData.name = sanitize(jsonData.data?.name || jsonData.name);
674
+ const pngName = preservedFileName || getPngName(jsonData.name, request.user.directories);
675
+
676
+ if (jsonData.spec !== undefined) {
677
+ console.log(`Found a ${jsonData.spec} character file.`);
678
+ importRisuSprites(request.user.directories, jsonData);
679
+ unsetFavFlag(jsonData);
680
+ jsonData = readFromV2(jsonData);
681
+ jsonData['create_date'] = humanizedISO8601DateTime();
682
+ const char = JSON.stringify(jsonData);
683
+ const result = await writeCharacterData(uploadPath, char, pngName, request);
684
+ fs.unlinkSync(uploadPath);
685
+ return result ? pngName : '';
686
+ } else if (jsonData.name !== undefined) {
687
+ console.log('Found a v1 character file.');
688
+
689
+ if (jsonData.creator_notes) {
690
+ jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', '');
691
+ }
692
+
693
+ let char = {
694
+ 'name': jsonData.name,
695
+ 'description': jsonData.description ?? '',
696
+ 'creatorcomment': jsonData.creatorcomment ?? jsonData.creator_notes ?? '',
697
+ 'personality': jsonData.personality ?? '',
698
+ 'first_mes': jsonData.first_mes ?? '',
699
+ 'avatar': 'none',
700
+ 'chat': jsonData.name + ' - ' + humanizedISO8601DateTime(),
701
+ 'mes_example': jsonData.mes_example ?? '',
702
+ 'scenario': jsonData.scenario ?? '',
703
+ 'create_date': humanizedISO8601DateTime(),
704
+ 'talkativeness': jsonData.talkativeness ?? 0.5,
705
+ 'creator': jsonData.creator ?? '',
706
+ 'tags': jsonData.tags ?? '',
707
+ };
708
+ char = convertToV2(char, request.user.directories);
709
+ const charJSON = JSON.stringify(char);
710
+ const result = await writeCharacterData(uploadPath, charJSON, pngName, request);
711
+ fs.unlinkSync(uploadPath);
712
+ return result ? pngName : '';
713
+ }
714
+
715
+ return '';
716
+ }
717
+
718
+ const router = express.Router();
719
+
720
+ router.post('/create', urlencodedParser, async function (request, response) {
721
+ try {
722
+ if (!request.body) return response.sendStatus(400);
723
+
724
+ request.body.ch_name = sanitize(request.body.ch_name);
725
+
726
+ const char = JSON.stringify(charaFormatData(request.body, request.user.directories));
727
+ const internalName = getPngName(request.body.ch_name, request.user.directories);
728
+ const avatarName = `${internalName}.png`;
729
+ const defaultAvatar = './public/img/ai4.png';
730
+ const chatsPath = path.join(request.user.directories.chats, internalName);
731
+
732
+ if (!fs.existsSync(chatsPath)) fs.mkdirSync(chatsPath);
733
+
734
+ if (!request.file) {
735
+ await writeCharacterData(defaultAvatar, char, internalName, request);
736
+ return response.send(avatarName);
737
+ } else {
738
+ const crop = tryParse(request.query.crop);
739
+ const uploadPath = path.join(request.file.destination, request.file.filename);
740
+ await writeCharacterData(uploadPath, char, internalName, request, crop);
741
+ fs.unlinkSync(uploadPath);
742
+ return response.send(avatarName);
743
+ }
744
+ } catch (err) {
745
+ console.error(err);
746
+ response.sendStatus(500);
747
+ }
748
+ });
749
+
750
+ router.post('/rename', jsonParser, async function (request, response) {
751
+ if (!request.body.avatar_url || !request.body.new_name) {
752
+ return response.sendStatus(400);
753
+ }
754
+
755
+ const oldAvatarName = request.body.avatar_url;
756
+ const newName = sanitize(request.body.new_name);
757
+ const oldInternalName = path.parse(request.body.avatar_url).name;
758
+ const newInternalName = getPngName(newName, request.user.directories);
759
+ const newAvatarName = `${newInternalName}.png`;
760
+
761
+ const oldAvatarPath = path.join(request.user.directories.characters, oldAvatarName);
762
+
763
+ const oldChatsPath = path.join(request.user.directories.chats, oldInternalName);
764
+ const newChatsPath = path.join(request.user.directories.chats, newInternalName);
765
+
766
+ try {
767
+ // Read old file, replace name int it
768
+ const rawOldData = await readCharacterData(oldAvatarPath);
769
+ if (rawOldData === undefined) throw new Error('Failed to read character file');
770
+
771
+ const oldData = getCharaCardV2(JSON.parse(rawOldData), request.user.directories);
772
+ _.set(oldData, 'data.name', newName);
773
+ _.set(oldData, 'name', newName);
774
+ const newData = JSON.stringify(oldData);
775
+
776
+ // Write data to new location
777
+ await writeCharacterData(oldAvatarPath, newData, newInternalName, request);
778
+
779
+ // Rename chats folder
780
+ if (fs.existsSync(oldChatsPath) && !fs.existsSync(newChatsPath)) {
781
+ fs.cpSync(oldChatsPath, newChatsPath, { recursive: true });
782
+ fs.rmSync(oldChatsPath, { recursive: true, force: true });
783
+ }
784
+
785
+ // Remove the old character file
786
+ fs.rmSync(oldAvatarPath);
787
+
788
+ // Return new avatar name to ST
789
+ return response.send({ avatar: newAvatarName });
790
+ }
791
+ catch (err) {
792
+ console.error(err);
793
+ return response.sendStatus(500);
794
+ }
795
+ });
796
+
797
+ router.post('/edit', urlencodedParser, async function (request, response) {
798
+ if (!request.body) {
799
+ console.error('Error: no response body detected');
800
+ response.status(400).send('Error: no response body detected');
801
+ return;
802
+ }
803
+
804
+ if (request.body.ch_name === '' || request.body.ch_name === undefined || request.body.ch_name === '.') {
805
+ console.error('Error: invalid name.');
806
+ response.status(400).send('Error: invalid name.');
807
+ return;
808
+ }
809
+
810
+ let char = charaFormatData(request.body, request.user.directories);
811
+ char.chat = request.body.chat;
812
+ char.create_date = request.body.create_date;
813
+ char = JSON.stringify(char);
814
+ let targetFile = (request.body.avatar_url).replace('.png', '');
815
+
816
+ try {
817
+ if (!request.file) {
818
+ const avatarPath = path.join(request.user.directories.characters, request.body.avatar_url);
819
+ await writeCharacterData(avatarPath, char, targetFile, request);
820
+ } else {
821
+ const crop = tryParse(request.query.crop);
822
+ const newAvatarPath = path.join(request.file.destination, request.file.filename);
823
+ invalidateThumbnail(request.user.directories, 'avatar', request.body.avatar_url);
824
+ await writeCharacterData(newAvatarPath, char, targetFile, request, crop);
825
+ fs.unlinkSync(newAvatarPath);
826
+ }
827
+
828
+ return response.sendStatus(200);
829
+ }
830
+ catch {
831
+ console.error('An error occured, character edit invalidated.');
832
+ }
833
+ });
834
+
835
+
836
+ /**
837
+ * Handle a POST request to edit a character attribute.
838
+ *
839
+ * This function reads the character data from a file, updates the specified attribute,
840
+ * and writes the updated data back to the file.
841
+ *
842
+ * @param {Object} request - The HTTP request object.
843
+ * @param {Object} response - The HTTP response object.
844
+ * @returns {void}
845
+ */
846
+ router.post('/edit-attribute', jsonParser, async function (request, response) {
847
+ console.log(request.body);
848
+ if (!request.body) {
849
+ console.error('Error: no response body detected');
850
+ return response.status(400).send('Error: no response body detected');
851
+ }
852
+
853
+ if (request.body.ch_name === '' || request.body.ch_name === undefined || request.body.ch_name === '.') {
854
+ console.error('Error: invalid name.');
855
+ return response.status(400).send('Error: invalid name.');
856
+ }
857
+
858
+ try {
859
+ const avatarPath = path.join(request.user.directories.characters, request.body.avatar_url);
860
+ const charJSON = await readCharacterData(avatarPath);
861
+ if (typeof charJSON !== 'string') throw new Error('Failed to read character file');
862
+
863
+ const char = JSON.parse(charJSON);
864
+ //check if the field exists
865
+ if (char[request.body.field] === undefined && char.data[request.body.field] === undefined) {
866
+ console.error('Error: invalid field.');
867
+ response.status(400).send('Error: invalid field.');
868
+ return;
869
+ }
870
+ char[request.body.field] = request.body.value;
871
+ char.data[request.body.field] = request.body.value;
872
+ let newCharJSON = JSON.stringify(char);
873
+ const targetFile = (request.body.avatar_url).replace('.png', '');
874
+ await writeCharacterData(avatarPath, newCharJSON, targetFile, request);
875
+ return response.sendStatus(200);
876
+ } catch (err) {
877
+ console.error('An error occured, character edit invalidated.', err);
878
+ }
879
+ });
880
+
881
+ /**
882
+ * Handle a POST request to edit character properties.
883
+ *
884
+ * Merges the request body with the selected character and
885
+ * validates the result against TavernCard V2 specification.
886
+ *
887
+ * @param {Object} request - The HTTP request object.
888
+ * @param {Object} response - The HTTP response object.
889
+ *
890
+ * @returns {void}
891
+ * */
892
+ router.post('/merge-attributes', jsonParser, async function (request, response) {
893
+ try {
894
+ const update = request.body;
895
+ const avatarPath = path.join(request.user.directories.characters, update.avatar);
896
+
897
+ const pngStringData = await readCharacterData(avatarPath);
898
+
899
+ if (!pngStringData) {
900
+ console.error('Error: invalid character file.');
901
+ return response.status(400).send('Error: invalid character file.');
902
+ }
903
+
904
+ let character = JSON.parse(pngStringData);
905
+ character = deepMerge(character, update);
906
+
907
+ const validator = new TavernCardValidator(character);
908
+ const targetImg = (update.avatar).replace('.png', '');
909
+
910
+ //Accept either V1 or V2.
911
+ if (validator.validate()) {
912
+ await writeCharacterData(avatarPath, JSON.stringify(character), targetImg, request);
913
+ response.sendStatus(200);
914
+ } else {
915
+ console.log(validator.lastValidationError);
916
+ response.status(400).send({ message: `Validation failed for ${character.name}`, error: validator.lastValidationError });
917
+ }
918
+ } catch (exception) {
919
+ response.status(500).send({ message: 'Unexpected error while saving character.', error: exception.toString() });
920
+ }
921
+ });
922
+
923
+ router.post('/delete', jsonParser, async function (request, response) {
924
+ if (!request.body || !request.body.avatar_url) {
925
+ return response.sendStatus(400);
926
+ }
927
+
928
+ if (request.body.avatar_url !== sanitize(request.body.avatar_url)) {
929
+ console.error('Malicious filename prevented');
930
+ return response.sendStatus(403);
931
+ }
932
+
933
+ const avatarPath = path.join(request.user.directories.characters, request.body.avatar_url);
934
+ if (!fs.existsSync(avatarPath)) {
935
+ return response.sendStatus(400);
936
+ }
937
+
938
+ fs.rmSync(avatarPath);
939
+ invalidateThumbnail(request.user.directories, 'avatar', request.body.avatar_url);
940
+ let dir_name = (request.body.avatar_url.replace('.png', ''));
941
+
942
+ if (!dir_name.length) {
943
+ console.error('Malicious dirname prevented');
944
+ return response.sendStatus(403);
945
+ }
946
+
947
+ if (request.body.delete_chats == true) {
948
+ try {
949
+ await fs.promises.rm(path.join(request.user.directories.chats, sanitize(dir_name)), { recursive: true, force: true });
950
+ } catch (err) {
951
+ console.error(err);
952
+ return response.sendStatus(500);
953
+ }
954
+ }
955
+
956
+ return response.sendStatus(200);
957
+ });
958
+
959
+ /**
960
+ * HTTP POST endpoint for the "/api/characters/all" route.
961
+ *
962
+ * This endpoint is responsible for reading character files from the `charactersPath` directory,
963
+ * parsing character data, calculating stats for each character and responding with the data.
964
+ * Stats are calculated only on the first run, on subsequent runs the stats are fetched from
965
+ * the `charStats` variable.
966
+ * The stats are calculated by the `calculateStats` function.
967
+ * The characters are processed by the `processCharacter` function.
968
+ *
969
+ * @param {import("express").Request} request The HTTP request object.
970
+ * @param {import("express").Response} response The HTTP response object.
971
+ * @return {void}
972
+ */
973
+ router.post('/all', jsonParser, async function (request, response) {
974
+ try {
975
+ const files = fs.readdirSync(request.user.directories.characters);
976
+ const pngFiles = files.filter(file => file.endsWith('.png'));
977
+ const processingPromises = pngFiles.map(file => processCharacter(file, request.user.directories));
978
+ const data = (await Promise.all(processingPromises)).filter(c => c.name);
979
+ return response.send(data);
980
+ } catch (err) {
981
+ console.error(err);
982
+ response.sendStatus(500);
983
+ }
984
+ });
985
+
986
+ router.post('/get', jsonParser, async function (request, response) {
987
+ try {
988
+ if (!request.body) return response.sendStatus(400);
989
+ const item = request.body.avatar_url;
990
+ const filePath = path.join(request.user.directories.characters, item);
991
+
992
+ if (!fs.existsSync(filePath)) {
993
+ return response.sendStatus(404);
994
+ }
995
+
996
+ const data = await processCharacter(item, request.user.directories);
997
+
998
+ return response.send(data);
999
+ } catch (err) {
1000
+ console.error(err);
1001
+ response.sendStatus(500);
1002
+ }
1003
+ });
1004
+
1005
+ router.post('/chats', jsonParser, async function (request, response) {
1006
+ if (!request.body) return response.sendStatus(400);
1007
+
1008
+ const characterDirectory = (request.body.avatar_url).replace('.png', '');
1009
+
1010
+ try {
1011
+ const chatsDirectory = path.join(request.user.directories.chats, characterDirectory);
1012
+ const files = fs.readdirSync(chatsDirectory);
1013
+ const jsonFiles = files.filter(file => path.extname(file) === '.jsonl');
1014
+
1015
+ if (jsonFiles.length === 0) {
1016
+ response.send({ error: true });
1017
+ return;
1018
+ }
1019
+
1020
+ if (request.body.simple) {
1021
+ return response.send(jsonFiles.map(file => ({ file_name: file })));
1022
+ }
1023
+
1024
+ const jsonFilesPromise = jsonFiles.map((file) => {
1025
+ return new Promise(async (res) => {
1026
+ const pathToFile = path.join(request.user.directories.chats, characterDirectory, file);
1027
+ const fileStream = fs.createReadStream(pathToFile);
1028
+ const stats = fs.statSync(pathToFile);
1029
+ const fileSizeInKB = `${(stats.size / 1024).toFixed(2)}kb`;
1030
+
1031
+ const rl = readline.createInterface({
1032
+ input: fileStream,
1033
+ crlfDelay: Infinity,
1034
+ });
1035
+
1036
+ let lastLine;
1037
+ let itemCounter = 0;
1038
+ rl.on('line', (line) => {
1039
+ itemCounter++;
1040
+ lastLine = line;
1041
+ });
1042
+ rl.on('close', () => {
1043
+ rl.close();
1044
+
1045
+ if (lastLine) {
1046
+ const jsonData = tryParse(lastLine);
1047
+ if (jsonData && (jsonData.name || jsonData.character_name)) {
1048
+ const chatData = {};
1049
+
1050
+ chatData['file_name'] = file;
1051
+ chatData['file_size'] = fileSizeInKB;
1052
+ chatData['chat_items'] = itemCounter - 1;
1053
+ chatData['mes'] = jsonData['mes'] || '[The chat is empty]';
1054
+ chatData['last_mes'] = jsonData['send_date'] || Date.now();
1055
+
1056
+ res(chatData);
1057
+ } else {
1058
+ console.log('Found an invalid or corrupted chat file:', pathToFile);
1059
+ res({});
1060
+ }
1061
+ }
1062
+ });
1063
+ });
1064
+ });
1065
+
1066
+ const chatData = await Promise.all(jsonFilesPromise);
1067
+ const validFiles = chatData.filter(i => i.file_name);
1068
+
1069
+ return response.send(validFiles);
1070
+ } catch (error) {
1071
+ console.log(error);
1072
+ return response.send({ error: true });
1073
+ }
1074
+ });
1075
+
1076
+ /**
1077
+ * Gets the name for the uploaded PNG file.
1078
+ * @param {string} file File name
1079
+ * @param {import('../users').UserDirectoryList} directories User directories
1080
+ * @returns {string} - The name for the uploaded PNG file
1081
+ */
1082
+ function getPngName(file, directories) {
1083
+ let i = 1;
1084
+ const baseName = file;
1085
+ while (fs.existsSync(path.join(directories.characters, `${file}.png`))) {
1086
+ file = baseName + i;
1087
+ i++;
1088
+ }
1089
+ return file;
1090
+ }
1091
+
1092
+ /**
1093
+ * Gets the preserved name for the uploaded file if the request is valid.
1094
+ * @param {import("express").Request} request - Express request object
1095
+ * @returns {string | undefined} - The preserved name if the request is valid, otherwise undefined
1096
+ */
1097
+ function getPreservedName(request) {
1098
+ return typeof request.body.preserved_name === 'string' && request.body.preserved_name.length > 0
1099
+ ? path.parse(request.body.preserved_name).name
1100
+ : undefined;
1101
+ }
1102
+
1103
+ router.post('/import', urlencodedParser, async function (request, response) {
1104
+ if (!request.body || !request.file) return response.sendStatus(400);
1105
+
1106
+ const uploadPath = path.join(request.file.destination, request.file.filename);
1107
+ const format = request.body.file_type;
1108
+ const preservedFileName = getPreservedName(request);
1109
+
1110
+ const formatImportFunctions = {
1111
+ 'yaml': importFromYaml,
1112
+ 'yml': importFromYaml,
1113
+ 'json': importFromJson,
1114
+ 'png': importFromPng,
1115
+ 'charx': importFromCharX,
1116
+ };
1117
+
1118
+ try {
1119
+ const importFunction = formatImportFunctions[format];
1120
+
1121
+ if (!importFunction) {
1122
+ throw new Error(`Unsupported format: ${format}`);
1123
+ }
1124
+
1125
+ const fileName = await importFunction(uploadPath, { request, response }, preservedFileName);
1126
+
1127
+ if (!fileName) {
1128
+ console.error('Failed to import character');
1129
+ return response.sendStatus(400);
1130
+ }
1131
+
1132
+ if (preservedFileName) {
1133
+ invalidateThumbnail(request.user.directories, 'avatar', `${preservedFileName}.png`);
1134
+ }
1135
+
1136
+ response.send({ file_name: fileName });
1137
+ } catch (err) {
1138
+ console.log(err);
1139
+ response.send({ error: true });
1140
+ }
1141
+ });
1142
+
1143
+ router.post('/duplicate', jsonParser, async function (request, response) {
1144
+ try {
1145
+ if (!request.body.avatar_url) {
1146
+ console.log('avatar URL not found in request body');
1147
+ console.log(request.body);
1148
+ return response.sendStatus(400);
1149
+ }
1150
+ let filename = path.join(request.user.directories.characters, sanitize(request.body.avatar_url));
1151
+ if (!fs.existsSync(filename)) {
1152
+ console.log('file for dupe not found');
1153
+ console.log(filename);
1154
+ return response.sendStatus(404);
1155
+ }
1156
+ let suffix = 1;
1157
+ let newFilename = filename;
1158
+
1159
+ // If filename ends with a _number, increment the number
1160
+ const nameParts = path.basename(filename, path.extname(filename)).split('_');
1161
+ const lastPart = nameParts[nameParts.length - 1];
1162
+
1163
+ let baseName;
1164
+
1165
+ if (!isNaN(Number(lastPart)) && nameParts.length > 1) {
1166
+ suffix = parseInt(lastPart) + 1;
1167
+ baseName = nameParts.slice(0, -1).join('_'); // construct baseName without suffix
1168
+ } else {
1169
+ baseName = nameParts.join('_'); // original filename is completely the baseName
1170
+ }
1171
+
1172
+ newFilename = path.join(request.user.directories.characters, `${baseName}_${suffix}${path.extname(filename)}`);
1173
+
1174
+ while (fs.existsSync(newFilename)) {
1175
+ let suffixStr = '_' + suffix;
1176
+ newFilename = path.join(request.user.directories.characters, `${baseName}${suffixStr}${path.extname(filename)}`);
1177
+ suffix++;
1178
+ }
1179
+
1180
+ fs.copyFileSync(filename, newFilename);
1181
+ console.log(`${filename} was copied to ${newFilename}`);
1182
+ response.send({ path: path.parse(newFilename).base });
1183
+ }
1184
+ catch (error) {
1185
+ console.error(error);
1186
+ return response.send({ error: true });
1187
+ }
1188
+ });
1189
+
1190
+ router.post('/export', jsonParser, async function (request, response) {
1191
+ try {
1192
+ if (!request.body.format || !request.body.avatar_url) {
1193
+ return response.sendStatus(400);
1194
+ }
1195
+
1196
+ let filename = path.join(request.user.directories.characters, sanitize(request.body.avatar_url));
1197
+
1198
+ if (!fs.existsSync(filename)) {
1199
+ return response.sendStatus(404);
1200
+ }
1201
+
1202
+ switch (request.body.format) {
1203
+ case 'png': {
1204
+ const fileContent = await fsPromises.readFile(filename);
1205
+ const contentType = mime.lookup(filename) || 'image/png';
1206
+ response.setHeader('Content-Type', contentType);
1207
+ response.setHeader('Content-Disposition', `attachment; filename="${encodeURI(path.basename(filename))}"`);
1208
+ return response.send(fileContent);
1209
+ }
1210
+ case 'json': {
1211
+ try {
1212
+ let json = await readCharacterData(filename);
1213
+ if (json === undefined) return response.sendStatus(400);
1214
+ let jsonObject = getCharaCardV2(JSON.parse(json), request.user.directories);
1215
+ return response.type('json').send(JSON.stringify(jsonObject, null, 4));
1216
+ }
1217
+ catch {
1218
+ return response.sendStatus(400);
1219
+ }
1220
+ }
1221
+ }
1222
+
1223
+ return response.sendStatus(400);
1224
+ } catch (err) {
1225
+ console.error('Character export failed', err);
1226
+ response.sendStatus(500);
1227
+ }
1228
+ });
1229
+
1230
+ module.exports = { router };
src/endpoints/chats.js ADDED
@@ -0,0 +1,461 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const readline = require('readline');
4
+ const express = require('express');
5
+ const sanitize = require('sanitize-filename');
6
+ const writeFileAtomicSync = require('write-file-atomic').sync;
7
+
8
+ const { jsonParser, urlencodedParser } = require('../express-common');
9
+ const { getConfigValue, humanizedISO8601DateTime, tryParse, generateTimestamp, removeOldBackups } = require('../util');
10
+
11
+ /**
12
+ * Saves a chat to the backups directory.
13
+ * @param {string} directory The user's backups directory.
14
+ * @param {string} name The name of the chat.
15
+ * @param {string} chat The serialized chat to save.
16
+ */
17
+ function backupChat(directory, name, chat) {
18
+ try {
19
+ const isBackupDisabled = getConfigValue('disableChatBackup', false);
20
+
21
+ if (isBackupDisabled) {
22
+ return;
23
+ }
24
+
25
+ // replace non-alphanumeric characters with underscores
26
+ name = sanitize(name).replace(/[^a-z0-9]/gi, '_').toLowerCase();
27
+
28
+ const backupFile = path.join(directory, `chat_${name}_${generateTimestamp()}.jsonl`);
29
+ writeFileAtomicSync(backupFile, chat, 'utf-8');
30
+
31
+ removeOldBackups(directory, `chat_${name}_`);
32
+ } catch (err) {
33
+ console.log(`Could not backup chat for ${name}`, err);
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Imports a chat from Ooba's format.
39
+ * @param {string} userName User name
40
+ * @param {string} characterName Character name
41
+ * @param {object} jsonData JSON data
42
+ * @returns {string} Chat data
43
+ */
44
+ function importOobaChat(userName, characterName, jsonData) {
45
+ /** @type {object[]} */
46
+ const chat = [{
47
+ user_name: userName,
48
+ character_name: characterName,
49
+ create_date: humanizedISO8601DateTime(),
50
+ }];
51
+
52
+ for (const arr of jsonData.data_visible) {
53
+ if (arr[0]) {
54
+ const userMessage = {
55
+ name: userName,
56
+ is_user: true,
57
+ send_date: humanizedISO8601DateTime(),
58
+ mes: arr[0],
59
+ };
60
+ chat.push(userMessage);
61
+ }
62
+ if (arr[1]) {
63
+ const charMessage = {
64
+ name: characterName,
65
+ is_user: false,
66
+ send_date: humanizedISO8601DateTime(),
67
+ mes: arr[1],
68
+ };
69
+ chat.push(charMessage);
70
+ }
71
+ }
72
+
73
+ const chatContent = chat.map(obj => JSON.stringify(obj)).join('\n');
74
+ return chatContent;
75
+ }
76
+
77
+ /**
78
+ * Imports a chat from Agnai's format.
79
+ * @param {string} userName User name
80
+ * @param {string} characterName Character name
81
+ * @param {object} jsonData Chat data
82
+ * @returns {string} Chat data
83
+ */
84
+ function importAgnaiChat(userName, characterName, jsonData) {
85
+ /** @type {object[]} */
86
+ const chat = [{
87
+ user_name: userName,
88
+ character_name: characterName,
89
+ create_date: humanizedISO8601DateTime(),
90
+ }];
91
+
92
+ for (const message of jsonData.messages) {
93
+ const isUser = !!message.userId;
94
+ chat.push({
95
+ name: isUser ? userName : characterName,
96
+ is_user: isUser,
97
+ send_date: humanizedISO8601DateTime(),
98
+ mes: message.msg,
99
+ });
100
+ }
101
+
102
+ const chatContent = chat.map(obj => JSON.stringify(obj)).join('\n');
103
+ return chatContent;
104
+ }
105
+
106
+ /**
107
+ * Imports a chat from CAI Tools format.
108
+ * @param {string} userName User name
109
+ * @param {string} characterName Character name
110
+ * @param {object} jsonData JSON data
111
+ * @returns {string[]} Converted data
112
+ */
113
+ function importCAIChat(userName, characterName, jsonData) {
114
+ /**
115
+ * Converts the chat data to suitable format.
116
+ * @param {object} history Imported chat data
117
+ * @returns {object[]} Converted chat data
118
+ */
119
+ function convert(history) {
120
+ const starter = {
121
+ user_name: userName,
122
+ character_name: characterName,
123
+ create_date: humanizedISO8601DateTime(),
124
+ };
125
+
126
+ const historyData = history.msgs.map((msg) => ({
127
+ name: msg.src.is_human ? userName : characterName,
128
+ is_user: msg.src.is_human,
129
+ send_date: humanizedISO8601DateTime(),
130
+ mes: msg.text,
131
+ }));
132
+
133
+ return [starter, ...historyData];
134
+ }
135
+
136
+ const newChats = (jsonData.histories.histories ?? []).map(history => newChats.push(convert(history).map(obj => JSON.stringify(obj)).join('\n')));
137
+ return newChats;
138
+ }
139
+
140
+ const router = express.Router();
141
+
142
+ router.post('/save', jsonParser, function (request, response) {
143
+ try {
144
+ const directoryName = String(request.body.avatar_url).replace('.png', '');
145
+ const chatData = request.body.chat;
146
+ const jsonlData = chatData.map(JSON.stringify).join('\n');
147
+ const fileName = `${sanitize(String(request.body.file_name))}.jsonl`;
148
+ const filePath = path.join(request.user.directories.chats, directoryName, fileName);
149
+ writeFileAtomicSync(filePath, jsonlData, 'utf8');
150
+ backupChat(request.user.directories.backups, directoryName, jsonlData);
151
+ return response.send({ result: 'ok' });
152
+ } catch (error) {
153
+ response.send(error);
154
+ return console.log(error);
155
+ }
156
+ });
157
+
158
+ router.post('/get', jsonParser, function (request, response) {
159
+ try {
160
+ const dirName = String(request.body.avatar_url).replace('.png', '');
161
+ const directoryPath = path.join(request.user.directories.chats, dirName);
162
+ const chatDirExists = fs.existsSync(directoryPath);
163
+
164
+ //if no chat dir for the character is found, make one with the character name
165
+ if (!chatDirExists) {
166
+ fs.mkdirSync(directoryPath);
167
+ return response.send({});
168
+ }
169
+
170
+ if (!request.body.file_name) {
171
+ return response.send({});
172
+ }
173
+
174
+ const fileName = path.join(directoryPath, `${sanitize(String(request.body.file_name))}.jsonl`);
175
+ const chatFileExists = fs.existsSync(fileName);
176
+
177
+ if (!chatFileExists) {
178
+ return response.send({});
179
+ }
180
+
181
+ const data = fs.readFileSync(fileName, 'utf8');
182
+ const lines = data.split('\n');
183
+
184
+ // Iterate through the array of strings and parse each line as JSON
185
+ const jsonData = lines.map((l) => { try { return JSON.parse(l); } catch (_) { return; } }).filter(x => x);
186
+ return response.send(jsonData);
187
+ } catch (error) {
188
+ console.error(error);
189
+ return response.send({});
190
+ }
191
+ });
192
+
193
+
194
+ router.post('/rename', jsonParser, async function (request, response) {
195
+ if (!request.body || !request.body.original_file || !request.body.renamed_file) {
196
+ return response.sendStatus(400);
197
+ }
198
+
199
+ const pathToFolder = request.body.is_group
200
+ ? request.user.directories.groupChats
201
+ : path.join(request.user.directories.chats, String(request.body.avatar_url).replace('.png', ''));
202
+ const pathToOriginalFile = path.join(pathToFolder, request.body.original_file);
203
+ const pathToRenamedFile = path.join(pathToFolder, request.body.renamed_file);
204
+ console.log('Old chat name', pathToOriginalFile);
205
+ console.log('New chat name', pathToRenamedFile);
206
+
207
+ if (!fs.existsSync(pathToOriginalFile) || fs.existsSync(pathToRenamedFile)) {
208
+ console.log('Either Source or Destination files are not available');
209
+ return response.status(400).send({ error: true });
210
+ }
211
+
212
+ fs.copyFileSync(pathToOriginalFile, pathToRenamedFile);
213
+ fs.rmSync(pathToOriginalFile);
214
+ console.log('Successfully renamed.');
215
+ return response.send({ ok: true });
216
+ });
217
+
218
+ router.post('/delete', jsonParser, function (request, response) {
219
+ if (!request.body) {
220
+ console.log('no request body seen');
221
+ return response.sendStatus(400);
222
+ }
223
+
224
+ if (request.body.chatfile !== sanitize(request.body.chatfile)) {
225
+ console.error('Malicious chat name prevented');
226
+ return response.sendStatus(403);
227
+ }
228
+
229
+ const dirName = String(request.body.avatar_url).replace('.png', '');
230
+ const fileName = path.join(request.user.directories.chats, dirName, sanitize(String(request.body.chatfile)));
231
+ const chatFileExists = fs.existsSync(fileName);
232
+
233
+ if (!chatFileExists) {
234
+ console.log(`Chat file not found '${fileName}'`);
235
+ return response.sendStatus(400);
236
+ } else {
237
+ fs.rmSync(fileName);
238
+ console.log('Deleted chat file: ' + fileName);
239
+ }
240
+
241
+ return response.send('ok');
242
+ });
243
+
244
+ router.post('/export', jsonParser, async function (request, response) {
245
+ if (!request.body.file || (!request.body.avatar_url && request.body.is_group === false)) {
246
+ return response.sendStatus(400);
247
+ }
248
+ const pathToFolder = request.body.is_group
249
+ ? request.user.directories.groupChats
250
+ : path.join(request.user.directories.chats, String(request.body.avatar_url).replace('.png', ''));
251
+ let filename = path.join(pathToFolder, request.body.file);
252
+ let exportfilename = request.body.exportfilename;
253
+ if (!fs.existsSync(filename)) {
254
+ const errorMessage = {
255
+ message: `Could not find JSONL file to export. Source chat file: ${filename}.`,
256
+ };
257
+ console.log(errorMessage.message);
258
+ return response.status(404).json(errorMessage);
259
+ }
260
+ try {
261
+ // Short path for JSONL files
262
+ if (request.body.format == 'jsonl') {
263
+ try {
264
+ const rawFile = fs.readFileSync(filename, 'utf8');
265
+ const successMessage = {
266
+ message: `Chat saved to ${exportfilename}`,
267
+ result: rawFile,
268
+ };
269
+
270
+ console.log(`Chat exported as ${exportfilename}`);
271
+ return response.status(200).json(successMessage);
272
+ }
273
+ catch (err) {
274
+ console.error(err);
275
+ const errorMessage = {
276
+ message: `Could not read JSONL file to export. Source chat file: ${filename}.`,
277
+ };
278
+ console.log(errorMessage.message);
279
+ return response.status(500).json(errorMessage);
280
+ }
281
+ }
282
+
283
+ const readStream = fs.createReadStream(filename);
284
+ const rl = readline.createInterface({
285
+ input: readStream,
286
+ });
287
+ let buffer = '';
288
+ rl.on('line', (line) => {
289
+ const data = JSON.parse(line);
290
+ // Skip non-printable/prompt-hidden messages
291
+ if (data.is_system) {
292
+ return;
293
+ }
294
+ if (data.mes) {
295
+ const name = data.name;
296
+ const message = (data?.extra?.display_text || data?.mes || '').replace(/\r?\n/g, '\n');
297
+ buffer += (`${name}: ${message}\n\n`);
298
+ }
299
+ });
300
+ rl.on('close', () => {
301
+ const successMessage = {
302
+ message: `Chat saved to ${exportfilename}`,
303
+ result: buffer,
304
+ };
305
+ console.log(`Chat exported as ${exportfilename}`);
306
+ return response.status(200).json(successMessage);
307
+ });
308
+ }
309
+ catch (err) {
310
+ console.log('chat export failed.');
311
+ console.log(err);
312
+ return response.sendStatus(400);
313
+ }
314
+ });
315
+
316
+ router.post('/group/import', urlencodedParser, function (request, response) {
317
+ try {
318
+ const filedata = request.file;
319
+
320
+ if (!filedata) {
321
+ return response.sendStatus(400);
322
+ }
323
+
324
+ const chatname = humanizedISO8601DateTime();
325
+ const pathToUpload = path.join(filedata.destination, filedata.filename);
326
+ const pathToNewFile = path.join(request.user.directories.groupChats, `${chatname}.jsonl`);
327
+ fs.copyFileSync(pathToUpload, pathToNewFile);
328
+ fs.unlinkSync(pathToUpload);
329
+ return response.send({ res: chatname });
330
+ } catch (error) {
331
+ console.error(error);
332
+ return response.send({ error: true });
333
+ }
334
+ });
335
+
336
+ router.post('/import', urlencodedParser, function (request, response) {
337
+ if (!request.body) return response.sendStatus(400);
338
+
339
+ const format = request.body.file_type;
340
+ const avatarUrl = (request.body.avatar_url).replace('.png', '');
341
+ const characterName = request.body.character_name;
342
+ const userName = request.body.user_name || 'You';
343
+
344
+ if (!request.file) {
345
+ return response.sendStatus(400);
346
+ }
347
+
348
+ try {
349
+ const pathToUpload = path.join(request.file.destination, request.file.filename);
350
+ const data = fs.readFileSync(pathToUpload, 'utf8');
351
+
352
+ if (format === 'json') {
353
+ fs.unlinkSync(pathToUpload);
354
+ const jsonData = JSON.parse(data);
355
+ if (jsonData.histories !== undefined) {
356
+ // CAI Tools format
357
+ const chats = importCAIChat(userName, characterName, jsonData);
358
+ for (const chat of chats) {
359
+ const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`;
360
+ const filePath = path.join(request.user.directories.chats, avatarUrl, fileName);
361
+ writeFileAtomicSync(filePath, chat, 'utf8');
362
+ }
363
+ return response.send({ res: true });
364
+ } else if (Array.isArray(jsonData.data_visible)) {
365
+ // oobabooga's format
366
+ const chat = importOobaChat(userName, characterName, jsonData);
367
+ const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`;
368
+ const filePath = path.join(request.user.directories.chats, avatarUrl, fileName);
369
+ writeFileAtomicSync(filePath, chat, 'utf8');
370
+ return response.send({ res: true });
371
+ } else if (Array.isArray(jsonData.messages)) {
372
+ // Agnai format
373
+ const chat = importAgnaiChat(userName, characterName, jsonData);
374
+ const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`;
375
+ const filePath = path.join(request.user.directories.chats, avatarUrl, fileName);
376
+ writeFileAtomicSync(filePath, chat, 'utf8');
377
+ return response.send({ res: true });
378
+ } else {
379
+ console.log('Incorrect chat format .json');
380
+ return response.send({ error: true });
381
+ }
382
+ }
383
+
384
+ if (format === 'jsonl') {
385
+ const line = data.split('\n')[0];
386
+
387
+ const jsonData = JSON.parse(line);
388
+
389
+ if (jsonData.user_name !== undefined || jsonData.name !== undefined) {
390
+ const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`;
391
+ const filePath = path.join(request.user.directories.chats, avatarUrl, fileName);
392
+ fs.copyFileSync(pathToUpload, filePath);
393
+ fs.unlinkSync(pathToUpload);
394
+ response.send({ res: true });
395
+ } else {
396
+ console.log('Incorrect chat format .jsonl');
397
+ return response.send({ error: true });
398
+ }
399
+ }
400
+ } catch (error) {
401
+ console.error(error);
402
+ return response.send({ error: true });
403
+ }
404
+ });
405
+
406
+ router.post('/group/get', jsonParser, (request, response) => {
407
+ if (!request.body || !request.body.id) {
408
+ return response.sendStatus(400);
409
+ }
410
+
411
+ const id = request.body.id;
412
+ const pathToFile = path.join(request.user.directories.groupChats, `${id}.jsonl`);
413
+
414
+ if (fs.existsSync(pathToFile)) {
415
+ const data = fs.readFileSync(pathToFile, 'utf8');
416
+ const lines = data.split('\n');
417
+
418
+ // Iterate through the array of strings and parse each line as JSON
419
+ const jsonData = lines.map(line => tryParse(line)).filter(x => x);
420
+ return response.send(jsonData);
421
+ } else {
422
+ return response.send([]);
423
+ }
424
+ });
425
+
426
+ router.post('/group/delete', jsonParser, (request, response) => {
427
+ if (!request.body || !request.body.id) {
428
+ return response.sendStatus(400);
429
+ }
430
+
431
+ const id = request.body.id;
432
+ const pathToFile = path.join(request.user.directories.groupChats, `${id}.jsonl`);
433
+
434
+ if (fs.existsSync(pathToFile)) {
435
+ fs.rmSync(pathToFile);
436
+ return response.send({ ok: true });
437
+ }
438
+
439
+ return response.send({ error: true });
440
+ });
441
+
442
+ router.post('/group/save', jsonParser, (request, response) => {
443
+ if (!request.body || !request.body.id) {
444
+ return response.sendStatus(400);
445
+ }
446
+
447
+ const id = request.body.id;
448
+ const pathToFile = path.join(request.user.directories.groupChats, `${id}.jsonl`);
449
+
450
+ if (!fs.existsSync(request.user.directories.groupChats)) {
451
+ fs.mkdirSync(request.user.directories.groupChats);
452
+ }
453
+
454
+ let chat_data = request.body.chat;
455
+ let jsonlData = chat_data.map(JSON.stringify).join('\n');
456
+ writeFileAtomicSync(pathToFile, jsonlData, 'utf8');
457
+ backupChat(request.user.directories.backups, String(id), jsonlData);
458
+ return response.send({ ok: true });
459
+ });
460
+
461
+ module.exports = { router };
src/endpoints/classify.js ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const { jsonParser } = require('../express-common');
3
+
4
+ const TASK = 'text-classification';
5
+
6
+ const router = express.Router();
7
+
8
+ /**
9
+ * @type {Map<string, object>} Cache for classification results
10
+ */
11
+ const cacheObject = new Map();
12
+
13
+ router.post('/labels', jsonParser, async (req, res) => {
14
+ try {
15
+ const module = await import('../transformers.mjs');
16
+ const pipe = await module.default.getPipeline(TASK);
17
+ const result = Object.keys(pipe.model.config.label2id);
18
+ return res.json({ labels: result });
19
+ } catch (error) {
20
+ console.error(error);
21
+ return res.sendStatus(500);
22
+ }
23
+ });
24
+
25
+ router.post('/', jsonParser, async (req, res) => {
26
+ try {
27
+ const { text } = req.body;
28
+
29
+ /**
30
+ * Get classification result for a given text
31
+ * @param {string} text Text to classify
32
+ * @returns {Promise<object>} Classification result
33
+ */
34
+ async function getResult(text) {
35
+ if (cacheObject.has(text)) {
36
+ return cacheObject.get(text);
37
+ } else {
38
+ const module = await import('../transformers.mjs');
39
+ const pipe = await module.default.getPipeline(TASK);
40
+ const result = await pipe(text, { topk: 5 });
41
+ result.sort((a, b) => b.score - a.score);
42
+ cacheObject.set(text, result);
43
+ return result;
44
+ }
45
+ }
46
+
47
+ console.log('Classify input:', text);
48
+ const result = await getResult(text);
49
+ console.log('Classify output:', result);
50
+
51
+ return res.json({ classification: result });
52
+ } catch (error) {
53
+ console.error(error);
54
+ return res.sendStatus(500);
55
+ }
56
+ });
57
+
58
+ module.exports = { router };
src/endpoints/content-manager.js ADDED
@@ -0,0 +1,725 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const express = require('express');
4
+ const fetch = require('node-fetch').default;
5
+ const sanitize = require('sanitize-filename');
6
+ const { getConfigValue, color } = require('../util');
7
+ const { jsonParser } = require('../express-common');
8
+ const writeFileAtomicSync = require('write-file-atomic').sync;
9
+ const contentDirectory = path.join(process.cwd(), 'default/content');
10
+ const scaffoldDirectory = path.join(process.cwd(), 'default/scaffold');
11
+ const contentIndexPath = path.join(contentDirectory, 'index.json');
12
+ const scaffoldIndexPath = path.join(scaffoldDirectory, 'index.json');
13
+ const characterCardParser = require('../character-card-parser.js');
14
+
15
+ const WHITELIST_GENERIC_URL_DOWNLOAD_SOURCES = getConfigValue('whitelistImportDomains', []);
16
+
17
+ /**
18
+ * @typedef {Object} ContentItem
19
+ * @property {string} filename
20
+ * @property {string} type
21
+ * @property {string} [name]
22
+ * @property {string|null} [folder]
23
+ */
24
+
25
+ /**
26
+ * @typedef {string} ContentType
27
+ * @enum {string}
28
+ */
29
+ const CONTENT_TYPES = {
30
+ SETTINGS: 'settings',
31
+ CHARACTER: 'character',
32
+ SPRITES: 'sprites',
33
+ BACKGROUND: 'background',
34
+ WORLD: 'world',
35
+ AVATAR: 'avatar',
36
+ THEME: 'theme',
37
+ WORKFLOW: 'workflow',
38
+ KOBOLD_PRESET: 'kobold_preset',
39
+ OPENAI_PRESET: 'openai_preset',
40
+ NOVEL_PRESET: 'novel_preset',
41
+ TEXTGEN_PRESET: 'textgen_preset',
42
+ INSTRUCT: 'instruct',
43
+ CONTEXT: 'context',
44
+ MOVING_UI: 'moving_ui',
45
+ QUICK_REPLIES: 'quick_replies',
46
+ };
47
+
48
+ /**
49
+ * Gets the default presets from the content directory.
50
+ * @param {import('../users').UserDirectoryList} directories User directories
51
+ * @returns {object[]} Array of default presets
52
+ */
53
+ function getDefaultPresets(directories) {
54
+ try {
55
+ const contentIndex = getContentIndex();
56
+ const presets = [];
57
+
58
+ for (const contentItem of contentIndex) {
59
+ if (contentItem.type.endsWith('_preset') || contentItem.type === 'instruct' || contentItem.type === 'context') {
60
+ contentItem.name = path.parse(contentItem.filename).name;
61
+ contentItem.folder = getTargetByType(contentItem.type, directories);
62
+ presets.push(contentItem);
63
+ }
64
+ }
65
+
66
+ return presets;
67
+ } catch (err) {
68
+ console.log('Failed to get default presets', err);
69
+ return [];
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Gets a default JSON file from the content directory.
75
+ * @param {string} filename Name of the file to get
76
+ * @returns {object | null} JSON object or null if the file doesn't exist
77
+ */
78
+ function getDefaultPresetFile(filename) {
79
+ try {
80
+ const contentPath = path.join(contentDirectory, filename);
81
+
82
+ if (!fs.existsSync(contentPath)) {
83
+ return null;
84
+ }
85
+
86
+ const fileContent = fs.readFileSync(contentPath, 'utf8');
87
+ return JSON.parse(fileContent);
88
+ } catch (err) {
89
+ console.log(`Failed to get default file ${filename}`, err);
90
+ return null;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Seeds content for a user.
96
+ * @param {ContentItem[]} contentIndex Content index
97
+ * @param {import('../users').UserDirectoryList} directories User directories
98
+ * @param {string[]} forceCategories List of categories to force check (even if content check is skipped)
99
+ * @returns {Promise<boolean>} Whether any content was added
100
+ */
101
+ async function seedContentForUser(contentIndex, directories, forceCategories) {
102
+ let anyContentAdded = false;
103
+
104
+ if (!fs.existsSync(directories.root)) {
105
+ fs.mkdirSync(directories.root, { recursive: true });
106
+ }
107
+
108
+ const contentLogPath = path.join(directories.root, 'content.log');
109
+ const contentLog = getContentLog(contentLogPath);
110
+
111
+ for (const contentItem of contentIndex) {
112
+ // If the content item is already in the log, skip it
113
+ if (contentLog.includes(contentItem.filename) && !forceCategories?.includes(contentItem.type)) {
114
+ continue;
115
+ }
116
+
117
+ if (!contentItem.folder) {
118
+ console.log(`Content file ${contentItem.filename} has no parent folder`);
119
+ continue;
120
+ }
121
+
122
+ const contentPath = path.join(contentItem.folder, contentItem.filename);
123
+
124
+ if (!fs.existsSync(contentPath)) {
125
+ console.log(`Content file ${contentItem.filename} is missing`);
126
+ continue;
127
+ }
128
+
129
+ const contentTarget = getTargetByType(contentItem.type, directories);
130
+
131
+ if (!contentTarget) {
132
+ console.log(`Content file ${contentItem.filename} has unknown type ${contentItem.type}`);
133
+ continue;
134
+ }
135
+
136
+ const basePath = path.parse(contentItem.filename).base;
137
+ const targetPath = path.join(contentTarget, basePath);
138
+ contentLog.push(contentItem.filename);
139
+
140
+ if (fs.existsSync(targetPath)) {
141
+ console.log(`Content file ${contentItem.filename} already exists in ${contentTarget}`);
142
+ continue;
143
+ }
144
+
145
+ fs.cpSync(contentPath, targetPath, { recursive: true, force: false });
146
+ console.log(`Content file ${contentItem.filename} copied to ${contentTarget}`);
147
+ anyContentAdded = true;
148
+ }
149
+
150
+ writeFileAtomicSync(contentLogPath, contentLog.join('\n'));
151
+ return anyContentAdded;
152
+ }
153
+
154
+ /**
155
+ * Checks for new content and seeds it for all users.
156
+ * @param {import('../users').UserDirectoryList[]} directoriesList List of user directories
157
+ * @param {string[]} forceCategories List of categories to force check (even if content check is skipped)
158
+ * @returns {Promise<void>}
159
+ */
160
+ async function checkForNewContent(directoriesList, forceCategories = []) {
161
+ try {
162
+ const contentCheckSkip = getConfigValue('skipContentCheck', false);
163
+ if (contentCheckSkip && forceCategories?.length === 0) {
164
+ return;
165
+ }
166
+
167
+ const contentIndex = getContentIndex();
168
+ let anyContentAdded = false;
169
+
170
+ for (const directories of directoriesList) {
171
+ const seedResult = await seedContentForUser(contentIndex, directories, forceCategories);
172
+
173
+ if (seedResult) {
174
+ anyContentAdded = true;
175
+ }
176
+ }
177
+
178
+ if (anyContentAdded && !contentCheckSkip && forceCategories?.length === 0) {
179
+ console.log();
180
+ console.log(`${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.')}`);
181
+ console.log();
182
+ }
183
+ } catch (err) {
184
+ console.log('Content check failed', err);
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Gets combined content index from the content and scaffold directories.
190
+ * @returns {ContentItem[]} Array of content index
191
+ */
192
+ function getContentIndex() {
193
+ const result = [];
194
+
195
+ if (fs.existsSync(scaffoldIndexPath)) {
196
+ const scaffoldIndexText = fs.readFileSync(scaffoldIndexPath, 'utf8');
197
+ const scaffoldIndex = JSON.parse(scaffoldIndexText);
198
+ if (Array.isArray(scaffoldIndex)) {
199
+ scaffoldIndex.forEach((item) => {
200
+ item.folder = scaffoldDirectory;
201
+ });
202
+ result.push(...scaffoldIndex);
203
+ }
204
+ }
205
+
206
+ if (fs.existsSync(contentIndexPath)) {
207
+ const contentIndexText = fs.readFileSync(contentIndexPath, 'utf8');
208
+ const contentIndex = JSON.parse(contentIndexText);
209
+ if (Array.isArray(contentIndex)) {
210
+ contentIndex.forEach((item) => {
211
+ item.folder = contentDirectory;
212
+ });
213
+ result.push(...contentIndex);
214
+ }
215
+ }
216
+
217
+ return result;
218
+ }
219
+
220
+ /**
221
+ * Gets the target directory for the specified asset type.
222
+ * @param {ContentType} type Asset type
223
+ * @param {import('../users').UserDirectoryList} directories User directories
224
+ * @returns {string | null} Target directory
225
+ */
226
+ function getTargetByType(type, directories) {
227
+ switch (type) {
228
+ case CONTENT_TYPES.SETTINGS:
229
+ return directories.root;
230
+ case CONTENT_TYPES.CHARACTER:
231
+ return directories.characters;
232
+ case CONTENT_TYPES.SPRITES:
233
+ return directories.characters;
234
+ case CONTENT_TYPES.BACKGROUND:
235
+ return directories.backgrounds;
236
+ case CONTENT_TYPES.WORLD:
237
+ return directories.worlds;
238
+ case CONTENT_TYPES.AVATAR:
239
+ return directories.avatars;
240
+ case CONTENT_TYPES.THEME:
241
+ return directories.themes;
242
+ case CONTENT_TYPES.WORKFLOW:
243
+ return directories.comfyWorkflows;
244
+ case CONTENT_TYPES.KOBOLD_PRESET:
245
+ return directories.koboldAI_Settings;
246
+ case CONTENT_TYPES.OPENAI_PRESET:
247
+ return directories.openAI_Settings;
248
+ case CONTENT_TYPES.NOVEL_PRESET:
249
+ return directories.novelAI_Settings;
250
+ case CONTENT_TYPES.TEXTGEN_PRESET:
251
+ return directories.textGen_Settings;
252
+ case CONTENT_TYPES.INSTRUCT:
253
+ return directories.instruct;
254
+ case CONTENT_TYPES.CONTEXT:
255
+ return directories.context;
256
+ case CONTENT_TYPES.MOVING_UI:
257
+ return directories.movingUI;
258
+ case CONTENT_TYPES.QUICK_REPLIES:
259
+ return directories.quickreplies;
260
+ default:
261
+ return null;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Gets the content log from the content log file.
267
+ * @param {string} contentLogPath Path to the content log file
268
+ * @returns {string[]} Array of content log lines
269
+ */
270
+ function getContentLog(contentLogPath) {
271
+ if (!fs.existsSync(contentLogPath)) {
272
+ return [];
273
+ }
274
+
275
+ const contentLogText = fs.readFileSync(contentLogPath, 'utf8');
276
+ return contentLogText.split('\n');
277
+ }
278
+
279
+ async function downloadChubLorebook(id) {
280
+ const result = await fetch('https://api.chub.ai/api/lorebooks/download', {
281
+ method: 'POST',
282
+ headers: { 'Content-Type': 'application/json' },
283
+ body: JSON.stringify({
284
+ 'fullPath': id,
285
+ 'format': 'SILLYTAVERN',
286
+ }),
287
+ });
288
+
289
+ if (!result.ok) {
290
+ const text = await result.text();
291
+ console.log('Chub returned error', result.statusText, text);
292
+ throw new Error('Failed to download lorebook');
293
+ }
294
+
295
+ const name = id.split('/').pop();
296
+ const buffer = await result.buffer();
297
+ const fileName = `${sanitize(name)}.json`;
298
+ const fileType = result.headers.get('content-type');
299
+
300
+ return { buffer, fileName, fileType };
301
+ }
302
+
303
+ async function downloadChubCharacter(id) {
304
+ const result = await fetch('https://api.chub.ai/api/characters/download', {
305
+ method: 'POST',
306
+ headers: { 'Content-Type': 'application/json' },
307
+ body: JSON.stringify({
308
+ 'format': 'tavern',
309
+ 'fullPath': id,
310
+ }),
311
+ });
312
+
313
+ if (!result.ok) {
314
+ const text = await result.text();
315
+ console.log('Chub returned error', result.statusText, text);
316
+ throw new Error('Failed to download character');
317
+ }
318
+
319
+ const buffer = await result.buffer();
320
+ const fileName = result.headers.get('content-disposition')?.split('filename=')[1] || `${sanitize(id)}.png`;
321
+ const fileType = result.headers.get('content-type');
322
+
323
+ return { buffer, fileName, fileType };
324
+ }
325
+
326
+ /**
327
+ * Downloads a character card from the Pygsite.
328
+ * @param {string} id UUID of the character
329
+ * @returns {Promise<{buffer: Buffer, fileName: string, fileType: string}>}
330
+ */
331
+ async function downloadPygmalionCharacter(id) {
332
+ const result = await fetch(`https://server.pygmalion.chat/api/export/character/${id}/v2`);
333
+
334
+ if (!result.ok) {
335
+ const text = await result.text();
336
+ console.log('Pygsite returned error', result.status, text);
337
+ throw new Error('Failed to download character');
338
+ }
339
+
340
+ const jsonData = await result.json();
341
+ const characterData = jsonData?.character;
342
+
343
+ if (!characterData || typeof characterData !== 'object') {
344
+ console.error('Pygsite returned invalid character data', jsonData);
345
+ throw new Error('Failed to download character');
346
+ }
347
+
348
+ try {
349
+ const avatarUrl = characterData?.data?.avatar;
350
+
351
+ if (!avatarUrl) {
352
+ console.error('Pygsite character does not have an avatar', characterData);
353
+ throw new Error('Failed to download avatar');
354
+ }
355
+
356
+ const avatarResult = await fetch(avatarUrl);
357
+ const avatarBuffer = await avatarResult.buffer();
358
+
359
+ const cardBuffer = characterCardParser.write(avatarBuffer, JSON.stringify(characterData));
360
+
361
+ return {
362
+ buffer: cardBuffer,
363
+ fileName: `${sanitize(id)}.png`,
364
+ fileType: 'image/png',
365
+ };
366
+ } catch (e) {
367
+ console.error('Failed to download avatar, using JSON instead', e);
368
+ return {
369
+ buffer: Buffer.from(JSON.stringify(jsonData)),
370
+ fileName: `${sanitize(id)}.json`,
371
+ fileType: 'application/json',
372
+ };
373
+ }
374
+ }
375
+
376
+ /**
377
+ *
378
+ * @param {String} str
379
+ * @returns { { id: string, type: "character" | "lorebook" } | null }
380
+ */
381
+ function parseChubUrl(str) {
382
+ const splitStr = str.split('/');
383
+ const length = splitStr.length;
384
+
385
+ if (length < 2) {
386
+ return null;
387
+ }
388
+
389
+ let domainIndex = -1;
390
+
391
+ splitStr.forEach((part, index) => {
392
+ if (part === 'www.chub.ai' || part === 'chub.ai' || part === 'www.characterhub.org' || part === 'characterhub.org') {
393
+ domainIndex = index;
394
+ }
395
+ });
396
+
397
+ const lastTwo = domainIndex !== -1 ? splitStr.slice(domainIndex + 1) : splitStr;
398
+
399
+ const firstPart = lastTwo[0].toLowerCase();
400
+
401
+ if (firstPart === 'characters' || firstPart === 'lorebooks') {
402
+ const type = firstPart === 'characters' ? 'character' : 'lorebook';
403
+ const id = type === 'character' ? lastTwo.slice(1).join('/') : lastTwo.join('/');
404
+ return {
405
+ id: id,
406
+ type: type,
407
+ };
408
+ } else if (length === 2) {
409
+ return {
410
+ id: lastTwo.join('/'),
411
+ type: 'character',
412
+ };
413
+ }
414
+
415
+ return null;
416
+ }
417
+
418
+ // Warning: Some characters might not exist in JannyAI.me
419
+ async function downloadJannyCharacter(uuid) {
420
+ // This endpoint is being guarded behind Bot Fight Mode of Cloudflare
421
+ // So hosted ST on Azure/AWS/GCP/Collab might get blocked by IP
422
+ // Should work normally on self-host PC/Android
423
+ const result = await fetch('https://api.jannyai.com/api/v1/download', {
424
+ method: 'POST',
425
+ headers: { 'Content-Type': 'application/json' },
426
+ body: JSON.stringify({
427
+ 'characterId': uuid,
428
+ }),
429
+ });
430
+
431
+ if (result.ok) {
432
+ const downloadResult = await result.json();
433
+ if (downloadResult.status === 'ok') {
434
+ const imageResult = await fetch(downloadResult.downloadUrl);
435
+ const buffer = await imageResult.buffer();
436
+ const fileName = `${sanitize(uuid)}.png`;
437
+ const fileType = imageResult.headers.get('content-type');
438
+
439
+ return { buffer, fileName, fileType };
440
+ }
441
+ }
442
+
443
+ console.log('Janny returned error', result.statusText, await result.text());
444
+ throw new Error('Failed to download character');
445
+ }
446
+
447
+ //Download Character Cards from AICharactersCards.com (AICC) API.
448
+ async function downloadAICCCharacter(id) {
449
+ const apiURL = `https://aicharactercards.com/wp-json/pngapi/v1/image/${id}`;
450
+ try {
451
+ const response = await fetch(apiURL);
452
+ if (!response.ok) {
453
+ throw new Error(`Failed to download character: ${response.statusText}`);
454
+ }
455
+
456
+ const contentType = response.headers.get('content-type') || 'image/png'; // Default to 'image/png' if header is missing
457
+ const buffer = await response.buffer();
458
+ const fileName = `${sanitize(id)}.png`; // Assuming PNG, but adjust based on actual content or headers
459
+
460
+ return {
461
+ buffer: buffer,
462
+ fileName: fileName,
463
+ fileType: contentType,
464
+ };
465
+ } catch (error) {
466
+ console.error('Error downloading character:', error);
467
+ throw error;
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Parses an aicharactercards URL to extract the path.
473
+ * @param {string} url URL to parse
474
+ * @returns {string | null} AICC path
475
+ */
476
+ function parseAICC(url) {
477
+ const pattern = /^https?:\/\/aicharactercards\.com\/character-cards\/([^/]+)\/([^/]+)\/?$|([^/]+)\/([^/]+)$/;
478
+ const match = url.match(pattern);
479
+ if (match) {
480
+ // Match group 1 & 2 for full URL, 3 & 4 for relative path
481
+ return match[1] && match[2] ? `${match[1]}/${match[2]}` : `${match[3]}/${match[4]}`;
482
+ }
483
+ return null;
484
+ }
485
+
486
+ /**
487
+ * Download character card from generic url.
488
+ * @param {String} url
489
+ */
490
+ async function downloadGenericPng(url) {
491
+ try {
492
+ const result = await fetch(url);
493
+
494
+ if (result.ok) {
495
+ const buffer = await result.buffer();
496
+ const fileName = sanitize(result.url.split('?')[0].split('/').reverse()[0]);
497
+ const contentType = result.headers.get('content-type') || 'image/png'; //yoink it from AICC function lol
498
+
499
+ return {
500
+ buffer: buffer,
501
+ fileName: fileName,
502
+ fileType: contentType,
503
+ };
504
+ }
505
+ } catch (error) {
506
+ console.error('Error downloading file: ', error);
507
+ throw error;
508
+ }
509
+ return null;
510
+ }
511
+
512
+ /**
513
+ * Parse Risu Realm URL to extract the UUID.
514
+ * @param {string} url Risu Realm URL
515
+ * @returns {string | null} UUID of the character
516
+ */
517
+ function parseRisuUrl(url) {
518
+ // Example: https://realm.risuai.net/character/7adb0ed8d81855c820b3506980fb40f054ceef010ff0c4bab73730c0ebe92279
519
+ // or https://realm.risuai.net/character/7adb0ed8-d818-55c8-20b3-506980fb40f0
520
+ const pattern = /^https?:\/\/realm\.risuai\.net\/character\/([a-f0-9-]+)\/?$/i;
521
+ const match = url.match(pattern);
522
+ return match ? match[1] : null;
523
+ }
524
+
525
+ /**
526
+ * Download RisuAI character card
527
+ * @param {string} uuid UUID of the character
528
+ * @returns {Promise<{buffer: Buffer, fileName: string, fileType: string}>}
529
+ */
530
+ async function downloadRisuCharacter(uuid) {
531
+ const result = await fetch(`https://realm.risuai.net/api/v1/download/png-v3/${uuid}?non_commercial=true`);
532
+
533
+ if (!result.ok) {
534
+ const text = await result.text();
535
+ console.log('RisuAI returned error', result.statusText, text);
536
+ throw new Error('Failed to download character');
537
+ }
538
+
539
+ const buffer = await result.buffer();
540
+ const fileName = `${sanitize(uuid)}.png`;
541
+ const fileType = 'image/png';
542
+
543
+ return { buffer, fileName, fileType };
544
+ }
545
+
546
+ /**
547
+ * @param {String} url
548
+ * @returns {String | null } UUID of the character
549
+ */
550
+ function getUuidFromUrl(url) {
551
+ // Extract UUID from URL
552
+ const uuidRegex = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/;
553
+ const matches = url.match(uuidRegex);
554
+
555
+ // Check if UUID is found
556
+ const uuid = matches ? matches[0] : null;
557
+ return uuid;
558
+ }
559
+
560
+ /**
561
+ * Filter to get the domain host of a url instead of a blanket string search.
562
+ * @param {String} url URL to strip
563
+ * @returns {String} Domain name
564
+ */
565
+ function getHostFromUrl(url) {
566
+ try {
567
+ const urlObj = new URL(url);
568
+ return urlObj.hostname;
569
+ } catch {
570
+ return '';
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Checks if host is part of generic download source whitelist.
576
+ * @param {String} host Host to check
577
+ * @returns {boolean} If the host is on the whitelist.
578
+ */
579
+ function isHostWhitelisted(host) {
580
+ return WHITELIST_GENERIC_URL_DOWNLOAD_SOURCES.includes(host);
581
+ }
582
+
583
+ const router = express.Router();
584
+
585
+ router.post('/importURL', jsonParser, async (request, response) => {
586
+ if (!request.body.url) {
587
+ return response.sendStatus(400);
588
+ }
589
+
590
+ try {
591
+ const url = request.body.url;
592
+ const host = getHostFromUrl(url);
593
+ let result;
594
+ let type;
595
+
596
+ const isChub = host.includes('chub.ai') || host.includes('characterhub.org');
597
+ const isJannnyContent = host.includes('janitorai');
598
+ const isPygmalionContent = host.includes('pygmalion.chat');
599
+ const isAICharacterCardsContent = host.includes('aicharactercards.com');
600
+ const isRisu = host.includes('realm.risuai.net');
601
+ const isGeneric = isHostWhitelisted(host);
602
+
603
+ if (isPygmalionContent) {
604
+ const uuid = getUuidFromUrl(url);
605
+ if (!uuid) {
606
+ return response.sendStatus(404);
607
+ }
608
+
609
+ type = 'character';
610
+ result = await downloadPygmalionCharacter(uuid);
611
+ } else if (isJannnyContent) {
612
+ const uuid = getUuidFromUrl(url);
613
+ if (!uuid) {
614
+ return response.sendStatus(404);
615
+ }
616
+
617
+ type = 'character';
618
+ result = await downloadJannyCharacter(uuid);
619
+ } else if (isAICharacterCardsContent) {
620
+ const AICCParsed = parseAICC(url);
621
+ if (!AICCParsed) {
622
+ return response.sendStatus(404);
623
+ }
624
+ type = 'character';
625
+ result = await downloadAICCCharacter(AICCParsed);
626
+ } else if (isChub) {
627
+ const chubParsed = parseChubUrl(url);
628
+ type = chubParsed?.type;
629
+
630
+ if (chubParsed?.type === 'character') {
631
+ console.log('Downloading chub character:', chubParsed.id);
632
+ result = await downloadChubCharacter(chubParsed.id);
633
+ }
634
+ else if (chubParsed?.type === 'lorebook') {
635
+ console.log('Downloading chub lorebook:', chubParsed.id);
636
+ result = await downloadChubLorebook(chubParsed.id);
637
+ }
638
+ else {
639
+ return response.sendStatus(404);
640
+ }
641
+ } else if (isRisu) {
642
+ const uuid = parseRisuUrl(url);
643
+ if (!uuid) {
644
+ return response.sendStatus(404);
645
+ }
646
+
647
+ type = 'character';
648
+ result = await downloadRisuCharacter(uuid);
649
+ } else if (isGeneric) {
650
+ console.log('Downloading from generic url.');
651
+ type = 'character';
652
+ result = await downloadGenericPng(url);
653
+ } else {
654
+ return response.sendStatus(404);
655
+ }
656
+
657
+ if (!result) {
658
+ return response.sendStatus(404);
659
+ }
660
+
661
+ if (result.fileType) response.set('Content-Type', result.fileType);
662
+ response.set('Content-Disposition', `attachment; filename="${encodeURI(result.fileName)}"`);
663
+ response.set('X-Custom-Content-Type', type);
664
+ return response.send(result.buffer);
665
+ } catch (error) {
666
+ console.log('Importing custom content failed', error);
667
+ return response.sendStatus(500);
668
+ }
669
+ });
670
+
671
+ router.post('/importUUID', jsonParser, async (request, response) => {
672
+ if (!request.body.url) {
673
+ return response.sendStatus(400);
674
+ }
675
+
676
+ try {
677
+ const uuid = request.body.url;
678
+ let result;
679
+
680
+ const isJannny = uuid.includes('_character');
681
+ const isPygmalion = (!isJannny && uuid.length == 36);
682
+ const isAICC = uuid.startsWith('AICC/');
683
+ const uuidType = uuid.includes('lorebook') ? 'lorebook' : 'character';
684
+
685
+ if (isPygmalion) {
686
+ console.log('Downloading Pygmalion character:', uuid);
687
+ result = await downloadPygmalionCharacter(uuid);
688
+ } else if (isJannny) {
689
+ console.log('Downloading Janitor character:', uuid.split('_')[0]);
690
+ result = await downloadJannyCharacter(uuid.split('_')[0]);
691
+ } else if (isAICC) {
692
+ const [, author, card] = uuid.split('/');
693
+ console.log('Downloading AICC character:', `${author}/${card}`);
694
+ result = await downloadAICCCharacter(`${author}/${card}`);
695
+ } else {
696
+ if (uuidType === 'character') {
697
+ console.log('Downloading chub character:', uuid);
698
+ result = await downloadChubCharacter(uuid);
699
+ }
700
+ else if (uuidType === 'lorebook') {
701
+ console.log('Downloading chub lorebook:', uuid);
702
+ result = await downloadChubLorebook(uuid);
703
+ }
704
+ else {
705
+ return response.sendStatus(404);
706
+ }
707
+ }
708
+
709
+ if (result.fileType) response.set('Content-Type', result.fileType);
710
+ response.set('Content-Disposition', `attachment; filename="${result.fileName}"`);
711
+ response.set('X-Custom-Content-Type', uuidType);
712
+ return response.send(result.buffer);
713
+ } catch (error) {
714
+ console.log('Importing custom content failed', error);
715
+ return response.sendStatus(500);
716
+ }
717
+ });
718
+
719
+ module.exports = {
720
+ CONTENT_TYPES,
721
+ checkForNewContent,
722
+ getDefaultPresets,
723
+ getDefaultPresetFile,
724
+ router,
725
+ };
src/endpoints/extensions.js ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const express = require('express');
4
+ const { default: simpleGit } = require('simple-git');
5
+ const sanitize = require('sanitize-filename');
6
+ const { PUBLIC_DIRECTORIES } = require('../constants');
7
+ const { jsonParser } = require('../express-common');
8
+
9
+ /**
10
+ * This function extracts the extension information from the manifest file.
11
+ * @param {string} extensionPath - The path of the extension folder
12
+ * @returns {Promise<Object>} - Returns the manifest data as an object
13
+ */
14
+ async function getManifest(extensionPath) {
15
+ const manifestPath = path.join(extensionPath, 'manifest.json');
16
+
17
+ // Check if manifest.json exists
18
+ if (!fs.existsSync(manifestPath)) {
19
+ throw new Error(`Manifest file not found at ${manifestPath}`);
20
+ }
21
+
22
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
23
+ return manifest;
24
+ }
25
+
26
+ /**
27
+ * This function checks if the local repository is up-to-date with the remote repository.
28
+ * @param {string} extensionPath - The path of the extension folder
29
+ * @returns {Promise<Object>} - Returns the extension information as an object
30
+ */
31
+ async function checkIfRepoIsUpToDate(extensionPath) {
32
+ const git = simpleGit();
33
+ await git.cwd(extensionPath).fetch('origin');
34
+ const currentBranch = await git.cwd(extensionPath).branch();
35
+ const currentCommitHash = await git.cwd(extensionPath).revparse(['HEAD']);
36
+ const log = await git.cwd(extensionPath).log({
37
+ from: currentCommitHash,
38
+ to: `origin/${currentBranch.current}`,
39
+ });
40
+
41
+ // Fetch remote repository information
42
+ const remotes = await git.cwd(extensionPath).getRemotes(true);
43
+
44
+ return {
45
+ isUpToDate: log.total === 0,
46
+ remoteUrl: remotes[0].refs.fetch, // URL of the remote repository
47
+ };
48
+ }
49
+
50
+ const router = express.Router();
51
+
52
+ /**
53
+ * HTTP POST handler function to clone a git repository from a provided URL, read the extension manifest,
54
+ * and return extension information and path.
55
+ *
56
+ * @param {Object} request - HTTP Request object, expects a JSON body with a 'url' property.
57
+ * @param {Object} response - HTTP Response object used to respond to the HTTP request.
58
+ *
59
+ * @returns {void}
60
+ */
61
+ router.post('/install', jsonParser, async (request, response) => {
62
+ if (!request.body.url) {
63
+ return response.status(400).send('Bad Request: URL is required in the request body.');
64
+ }
65
+
66
+ try {
67
+ const git = simpleGit();
68
+
69
+ // make sure the third-party directory exists
70
+ if (!fs.existsSync(path.join(request.user.directories.extensions))) {
71
+ fs.mkdirSync(path.join(request.user.directories.extensions));
72
+ }
73
+
74
+ const url = request.body.url;
75
+ const extensionPath = path.join(request.user.directories.extensions, path.basename(url, '.git'));
76
+
77
+ if (fs.existsSync(extensionPath)) {
78
+ return response.status(409).send(`Directory already exists at ${extensionPath}`);
79
+ }
80
+
81
+ await git.clone(url, extensionPath, { '--depth': 1 });
82
+ console.log(`Extension has been cloned at ${extensionPath}`);
83
+
84
+
85
+ const { version, author, display_name } = await getManifest(extensionPath);
86
+
87
+
88
+ return response.send({ version, author, display_name, extensionPath });
89
+ } catch (error) {
90
+ console.log('Importing custom content failed', error);
91
+ return response.status(500).send(`Server Error: ${error.message}`);
92
+ }
93
+ });
94
+
95
+ /**
96
+ * HTTP POST handler function to pull the latest updates from a git repository
97
+ * based on the extension name provided in the request body. It returns the latest commit hash,
98
+ * the path of the extension, the status of the repository (whether it's up-to-date or not),
99
+ * and the remote URL of the repository.
100
+ *
101
+ * @param {Object} request - HTTP Request object, expects a JSON body with an 'extensionName' property.
102
+ * @param {Object} response - HTTP Response object used to respond to the HTTP request.
103
+ *
104
+ * @returns {void}
105
+ */
106
+ router.post('/update', jsonParser, async (request, response) => {
107
+ const git = simpleGit();
108
+ if (!request.body.extensionName) {
109
+ return response.status(400).send('Bad Request: extensionName is required in the request body.');
110
+ }
111
+
112
+ try {
113
+ const extensionName = request.body.extensionName;
114
+ const extensionPath = path.join(request.user.directories.extensions, extensionName);
115
+
116
+ if (!fs.existsSync(extensionPath)) {
117
+ return response.status(404).send(`Directory does not exist at ${extensionPath}`);
118
+ }
119
+
120
+ const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
121
+ const currentBranch = await git.cwd(extensionPath).branch();
122
+ if (!isUpToDate) {
123
+
124
+ await git.cwd(extensionPath).pull('origin', currentBranch.current);
125
+ console.log(`Extension has been updated at ${extensionPath}`);
126
+ } else {
127
+ console.log(`Extension is up to date at ${extensionPath}`);
128
+ }
129
+ await git.cwd(extensionPath).fetch('origin');
130
+ const fullCommitHash = await git.cwd(extensionPath).revparse(['HEAD']);
131
+ const shortCommitHash = fullCommitHash.slice(0, 7);
132
+
133
+ return response.send({ shortCommitHash, extensionPath, isUpToDate, remoteUrl });
134
+
135
+ } catch (error) {
136
+ console.log('Updating custom content failed', error);
137
+ return response.status(500).send(`Server Error: ${error.message}`);
138
+ }
139
+ });
140
+
141
+ /**
142
+ * HTTP POST handler function to get the current git commit hash and branch name for a given extension.
143
+ * It checks whether the repository is up-to-date with the remote, and returns the status along with
144
+ * the remote URL of the repository.
145
+ *
146
+ * @param {Object} request - HTTP Request object, expects a JSON body with an 'extensionName' property.
147
+ * @param {Object} response - HTTP Response object used to respond to the HTTP request.
148
+ *
149
+ * @returns {void}
150
+ */
151
+ router.post('/version', jsonParser, async (request, response) => {
152
+ const git = simpleGit();
153
+ if (!request.body.extensionName) {
154
+ return response.status(400).send('Bad Request: extensionName is required in the request body.');
155
+ }
156
+
157
+ try {
158
+ const extensionName = request.body.extensionName;
159
+ const extensionPath = path.join(request.user.directories.extensions, extensionName);
160
+
161
+ if (!fs.existsSync(extensionPath)) {
162
+ return response.status(404).send(`Directory does not exist at ${extensionPath}`);
163
+ }
164
+
165
+ const currentBranch = await git.cwd(extensionPath).branch();
166
+ // get only the working branch
167
+ const currentBranchName = currentBranch.current;
168
+ await git.cwd(extensionPath).fetch('origin');
169
+ const currentCommitHash = await git.cwd(extensionPath).revparse(['HEAD']);
170
+ console.log(currentBranch, currentCommitHash);
171
+ const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
172
+
173
+ return response.send({ currentBranchName, currentCommitHash, isUpToDate, remoteUrl });
174
+
175
+ } catch (error) {
176
+ console.log('Getting extension version failed', error);
177
+ return response.status(500).send(`Server Error: ${error.message}`);
178
+ }
179
+ });
180
+
181
+ /**
182
+ * HTTP POST handler function to delete a git repository based on the extension name provided in the request body.
183
+ *
184
+ * @param {Object} request - HTTP Request object, expects a JSON body with a 'url' property.
185
+ * @param {Object} response - HTTP Response object used to respond to the HTTP request.
186
+ *
187
+ * @returns {void}
188
+ */
189
+ router.post('/delete', jsonParser, async (request, response) => {
190
+ if (!request.body.extensionName) {
191
+ return response.status(400).send('Bad Request: extensionName is required in the request body.');
192
+ }
193
+
194
+ // Sanitize the extension name to prevent directory traversal
195
+ const extensionName = sanitize(request.body.extensionName);
196
+
197
+ try {
198
+ const extensionPath = path.join(request.user.directories.extensions, extensionName);
199
+
200
+ if (!fs.existsSync(extensionPath)) {
201
+ return response.status(404).send(`Directory does not exist at ${extensionPath}`);
202
+ }
203
+
204
+ await fs.promises.rm(extensionPath, { recursive: true });
205
+ console.log(`Extension has been deleted at ${extensionPath}`);
206
+
207
+ return response.send(`Extension has been deleted at ${extensionPath}`);
208
+
209
+ } catch (error) {
210
+ console.log('Deleting custom content failed', error);
211
+ return response.status(500).send(`Server Error: ${error.message}`);
212
+ }
213
+ });
214
+
215
+ /**
216
+ * Discover the extension folders
217
+ * If the folder is called third-party, search for subfolders instead
218
+ */
219
+ router.get('/discover', jsonParser, function (request, response) {
220
+ // get all folders in the extensions folder, except third-party
221
+ const extensions = fs
222
+ .readdirSync(PUBLIC_DIRECTORIES.extensions)
223
+ .filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.extensions, f)).isDirectory())
224
+ .filter(f => f !== 'third-party');
225
+
226
+ // get all folders in the third-party folder, if it exists
227
+
228
+ if (!fs.existsSync(path.join(request.user.directories.extensions))) {
229
+ return response.send(extensions);
230
+ }
231
+
232
+ const thirdPartyExtensions = fs
233
+ .readdirSync(path.join(request.user.directories.extensions))
234
+ .filter(f => fs.statSync(path.join(request.user.directories.extensions, f)).isDirectory());
235
+
236
+ // add the third-party extensions to the extensions array
237
+ extensions.push(...thirdPartyExtensions.map(f => `third-party/${f}`));
238
+ console.log(extensions);
239
+
240
+
241
+ return response.send(extensions);
242
+ });
243
+
244
+ module.exports = { router };
src/endpoints/files.js ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const writeFileSyncAtomic = require('write-file-atomic').sync;
4
+ const express = require('express');
5
+ const sanitize = require('sanitize-filename');
6
+ const router = express.Router();
7
+ const { validateAssetFileName } = require('./assets');
8
+ const { jsonParser } = require('../express-common');
9
+ const { clientRelativePath } = require('../util');
10
+
11
+ router.post('/sanitize-filename', jsonParser, async (request, response) => {
12
+ try {
13
+ const fileName = String(request.body.fileName);
14
+ if (!fileName) {
15
+ return response.status(400).send('No fileName specified');
16
+ }
17
+
18
+ const sanitizedFilename = sanitize(fileName);
19
+ return response.send({ fileName: sanitizedFilename });
20
+ } catch (error) {
21
+ console.log(error);
22
+ return response.sendStatus(500);
23
+ }
24
+ });
25
+
26
+ router.post('/upload', jsonParser, async (request, response) => {
27
+ try {
28
+ if (!request.body.name) {
29
+ return response.status(400).send('No upload name specified');
30
+ }
31
+
32
+ if (!request.body.data) {
33
+ return response.status(400).send('No upload data specified');
34
+ }
35
+
36
+ // Validate filename
37
+ const validation = validateAssetFileName(request.body.name);
38
+ if (validation.error)
39
+ return response.status(400).send(validation.message);
40
+
41
+ const pathToUpload = path.join(request.user.directories.files, request.body.name);
42
+ writeFileSyncAtomic(pathToUpload, request.body.data, 'base64');
43
+ const url = clientRelativePath(request.user.directories.root, pathToUpload);
44
+ console.log(`Uploaded file: ${url} from ${request.user.profile.handle}`);
45
+ return response.send({ path: url });
46
+ } catch (error) {
47
+ console.log(error);
48
+ return response.sendStatus(500);
49
+ }
50
+ });
51
+
52
+ router.post('/delete', jsonParser, async (request, response) => {
53
+ try {
54
+ if (!request.body.path) {
55
+ return response.status(400).send('No path specified');
56
+ }
57
+
58
+ const pathToDelete = path.join(request.user.directories.root, request.body.path);
59
+ if (!pathToDelete.startsWith(request.user.directories.files)) {
60
+ return response.status(400).send('Invalid path');
61
+ }
62
+
63
+ if (!fs.existsSync(pathToDelete)) {
64
+ return response.status(404).send('File not found');
65
+ }
66
+
67
+ fs.rmSync(pathToDelete);
68
+ console.log(`Deleted file: ${request.body.path} from ${request.user.profile.handle}`);
69
+ return response.sendStatus(200);
70
+ } catch (error) {
71
+ console.log(error);
72
+ return response.sendStatus(500);
73
+ }
74
+ });
75
+
76
+ router.post('/verify', jsonParser, async (request, response) => {
77
+ try {
78
+ if (!Array.isArray(request.body.urls)) {
79
+ return response.status(400).send('No URLs specified');
80
+ }
81
+
82
+ const verified = {};
83
+
84
+ for (const url of request.body.urls) {
85
+ const pathToVerify = path.join(request.user.directories.root, url);
86
+ if (!pathToVerify.startsWith(request.user.directories.files)) {
87
+ console.debug(`File verification: Invalid path: ${pathToVerify}`);
88
+ continue;
89
+ }
90
+ const fileExists = fs.existsSync(pathToVerify);
91
+ verified[url] = fileExists;
92
+ }
93
+
94
+ return response.send(verified);
95
+ } catch (error) {
96
+ console.log(error);
97
+ return response.sendStatus(500);
98
+ }
99
+ });
100
+
101
+ module.exports = { router };
src/endpoints/google.js ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { readSecret, SECRET_KEYS } = require('./secrets');
2
+ const fetch = require('node-fetch').default;
3
+ const express = require('express');
4
+ const { jsonParser } = require('../express-common');
5
+ const { GEMINI_SAFETY } = require('../constants');
6
+
7
+ const API_MAKERSUITE = 'https://generativelanguage.googleapis.com';
8
+
9
+ const router = express.Router();
10
+
11
+ router.post('/caption-image', jsonParser, async (request, response) => {
12
+ try {
13
+ const mimeType = request.body.image.split(';')[0].split(':')[1];
14
+ const base64Data = request.body.image.split(',')[1];
15
+ const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE);
16
+ const apiUrl = new URL(request.body.reverse_proxy || API_MAKERSUITE);
17
+ const model = request.body.model || 'gemini-pro-vision';
18
+ const url = `${apiUrl.origin}/v1beta/models/${model}:generateContent?key=${apiKey}`;
19
+ const body = {
20
+ contents: [{
21
+ parts: [
22
+ { text: request.body.prompt },
23
+ {
24
+ inlineData: {
25
+ mimeType: 'image/png', // It needs to specify a MIME type in data if it's not a PNG
26
+ data: mimeType === 'image/png' ? base64Data : request.body.image,
27
+ },
28
+ }],
29
+ }],
30
+ safetySettings: GEMINI_SAFETY,
31
+ generationConfig: { maxOutputTokens: 1000 },
32
+ };
33
+
34
+ console.log('Multimodal captioning request', model, body);
35
+
36
+ const result = await fetch(url, {
37
+ body: JSON.stringify(body),
38
+ method: 'POST',
39
+ headers: {
40
+ 'Content-Type': 'application/json',
41
+ },
42
+ timeout: 0,
43
+ });
44
+
45
+ if (!result.ok) {
46
+ const error = await result.json();
47
+ console.log(`Google AI Studio API returned error: ${result.status} ${result.statusText}`, error);
48
+ return response.status(result.status).send({ error: true });
49
+ }
50
+
51
+ const data = await result.json();
52
+ console.log('Multimodal captioning response', data);
53
+
54
+ const candidates = data?.candidates;
55
+ if (!candidates) {
56
+ return response.status(500).send('No candidates found, image was most likely filtered.');
57
+ }
58
+
59
+ const caption = candidates[0].content.parts[0].text;
60
+ if (!caption) {
61
+ return response.status(500).send('No caption found');
62
+ }
63
+
64
+ return response.json({ caption });
65
+ } catch (error) {
66
+ console.error(error);
67
+ response.status(500).send('Internal server error');
68
+ }
69
+ });
70
+
71
+ module.exports = { router };
src/endpoints/groups.js ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const express = require('express');
4
+ const sanitize = require('sanitize-filename');
5
+ const writeFileAtomicSync = require('write-file-atomic').sync;
6
+
7
+ const { jsonParser } = require('../express-common');
8
+ const { humanizedISO8601DateTime } = require('../util');
9
+
10
+ const router = express.Router();
11
+
12
+ router.post('/all', jsonParser, (request, response) => {
13
+ const groups = [];
14
+
15
+ if (!fs.existsSync(request.user.directories.groups)) {
16
+ fs.mkdirSync(request.user.directories.groups);
17
+ }
18
+
19
+ const files = fs.readdirSync(request.user.directories.groups).filter(x => path.extname(x) === '.json');
20
+ const chats = fs.readdirSync(request.user.directories.groupChats).filter(x => path.extname(x) === '.jsonl');
21
+
22
+ files.forEach(function (file) {
23
+ try {
24
+ const filePath = path.join(request.user.directories.groups, file);
25
+ const fileContents = fs.readFileSync(filePath, 'utf8');
26
+ const group = JSON.parse(fileContents);
27
+ const groupStat = fs.statSync(filePath);
28
+ group['date_added'] = groupStat.birthtimeMs;
29
+ group['create_date'] = humanizedISO8601DateTime(groupStat.birthtimeMs);
30
+
31
+ let chat_size = 0;
32
+ let date_last_chat = 0;
33
+
34
+ if (Array.isArray(group.chats) && Array.isArray(chats)) {
35
+ for (const chat of chats) {
36
+ if (group.chats.includes(path.parse(chat).name)) {
37
+ const chatStat = fs.statSync(path.join(request.user.directories.groupChats, chat));
38
+ chat_size += chatStat.size;
39
+ date_last_chat = Math.max(date_last_chat, chatStat.mtimeMs);
40
+ }
41
+ }
42
+ }
43
+
44
+ group['date_last_chat'] = date_last_chat;
45
+ group['chat_size'] = chat_size;
46
+ groups.push(group);
47
+ }
48
+ catch (error) {
49
+ console.error(error);
50
+ }
51
+ });
52
+
53
+ return response.send(groups);
54
+ });
55
+
56
+ router.post('/create', jsonParser, (request, response) => {
57
+ if (!request.body) {
58
+ return response.sendStatus(400);
59
+ }
60
+
61
+ const id = String(Date.now());
62
+ const groupMetadata = {
63
+ id: id,
64
+ name: request.body.name ?? 'New Group',
65
+ members: request.body.members ?? [],
66
+ avatar_url: request.body.avatar_url,
67
+ allow_self_responses: !!request.body.allow_self_responses,
68
+ activation_strategy: request.body.activation_strategy ?? 0,
69
+ generation_mode: request.body.generation_mode ?? 0,
70
+ disabled_members: request.body.disabled_members ?? [],
71
+ chat_metadata: request.body.chat_metadata ?? {},
72
+ fav: request.body.fav,
73
+ chat_id: request.body.chat_id ?? id,
74
+ chats: request.body.chats ?? [id],
75
+ auto_mode_delay: request.body.auto_mode_delay ?? 5,
76
+ generation_mode_join_prefix: request.body.generation_mode_join_prefix ?? '',
77
+ generation_mode_join_suffix: request.body.generation_mode_join_suffix ?? '',
78
+ };
79
+ const pathToFile = path.join(request.user.directories.groups, `${id}.json`);
80
+ const fileData = JSON.stringify(groupMetadata, null, 4);
81
+
82
+ if (!fs.existsSync(request.user.directories.groups)) {
83
+ fs.mkdirSync(request.user.directories.groups);
84
+ }
85
+
86
+ writeFileAtomicSync(pathToFile, fileData);
87
+ return response.send(groupMetadata);
88
+ });
89
+
90
+ router.post('/edit', jsonParser, (request, response) => {
91
+ if (!request.body || !request.body.id) {
92
+ return response.sendStatus(400);
93
+ }
94
+ const id = request.body.id;
95
+ const pathToFile = path.join(request.user.directories.groups, `${id}.json`);
96
+ const fileData = JSON.stringify(request.body, null, 4);
97
+
98
+ writeFileAtomicSync(pathToFile, fileData);
99
+ return response.send({ ok: true });
100
+ });
101
+
102
+ router.post('/delete', jsonParser, async (request, response) => {
103
+ if (!request.body || !request.body.id) {
104
+ return response.sendStatus(400);
105
+ }
106
+
107
+ const id = request.body.id;
108
+ const pathToGroup = path.join(request.user.directories.groups, sanitize(`${id}.json`));
109
+
110
+ try {
111
+ // Delete group chats
112
+ const group = JSON.parse(fs.readFileSync(pathToGroup, 'utf8'));
113
+
114
+ if (group && Array.isArray(group.chats)) {
115
+ for (const chat of group.chats) {
116
+ console.log('Deleting group chat', chat);
117
+ const pathToFile = path.join(request.user.directories.groupChats, `${id}.jsonl`);
118
+
119
+ if (fs.existsSync(pathToFile)) {
120
+ fs.rmSync(pathToFile);
121
+ }
122
+ }
123
+ }
124
+ } catch (error) {
125
+ console.error('Could not delete group chats. Clean them up manually.', error);
126
+ }
127
+
128
+ if (fs.existsSync(pathToGroup)) {
129
+ fs.rmSync(pathToGroup);
130
+ }
131
+
132
+ return response.send({ ok: true });
133
+ });
134
+
135
+ module.exports = { router };
src/endpoints/horde.js ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fetch = require('node-fetch').default;
2
+ const express = require('express');
3
+ const { AIHorde, ModelGenerationInputStableSamplers, ModelInterrogationFormTypes, HordeAsyncRequestStates } = require('@zeldafan0225/ai_horde');
4
+ const { getVersion, delay, Cache } = require('../util');
5
+ const { readSecret, SECRET_KEYS } = require('./secrets');
6
+ const { jsonParser } = require('../express-common');
7
+
8
+ const ANONYMOUS_KEY = '0000000000';
9
+ const cache = new Cache(60 * 1000);
10
+ const router = express.Router();
11
+
12
+ /**
13
+ * Returns the AIHorde client agent.
14
+ * @returns {Promise<string>} AIHorde client agent
15
+ */
16
+ async function getClientAgent() {
17
+ const version = await getVersion();
18
+ return version?.agent || 'SillyTavern:UNKNOWN:Cohee#1207';
19
+ }
20
+
21
+ /**
22
+ * Returns the AIHorde client.
23
+ * @returns {Promise<AIHorde>} AIHorde client
24
+ */
25
+ async function getHordeClient() {
26
+ const ai_horde = new AIHorde({
27
+ client_agent: await getClientAgent(),
28
+ });
29
+ return ai_horde;
30
+ }
31
+
32
+ /**
33
+ * Removes dirty no-no words from the prompt.
34
+ * Taken verbatim from KAI Lite's implementation (AGPLv3).
35
+ * https://github.com/LostRuins/lite.koboldai.net/blob/main/index.html#L7786C2-L7811C1
36
+ * @param {string} prompt Prompt to sanitize
37
+ * @returns {string} Sanitized prompt
38
+ */
39
+ function sanitizeHordeImagePrompt(prompt) {
40
+ if (!prompt) {
41
+ return '';
42
+ }
43
+
44
+ //to avoid flagging from some image models, always swap these words
45
+ prompt = prompt.replace(/\b(girl)\b/gmi, 'woman');
46
+ prompt = prompt.replace(/\b(boy)\b/gmi, 'man');
47
+ prompt = prompt.replace(/\b(girls)\b/gmi, 'women');
48
+ prompt = prompt.replace(/\b(boys)\b/gmi, 'men');
49
+ //always remove these high risk words from prompt, as they add little value to image gen while increasing the risk the prompt gets flagged
50
+ prompt = prompt.replace(/\b(under.age|under.aged|underage|underaged|loli|pedo|pedophile|(\w+).year.old|(\w+).years.old|minor|prepubescent|minors|shota)\b/gmi, '');
51
+ //replace risky subject nouns with person
52
+ prompt = prompt.replace(/\b(youngster|infant|baby|toddler|child|teen|kid|kiddie|kiddo|teenager|student|preteen|pre.teen)\b/gmi, 'person');
53
+ //remove risky adjectives and related words
54
+ prompt = prompt.replace(/\b(young|younger|youthful|youth|small|smaller|smallest|girly|boyish|lil|tiny|teenaged|lit[tl]le|school.aged|school|highschool|kindergarten|teens|children|kids)\b/gmi, '');
55
+
56
+ return prompt;
57
+ }
58
+
59
+ router.post('/text-workers', jsonParser, async (request, response) => {
60
+ try {
61
+ const cachedWorkers = cache.get('workers');
62
+
63
+ if (cachedWorkers && !request.body.force) {
64
+ return response.send(cachedWorkers);
65
+ }
66
+
67
+ const agent = await getClientAgent();
68
+ const fetchResult = await fetch('https://aihorde.net/api/v2/workers?type=text', {
69
+ headers: {
70
+ 'Client-Agent': agent,
71
+ },
72
+ });
73
+ const data = await fetchResult.json();
74
+ cache.set('workers', data);
75
+ return response.send(data);
76
+ } catch (error) {
77
+ console.error(error);
78
+ response.sendStatus(500);
79
+ }
80
+ });
81
+
82
+ router.post('/text-models', jsonParser, async (request, response) => {
83
+ try {
84
+ const cachedModels = cache.get('models');
85
+
86
+ if (cachedModels && !request.body.force) {
87
+ return response.send(cachedModels);
88
+ }
89
+
90
+ const agent = await getClientAgent();
91
+ const fetchResult = await fetch('https://aihorde.net/api/v2/status/models?type=text', {
92
+ headers: {
93
+ 'Client-Agent': agent,
94
+ },
95
+ });
96
+
97
+ const data = await fetchResult.json();
98
+ cache.set('models', data);
99
+ return response.send(data);
100
+ } catch (error) {
101
+ console.error(error);
102
+ response.sendStatus(500);
103
+ }
104
+ });
105
+
106
+ router.post('/status', jsonParser, async (_, response) => {
107
+ try {
108
+ const agent = await getClientAgent();
109
+ const fetchResult = await fetch('https://aihorde.net/api/v2/status/heartbeat', {
110
+ headers: {
111
+ 'Client-Agent': agent,
112
+ },
113
+ });
114
+
115
+ return response.send({ ok: fetchResult.ok });
116
+ } catch (error) {
117
+ console.error(error);
118
+ response.sendStatus(500);
119
+ }
120
+ });
121
+
122
+ router.post('/cancel-task', jsonParser, async (request, response) => {
123
+ try {
124
+ const taskId = request.body.taskId;
125
+ const agent = await getClientAgent();
126
+ const fetchResult = await fetch(`https://aihorde.net/api/v2/generate/text/status/${taskId}`, {
127
+ method: 'DELETE',
128
+ headers: {
129
+ 'Client-Agent': agent,
130
+ },
131
+ });
132
+
133
+ const data = await fetchResult.json();
134
+ console.log(`Cancelled Horde task ${taskId}`);
135
+ return response.send(data);
136
+ } catch (error) {
137
+ console.error(error);
138
+ response.sendStatus(500);
139
+ }
140
+ });
141
+
142
+ router.post('/task-status', jsonParser, async (request, response) => {
143
+ try {
144
+ const taskId = request.body.taskId;
145
+ const agent = await getClientAgent();
146
+ const fetchResult = await fetch(`https://aihorde.net/api/v2/generate/text/status/${taskId}`, {
147
+ headers: {
148
+ 'Client-Agent': agent,
149
+ },
150
+ });
151
+
152
+ const data = await fetchResult.json();
153
+ console.log(`Horde task ${taskId} status:`, data);
154
+ return response.send(data);
155
+ } catch (error) {
156
+ console.error(error);
157
+ response.sendStatus(500);
158
+ }
159
+ });
160
+
161
+ router.post('/generate-text', jsonParser, async (request, response) => {
162
+ const apiKey = readSecret(request.user.directories, SECRET_KEYS.HORDE) || ANONYMOUS_KEY;
163
+ const url = 'https://aihorde.net/api/v2/generate/text/async';
164
+ const agent = await getClientAgent();
165
+
166
+ console.log(request.body);
167
+ try {
168
+ const result = await fetch(url, {
169
+ method: 'POST',
170
+ body: JSON.stringify(request.body),
171
+ headers: {
172
+ 'Content-Type': 'application/json',
173
+ 'apikey': apiKey,
174
+ 'Client-Agent': agent,
175
+ },
176
+ });
177
+
178
+ if (!result.ok) {
179
+ const message = await result.text();
180
+ console.log('Horde returned an error:', message);
181
+ return response.send({ error: { message } });
182
+ }
183
+
184
+ const data = await result.json();
185
+ return response.send(data);
186
+ } catch (error) {
187
+ console.log(error);
188
+ return response.send({ error: true });
189
+ }
190
+ });
191
+
192
+ router.post('/sd-samplers', jsonParser, async (_, response) => {
193
+ try {
194
+ const samplers = Object.values(ModelGenerationInputStableSamplers);
195
+ response.send(samplers);
196
+ } catch (error) {
197
+ console.error(error);
198
+ response.sendStatus(500);
199
+ }
200
+ });
201
+
202
+ router.post('/sd-models', jsonParser, async (_, response) => {
203
+ try {
204
+ const ai_horde = await getHordeClient();
205
+ const models = await ai_horde.getModels();
206
+ response.send(models);
207
+ } catch (error) {
208
+ console.error(error);
209
+ response.sendStatus(500);
210
+ }
211
+ });
212
+
213
+ router.post('/caption-image', jsonParser, async (request, response) => {
214
+ try {
215
+ const api_key_horde = readSecret(request.user.directories, SECRET_KEYS.HORDE) || ANONYMOUS_KEY;
216
+ const ai_horde = await getHordeClient();
217
+ const result = await ai_horde.postAsyncInterrogate({
218
+ source_image: request.body.image,
219
+ forms: [{ name: ModelInterrogationFormTypes.caption }],
220
+ }, { token: api_key_horde });
221
+
222
+ if (!result.id) {
223
+ console.error('Image interrogation request is not satisfyable:', result.message || 'unknown error');
224
+ return response.sendStatus(400);
225
+ }
226
+
227
+ const MAX_ATTEMPTS = 200;
228
+ const CHECK_INTERVAL = 3000;
229
+
230
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
231
+ await delay(CHECK_INTERVAL);
232
+ const status = await ai_horde.getInterrogationStatus(result.id);
233
+ console.log(status);
234
+
235
+ if (status.state === HordeAsyncRequestStates.done) {
236
+
237
+ if (status.forms === undefined) {
238
+ console.error('Image interrogation request failed: no forms found.');
239
+ return response.sendStatus(500);
240
+ }
241
+
242
+ console.log('Image interrogation result:', status);
243
+ const caption = status?.forms[0]?.result?.caption || '';
244
+
245
+ if (!caption) {
246
+ console.error('Image interrogation request failed: no caption found.');
247
+ return response.sendStatus(500);
248
+ }
249
+
250
+ return response.send({ caption });
251
+ }
252
+
253
+ if (status.state === HordeAsyncRequestStates.faulted || status.state === HordeAsyncRequestStates.cancelled) {
254
+ console.log('Image interrogation request is not successful.');
255
+ return response.sendStatus(503);
256
+ }
257
+ }
258
+
259
+ } catch (error) {
260
+ console.error(error);
261
+ response.sendStatus(500);
262
+ }
263
+ });
264
+
265
+ router.post('/user-info', jsonParser, async (request, response) => {
266
+ const api_key_horde = readSecret(request.user.directories, SECRET_KEYS.HORDE);
267
+
268
+ if (!api_key_horde) {
269
+ return response.send({ anonymous: true });
270
+ }
271
+
272
+ try {
273
+ const ai_horde = await getHordeClient();
274
+ const user = await ai_horde.findUser({ token: api_key_horde });
275
+ return response.send(user);
276
+ } catch (error) {
277
+ console.error(error);
278
+ return response.sendStatus(500);
279
+ }
280
+ });
281
+
282
+ router.post('/generate-image', jsonParser, async (request, response) => {
283
+ if (!request.body.prompt) {
284
+ return response.sendStatus(400);
285
+ }
286
+
287
+ const MAX_ATTEMPTS = 200;
288
+ const CHECK_INTERVAL = 3000;
289
+ const PROMPT_THRESHOLD = 5000;
290
+
291
+ try {
292
+ const maxLength = PROMPT_THRESHOLD - String(request.body.negative_prompt).length - 5;
293
+ if (String(request.body.prompt).length > maxLength) {
294
+ console.log('Stable Horde prompt is too long, truncating...');
295
+ request.body.prompt = String(request.body.prompt).substring(0, maxLength);
296
+ }
297
+
298
+ // Sanitize prompt if requested
299
+ if (request.body.sanitize) {
300
+ const sanitized = sanitizeHordeImagePrompt(request.body.prompt);
301
+
302
+ if (request.body.prompt !== sanitized) {
303
+ console.log('Stable Horde prompt was sanitized.');
304
+ }
305
+
306
+ request.body.prompt = sanitized;
307
+ }
308
+
309
+ const api_key_horde = readSecret(request.user.directories, SECRET_KEYS.HORDE) || ANONYMOUS_KEY;
310
+ console.log('Stable Horde request:', request.body);
311
+
312
+ const ai_horde = await getHordeClient();
313
+ const generation = await ai_horde.postAsyncImageGenerate(
314
+ {
315
+ prompt: `${request.body.prompt} ### ${request.body.negative_prompt}`,
316
+ params:
317
+ {
318
+ sampler_name: request.body.sampler,
319
+ hires_fix: request.body.enable_hr,
320
+ // @ts-ignore - use_gfpgan param is not in the type definition, need to update to new ai_horde @ https://github.com/ZeldaFan0225/ai_horde/blob/main/index.ts
321
+ use_gfpgan: request.body.restore_faces,
322
+ cfg_scale: request.body.scale,
323
+ steps: request.body.steps,
324
+ width: request.body.width,
325
+ height: request.body.height,
326
+ karras: Boolean(request.body.karras),
327
+ clip_skip: request.body.clip_skip,
328
+ seed: request.body.seed >= 0 ? String(request.body.seed) : undefined,
329
+ n: 1,
330
+ },
331
+ r2: false,
332
+ nsfw: request.body.nfsw,
333
+ models: [request.body.model],
334
+ },
335
+ { token: api_key_horde });
336
+
337
+ if (!generation.id) {
338
+ console.error('Image generation request is not satisfyable:', generation.message || 'unknown error');
339
+ return response.sendStatus(400);
340
+ }
341
+
342
+ console.log('Horde image generation request:', generation);
343
+
344
+ const controller = new AbortController();
345
+ request.socket.removeAllListeners('close');
346
+ request.socket.on('close', function () {
347
+ console.log('Horde image generation request aborted.');
348
+ controller.abort();
349
+ if (generation.id) ai_horde.deleteImageGenerationRequest(generation.id);
350
+ });
351
+
352
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
353
+ controller.signal.throwIfAborted();
354
+ await delay(CHECK_INTERVAL);
355
+ const check = await ai_horde.getImageGenerationCheck(generation.id);
356
+ console.log(check);
357
+
358
+ if (check.done) {
359
+ const result = await ai_horde.getImageGenerationStatus(generation.id);
360
+ if (result.generations === undefined) return response.sendStatus(500);
361
+ return response.send(result.generations[0].img);
362
+ }
363
+
364
+ /*
365
+ if (!check.is_possible) {
366
+ return response.sendStatus(503);
367
+ }
368
+ */
369
+
370
+ if (check.faulted) {
371
+ return response.sendStatus(500);
372
+ }
373
+ }
374
+
375
+ return response.sendStatus(504);
376
+ } catch (error) {
377
+ console.error(error);
378
+ return response.sendStatus(500);
379
+ }
380
+ });
381
+
382
+ module.exports = { router };
src/endpoints/images.js ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const express = require('express');
4
+ const sanitize = require('sanitize-filename');
5
+
6
+ const { jsonParser } = require('../express-common');
7
+ const { clientRelativePath, removeFileExtension, getImages } = require('../util');
8
+
9
+ /**
10
+ * Ensure the directory for the provided file path exists.
11
+ * If not, it will recursively create the directory.
12
+ *
13
+ * @param {string} filePath - The full path of the file for which the directory should be ensured.
14
+ */
15
+ function ensureDirectoryExistence(filePath) {
16
+ const dirname = path.dirname(filePath);
17
+ if (fs.existsSync(dirname)) {
18
+ return true;
19
+ }
20
+ ensureDirectoryExistence(dirname);
21
+ fs.mkdirSync(dirname);
22
+ }
23
+
24
+ const router = express.Router();
25
+
26
+ /**
27
+ * Endpoint to handle image uploads.
28
+ * The image should be provided in the request body in base64 format.
29
+ * Optionally, a character name can be provided to save the image in a sub-folder.
30
+ *
31
+ * @route POST /api/images/upload
32
+ * @param {Object} request.body - The request payload.
33
+ * @param {string} request.body.image - The base64 encoded image data.
34
+ * @param {string} [request.body.ch_name] - Optional character name to determine the sub-directory.
35
+ * @returns {Object} response - The response object containing the path where the image was saved.
36
+ */
37
+ router.post('/upload', jsonParser, async (request, response) => {
38
+ // Check for image data
39
+ if (!request.body || !request.body.image) {
40
+ return response.status(400).send({ error: 'No image data provided' });
41
+ }
42
+
43
+ try {
44
+ // Extracting the base64 data and the image format
45
+ const splitParts = request.body.image.split(',');
46
+ const format = splitParts[0].split(';')[0].split('/')[1];
47
+ const base64Data = splitParts[1];
48
+ const validFormat = ['png', 'jpg', 'webp', 'jpeg', 'gif'].includes(format);
49
+ if (!validFormat) {
50
+ return response.status(400).send({ error: 'Invalid image format' });
51
+ }
52
+
53
+ // Constructing filename and path
54
+ let filename;
55
+ if (request.body.filename) {
56
+ filename = `${removeFileExtension(request.body.filename)}.${format}`;
57
+ } else {
58
+ filename = `${Date.now()}.${format}`;
59
+ }
60
+
61
+ // if character is defined, save to a sub folder for that character
62
+ let pathToNewFile = path.join(request.user.directories.userImages, sanitize(filename));
63
+ if (request.body.ch_name) {
64
+ pathToNewFile = path.join(request.user.directories.userImages, sanitize(request.body.ch_name), sanitize(filename));
65
+ }
66
+
67
+ ensureDirectoryExistence(pathToNewFile);
68
+ const imageBuffer = Buffer.from(base64Data, 'base64');
69
+ await fs.promises.writeFile(pathToNewFile, imageBuffer);
70
+ response.send({ path: clientRelativePath(request.user.directories.root, pathToNewFile) });
71
+ } catch (error) {
72
+ console.log(error);
73
+ response.status(500).send({ error: 'Failed to save the image' });
74
+ }
75
+ });
76
+
77
+ router.post('/list/:folder', (request, response) => {
78
+ const directoryPath = path.join(request.user.directories.userImages, sanitize(request.params.folder));
79
+
80
+ if (!fs.existsSync(directoryPath)) {
81
+ fs.mkdirSync(directoryPath, { recursive: true });
82
+ }
83
+
84
+ try {
85
+ const images = getImages(directoryPath, 'date');
86
+ return response.send(images);
87
+ } catch (error) {
88
+ console.error(error);
89
+ return response.status(500).send({ error: 'Unable to retrieve files' });
90
+ }
91
+ });
92
+
93
+ module.exports = { router };
src/endpoints/moving-ui.js ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const path = require('path');
2
+ const express = require('express');
3
+ const sanitize = require('sanitize-filename');
4
+ const writeFileAtomicSync = require('write-file-atomic').sync;
5
+
6
+ const { jsonParser } = require('../express-common');
7
+
8
+ const router = express.Router();
9
+
10
+ router.post('/save', jsonParser, (request, response) => {
11
+ if (!request.body || !request.body.name) {
12
+ return response.sendStatus(400);
13
+ }
14
+
15
+ const filename = path.join(request.user.directories.movingUI, sanitize(request.body.name) + '.json');
16
+ writeFileAtomicSync(filename, JSON.stringify(request.body, null, 4), 'utf8');
17
+
18
+ return response.sendStatus(200);
19
+ });
20
+
21
+ module.exports = { router };
src/endpoints/novelai.js ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fetch = require('node-fetch').default;
2
+ const express = require('express');
3
+ const util = require('util');
4
+ const { readSecret, SECRET_KEYS } = require('./secrets');
5
+ const { readAllChunks, extractFileFromZipBuffer, forwardFetchResponse } = require('../util');
6
+ const { jsonParser } = require('../express-common');
7
+
8
+ const API_NOVELAI = 'https://api.novelai.net';
9
+ const IMAGE_NOVELAI = 'https://image.novelai.net';
10
+
11
+ // Ban bracket generation, plus defaults
12
+ const badWordsList = [
13
+ [3], [49356], [1431], [31715], [34387], [20765], [30702], [10691], [49333], [1266],
14
+ [19438], [43145], [26523], [41471], [2936], [85, 85], [49332], [7286], [1115], [24],
15
+ ];
16
+
17
+ const hypeBotBadWordsList = [
18
+ [58], [60], [90], [92], [685], [1391], [1782], [2361], [3693], [4083], [4357], [4895],
19
+ [5512], [5974], [7131], [8183], [8351], [8762], [8964], [8973], [9063], [11208],
20
+ [11709], [11907], [11919], [12878], [12962], [13018], [13412], [14631], [14692],
21
+ [14980], [15090], [15437], [16151], [16410], [16589], [17241], [17414], [17635],
22
+ [17816], [17912], [18083], [18161], [18477], [19629], [19779], [19953], [20520],
23
+ [20598], [20662], [20740], [21476], [21737], [22133], [22241], [22345], [22935],
24
+ [23330], [23785], [23834], [23884], [25295], [25597], [25719], [25787], [25915],
25
+ [26076], [26358], [26398], [26894], [26933], [27007], [27422], [28013], [29164],
26
+ [29225], [29342], [29565], [29795], [30072], [30109], [30138], [30866], [31161],
27
+ [31478], [32092], [32239], [32509], [33116], [33250], [33761], [34171], [34758],
28
+ [34949], [35944], [36338], [36463], [36563], [36786], [36796], [36937], [37250],
29
+ [37913], [37981], [38165], [38362], [38381], [38430], [38892], [39850], [39893],
30
+ [41832], [41888], [42535], [42669], [42785], [42924], [43839], [44438], [44587],
31
+ [44926], [45144], [45297], [46110], [46570], [46581], [46956], [47175], [47182],
32
+ [47527], [47715], [48600], [48683], [48688], [48874], [48999], [49074], [49082],
33
+ [49146], [49946], [10221], [4841], [1427], [2602, 834], [29343], [37405], [35780], [2602], [50256],
34
+ ];
35
+
36
+ // Used for phrase repetition penalty
37
+ const repPenaltyAllowList = [
38
+ [49256, 49264, 49231, 49230, 49287, 85, 49255, 49399, 49262, 336, 333, 432, 363, 468, 492, 745, 401, 426, 623, 794,
39
+ 1096, 2919, 2072, 7379, 1259, 2110, 620, 526, 487, 16562, 603, 805, 761, 2681, 942, 8917, 653, 3513, 506, 5301,
40
+ 562, 5010, 614, 10942, 539, 2976, 462, 5189, 567, 2032, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 588,
41
+ 803, 1040, 49209, 4, 5, 6, 7, 8, 9, 10, 11, 12],
42
+ ];
43
+
44
+ // Ban the dinkus and asterism
45
+ const logitBiasExp = [
46
+ { 'sequence': [23], 'bias': -0.08, 'ensure_sequence_finish': false, 'generate_once': false },
47
+ { 'sequence': [21], 'bias': -0.08, 'ensure_sequence_finish': false, 'generate_once': false },
48
+ ];
49
+
50
+ function getBadWordsList(model) {
51
+ let list = [];
52
+
53
+ if (model.includes('hypebot')) {
54
+ list = hypeBotBadWordsList;
55
+ }
56
+
57
+ if (model.includes('clio') || model.includes('kayra')) {
58
+ list = badWordsList;
59
+ }
60
+
61
+ // Clone the list so we don't modify the original
62
+ return list.slice();
63
+ }
64
+
65
+ const router = express.Router();
66
+
67
+ router.post('/status', jsonParser, async function (req, res) {
68
+ if (!req.body) return res.sendStatus(400);
69
+ const api_key_novel = readSecret(req.user.directories, SECRET_KEYS.NOVEL);
70
+
71
+ if (!api_key_novel) {
72
+ console.log('NovelAI Access Token is missing.');
73
+ return res.sendStatus(400);
74
+ }
75
+
76
+ try {
77
+ const response = await fetch(API_NOVELAI + '/user/subscription', {
78
+ method: 'GET',
79
+ headers: {
80
+ 'Content-Type': 'application/json',
81
+ 'Authorization': 'Bearer ' + api_key_novel,
82
+ },
83
+ });
84
+
85
+ if (response.ok) {
86
+ const data = await response.json();
87
+ return res.send(data);
88
+ } else if (response.status == 401) {
89
+ console.log('NovelAI Access Token is incorrect.');
90
+ return res.send({ error: true });
91
+ }
92
+ else {
93
+ console.log('NovelAI returned an error:', response.statusText);
94
+ return res.send({ error: true });
95
+ }
96
+ } catch (error) {
97
+ console.log(error);
98
+ return res.send({ error: true });
99
+ }
100
+ });
101
+
102
+ router.post('/generate', jsonParser, async function (req, res) {
103
+ if (!req.body) return res.sendStatus(400);
104
+
105
+ const api_key_novel = readSecret(req.user.directories, SECRET_KEYS.NOVEL);
106
+
107
+ if (!api_key_novel) {
108
+ console.log('NovelAI Access Token is missing.');
109
+ return res.sendStatus(400);
110
+ }
111
+
112
+ const controller = new AbortController();
113
+ req.socket.removeAllListeners('close');
114
+ req.socket.on('close', function () {
115
+ controller.abort();
116
+ });
117
+
118
+ const isNewModel = (req.body.model.includes('clio') || req.body.model.includes('kayra'));
119
+ const badWordsList = getBadWordsList(req.body.model);
120
+
121
+ // Add customized bad words for Clio and Kayra
122
+ if (isNewModel && Array.isArray(req.body.bad_words_ids)) {
123
+ for (const badWord of req.body.bad_words_ids) {
124
+ if (Array.isArray(badWord) && badWord.every(x => Number.isInteger(x))) {
125
+ badWordsList.push(badWord);
126
+ }
127
+ }
128
+ }
129
+
130
+ // Remove empty arrays from bad words list
131
+ for (const badWord of badWordsList) {
132
+ if (badWord.length === 0) {
133
+ badWordsList.splice(badWordsList.indexOf(badWord), 1);
134
+ }
135
+ }
136
+
137
+ // Add default biases for dinkus and asterism
138
+ const logit_bias_exp = isNewModel ? logitBiasExp.slice() : [];
139
+
140
+ if (Array.isArray(logit_bias_exp) && Array.isArray(req.body.logit_bias_exp)) {
141
+ logit_bias_exp.push(...req.body.logit_bias_exp);
142
+ }
143
+
144
+ const data = {
145
+ 'input': req.body.input,
146
+ 'model': req.body.model,
147
+ 'parameters': {
148
+ 'use_string': req.body.use_string ?? true,
149
+ 'temperature': req.body.temperature,
150
+ 'max_length': req.body.max_length,
151
+ 'min_length': req.body.min_length,
152
+ 'tail_free_sampling': req.body.tail_free_sampling,
153
+ 'repetition_penalty': req.body.repetition_penalty,
154
+ 'repetition_penalty_range': req.body.repetition_penalty_range,
155
+ 'repetition_penalty_slope': req.body.repetition_penalty_slope,
156
+ 'repetition_penalty_frequency': req.body.repetition_penalty_frequency,
157
+ 'repetition_penalty_presence': req.body.repetition_penalty_presence,
158
+ 'repetition_penalty_whitelist': isNewModel ? repPenaltyAllowList : null,
159
+ 'top_a': req.body.top_a,
160
+ 'top_p': req.body.top_p,
161
+ 'top_k': req.body.top_k,
162
+ 'typical_p': req.body.typical_p,
163
+ 'mirostat_lr': req.body.mirostat_lr,
164
+ 'mirostat_tau': req.body.mirostat_tau,
165
+ 'cfg_scale': req.body.cfg_scale,
166
+ 'cfg_uc': req.body.cfg_uc,
167
+ 'phrase_rep_pen': req.body.phrase_rep_pen,
168
+ 'stop_sequences': req.body.stop_sequences,
169
+ 'bad_words_ids': badWordsList.length ? badWordsList : null,
170
+ 'logit_bias_exp': logit_bias_exp,
171
+ 'generate_until_sentence': req.body.generate_until_sentence,
172
+ 'use_cache': req.body.use_cache,
173
+ 'return_full_text': req.body.return_full_text,
174
+ 'prefix': req.body.prefix,
175
+ 'order': req.body.order,
176
+ 'num_logprobs': req.body.num_logprobs,
177
+ },
178
+ };
179
+
180
+ // Tells the model to stop generation at '>'
181
+ if ('theme_textadventure' === req.body.prefix &&
182
+ (true === req.body.model.includes('clio') ||
183
+ true === req.body.model.includes('kayra'))) {
184
+ data.parameters.eos_token_id = 49405;
185
+ }
186
+
187
+ console.log(util.inspect(data, { depth: 4 }));
188
+
189
+ const args = {
190
+ body: JSON.stringify(data),
191
+ headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + api_key_novel },
192
+ signal: controller.signal,
193
+ };
194
+
195
+ try {
196
+ const url = req.body.streaming ? `${API_NOVELAI}/ai/generate-stream` : `${API_NOVELAI}/ai/generate`;
197
+ const response = await fetch(url, { method: 'POST', timeout: 0, ...args });
198
+
199
+ if (req.body.streaming) {
200
+ // Pipe remote SSE stream to Express response
201
+ forwardFetchResponse(response, res);
202
+ } else {
203
+ if (!response.ok) {
204
+ const text = await response.text();
205
+ let message = text;
206
+ console.log(`Novel API returned error: ${response.status} ${response.statusText} ${text}`);
207
+
208
+ try {
209
+ const data = JSON.parse(text);
210
+ message = data.message;
211
+ }
212
+ catch {
213
+ // ignore
214
+ }
215
+
216
+ return res.status(response.status).send({ error: { message } });
217
+ }
218
+
219
+ const data = await response.json();
220
+ console.log('NovelAI Output', data?.output);
221
+ return res.send(data);
222
+ }
223
+ } catch (error) {
224
+ return res.send({ error: true });
225
+ }
226
+ });
227
+
228
+ router.post('/generate-image', jsonParser, async (request, response) => {
229
+ if (!request.body) {
230
+ return response.sendStatus(400);
231
+ }
232
+
233
+ const key = readSecret(request.user.directories, SECRET_KEYS.NOVEL);
234
+
235
+ if (!key) {
236
+ console.log('NovelAI Access Token is missing.');
237
+ return response.sendStatus(400);
238
+ }
239
+
240
+ try {
241
+ console.log('NAI Diffusion request:', request.body);
242
+ const generateUrl = `${IMAGE_NOVELAI}/ai/generate-image`;
243
+ const generateResult = await fetch(generateUrl, {
244
+ method: 'POST',
245
+ headers: {
246
+ 'Authorization': `Bearer ${key}`,
247
+ 'Content-Type': 'application/json',
248
+ },
249
+ body: JSON.stringify({
250
+ action: 'generate',
251
+ input: request.body.prompt,
252
+ model: request.body.model ?? 'nai-diffusion',
253
+ parameters: {
254
+ negative_prompt: request.body.negative_prompt ?? '',
255
+ height: request.body.height ?? 512,
256
+ width: request.body.width ?? 512,
257
+ scale: request.body.scale ?? 9,
258
+ seed: request.body.seed >= 0 ? request.body.seed : Math.floor(Math.random() * 9999999999),
259
+ sampler: request.body.sampler ?? 'k_dpmpp_2m',
260
+ steps: request.body.steps ?? 28,
261
+ n_samples: 1,
262
+ // NAI handholding for prompts
263
+ ucPreset: 0,
264
+ qualityToggle: false,
265
+ add_original_image: false,
266
+ controlnet_strength: 1,
267
+ dynamic_thresholding: request.body.decrisper ?? false,
268
+ legacy: false,
269
+ sm: request.body.sm ?? false,
270
+ sm_dyn: request.body.sm_dyn ?? false,
271
+ uncond_scale: 1,
272
+ },
273
+ }),
274
+ });
275
+
276
+ if (!generateResult.ok) {
277
+ const text = await generateResult.text();
278
+ console.log('NovelAI returned an error.', generateResult.statusText, text);
279
+ return response.sendStatus(500);
280
+ }
281
+
282
+ const archiveBuffer = await generateResult.arrayBuffer();
283
+ const imageBuffer = await extractFileFromZipBuffer(archiveBuffer, '.png');
284
+
285
+ if (!imageBuffer) {
286
+ console.warn('NovelAI generated an image, but the PNG file was not found.');
287
+ return response.sendStatus(500);
288
+ }
289
+
290
+ const originalBase64 = imageBuffer.toString('base64');
291
+
292
+ // No upscaling
293
+ if (isNaN(request.body.upscale_ratio) || request.body.upscale_ratio <= 1) {
294
+ return response.send(originalBase64);
295
+ }
296
+
297
+ try {
298
+ console.debug('Upscaling image...');
299
+ const upscaleUrl = `${API_NOVELAI}/ai/upscale`;
300
+ const upscaleResult = await fetch(upscaleUrl, {
301
+ method: 'POST',
302
+ headers: {
303
+ 'Authorization': `Bearer ${key}`,
304
+ 'Content-Type': 'application/json',
305
+ },
306
+ body: JSON.stringify({
307
+ image: originalBase64,
308
+ height: request.body.height,
309
+ width: request.body.width,
310
+ scale: request.body.upscale_ratio,
311
+ }),
312
+ });
313
+
314
+ if (!upscaleResult.ok) {
315
+ throw new Error('NovelAI returned an error.');
316
+ }
317
+
318
+ const upscaledArchiveBuffer = await upscaleResult.arrayBuffer();
319
+ const upscaledImageBuffer = await extractFileFromZipBuffer(upscaledArchiveBuffer, '.png');
320
+
321
+ if (!upscaledImageBuffer) {
322
+ throw new Error('NovelAI upscaled an image, but the PNG file was not found.');
323
+ }
324
+
325
+ const upscaledBase64 = upscaledImageBuffer.toString('base64');
326
+
327
+ return response.send(upscaledBase64);
328
+ } catch (error) {
329
+ console.warn('NovelAI generated an image, but upscaling failed. Returning original image.');
330
+ return response.send(originalBase64);
331
+ }
332
+ } catch (error) {
333
+ console.log(error);
334
+ return response.sendStatus(500);
335
+ }
336
+ });
337
+
338
+ router.post('/generate-voice', jsonParser, async (request, response) => {
339
+ const token = readSecret(request.user.directories, SECRET_KEYS.NOVEL);
340
+
341
+ if (!token) {
342
+ console.log('NovelAI Access Token is missing.');
343
+ return response.sendStatus(400);
344
+ }
345
+
346
+ const text = request.body.text;
347
+ const voice = request.body.voice;
348
+
349
+ if (!text || !voice) {
350
+ return response.sendStatus(400);
351
+ }
352
+
353
+ try {
354
+ const url = `${API_NOVELAI}/ai/generate-voice?text=${encodeURIComponent(text)}&voice=-1&seed=${encodeURIComponent(voice)}&opus=false&version=v2`;
355
+ const result = await fetch(url, {
356
+ method: 'GET',
357
+ headers: {
358
+ 'Authorization': `Bearer ${token}`,
359
+ 'Accept': 'audio/mpeg',
360
+ },
361
+ timeout: 0,
362
+ });
363
+
364
+ if (!result.ok) {
365
+ const errorText = await result.text();
366
+ console.log('NovelAI returned an error.', result.statusText, errorText);
367
+ return response.sendStatus(500);
368
+ }
369
+
370
+ const chunks = await readAllChunks(result.body);
371
+ const buffer = Buffer.concat(chunks);
372
+ response.setHeader('Content-Type', 'audio/mpeg');
373
+ return response.send(buffer);
374
+ }
375
+ catch (error) {
376
+ console.error(error);
377
+ return response.sendStatus(500);
378
+ }
379
+ });
380
+
381
+ module.exports = { router };
src/endpoints/openai.js ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { readSecret, SECRET_KEYS } = require('./secrets');
2
+ const fetch = require('node-fetch').default;
3
+ const express = require('express');
4
+ const FormData = require('form-data');
5
+ const fs = require('fs');
6
+ const { jsonParser, urlencodedParser } = require('../express-common');
7
+ const { getConfigValue, mergeObjectWithYaml, excludeKeysByYaml, trimV1 } = require('../util');
8
+ const { setAdditionalHeaders } = require('../additional-headers');
9
+ const { OPENROUTER_HEADERS } = require('../constants');
10
+
11
+ const router = express.Router();
12
+
13
+ router.post('/caption-image', jsonParser, async (request, response) => {
14
+ try {
15
+ let key = '';
16
+ let headers = {};
17
+ let bodyParams = {};
18
+
19
+ if (request.body.api === 'openai' && !request.body.reverse_proxy) {
20
+ key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);
21
+ }
22
+
23
+ if (request.body.api === 'openrouter' && !request.body.reverse_proxy) {
24
+ key = readSecret(request.user.directories, SECRET_KEYS.OPENROUTER);
25
+ }
26
+
27
+ if (request.body.reverse_proxy && request.body.proxy_password) {
28
+ key = request.body.proxy_password;
29
+ }
30
+
31
+ if (request.body.api === 'custom') {
32
+ key = readSecret(request.user.directories, SECRET_KEYS.CUSTOM);
33
+ mergeObjectWithYaml(bodyParams, request.body.custom_include_body);
34
+ mergeObjectWithYaml(headers, request.body.custom_include_headers);
35
+ }
36
+
37
+ if (request.body.api === 'ooba') {
38
+ key = readSecret(request.user.directories, SECRET_KEYS.OOBA);
39
+ bodyParams.temperature = 0.1;
40
+ }
41
+
42
+ if (request.body.api === 'koboldcpp') {
43
+ key = readSecret(request.user.directories, SECRET_KEYS.KOBOLDCPP);
44
+ }
45
+
46
+ if (request.body.api === 'vllm') {
47
+ key = readSecret(request.user.directories, SECRET_KEYS.VLLM);
48
+ }
49
+
50
+ if (request.body.api === 'zerooneai') {
51
+ key = readSecret(request.user.directories, SECRET_KEYS.ZEROONEAI);
52
+ }
53
+
54
+ if (!key && !request.body.reverse_proxy && ['custom', 'ooba', 'koboldcpp', 'vllm'].includes(request.body.api) === false) {
55
+ console.log('No key found for API', request.body.api);
56
+ return response.sendStatus(400);
57
+ }
58
+
59
+ const body = {
60
+ model: request.body.model,
61
+ messages: [
62
+ {
63
+ role: 'user',
64
+ content: [
65
+ { type: 'text', text: request.body.prompt },
66
+ { type: 'image_url', image_url: { 'url': request.body.image } },
67
+ ],
68
+ },
69
+ ],
70
+ ...bodyParams,
71
+ };
72
+
73
+ const captionSystemPrompt = getConfigValue('openai.captionSystemPrompt');
74
+ if (captionSystemPrompt) {
75
+ body.messages.unshift({
76
+ role: 'system',
77
+ content: captionSystemPrompt,
78
+ });
79
+ }
80
+
81
+ if (request.body.api === 'custom') {
82
+ excludeKeysByYaml(body, request.body.custom_exclude_body);
83
+ }
84
+
85
+ console.log('Multimodal captioning request', body);
86
+
87
+ let apiUrl = '';
88
+
89
+ if (request.body.api === 'openrouter') {
90
+ apiUrl = 'https://openrouter.ai/api/v1/chat/completions';
91
+ Object.assign(headers, OPENROUTER_HEADERS);
92
+ }
93
+
94
+ if (request.body.api === 'openai') {
95
+ apiUrl = 'https://api.openai.com/v1/chat/completions';
96
+ }
97
+
98
+ if (request.body.reverse_proxy) {
99
+ apiUrl = `${request.body.reverse_proxy}/chat/completions`;
100
+ }
101
+
102
+ if (request.body.api === 'custom') {
103
+ apiUrl = `${request.body.server_url}/chat/completions`;
104
+ }
105
+
106
+ if (request.body.api === 'zerooneai') {
107
+ apiUrl = 'https://api.01.ai/v1/chat/completions';
108
+ }
109
+
110
+ if (request.body.api === 'ooba') {
111
+ apiUrl = `${trimV1(request.body.server_url)}/v1/chat/completions`;
112
+ const imgMessage = body.messages.pop();
113
+ body.messages.push({
114
+ role: 'user',
115
+ content: imgMessage?.content?.[0]?.text,
116
+ });
117
+ body.messages.push({
118
+ role: 'user',
119
+ content: [],
120
+ image_url: imgMessage?.content?.[1]?.image_url?.url,
121
+ });
122
+ }
123
+
124
+ if (request.body.api === 'koboldcpp' || request.body.api === 'vllm') {
125
+ apiUrl = `${trimV1(request.body.server_url)}/v1/chat/completions`;
126
+ }
127
+
128
+ setAdditionalHeaders(request, { headers }, apiUrl);
129
+
130
+ const result = await fetch(apiUrl, {
131
+ method: 'POST',
132
+ headers: {
133
+ 'Content-Type': 'application/json',
134
+ Authorization: `Bearer ${key}`,
135
+ ...headers,
136
+ },
137
+ body: JSON.stringify(body),
138
+ timeout: 0,
139
+ });
140
+
141
+ if (!result.ok) {
142
+ const text = await result.text();
143
+ console.log('Multimodal captioning request failed', result.statusText, text);
144
+ return response.status(500).send(text);
145
+ }
146
+
147
+ const data = await result.json();
148
+ console.log('Multimodal captioning response', data);
149
+ const caption = data?.choices[0]?.message?.content;
150
+
151
+ if (!caption) {
152
+ return response.status(500).send('No caption found');
153
+ }
154
+
155
+ return response.json({ caption });
156
+ }
157
+ catch (error) {
158
+ console.error(error);
159
+ response.status(500).send('Internal server error');
160
+ }
161
+ });
162
+
163
+ router.post('/transcribe-audio', urlencodedParser, async (request, response) => {
164
+ try {
165
+ const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);
166
+
167
+ if (!key) {
168
+ console.log('No OpenAI key found');
169
+ return response.sendStatus(400);
170
+ }
171
+
172
+ if (!request.file) {
173
+ console.log('No audio file found');
174
+ return response.sendStatus(400);
175
+ }
176
+
177
+ const formData = new FormData();
178
+ console.log('Processing audio file', request.file.path);
179
+ formData.append('file', fs.createReadStream(request.file.path), { filename: 'audio.wav', contentType: 'audio/wav' });
180
+ formData.append('model', request.body.model);
181
+
182
+ if (request.body.language) {
183
+ formData.append('language', request.body.language);
184
+ }
185
+
186
+ const result = await fetch('https://api.openai.com/v1/audio/transcriptions', {
187
+ method: 'POST',
188
+ headers: {
189
+ 'Authorization': `Bearer ${key}`,
190
+ ...formData.getHeaders(),
191
+ },
192
+ body: formData,
193
+ });
194
+
195
+ if (!result.ok) {
196
+ const text = await result.text();
197
+ console.log('OpenAI request failed', result.statusText, text);
198
+ return response.status(500).send(text);
199
+ }
200
+
201
+ fs.rmSync(request.file.path);
202
+ const data = await result.json();
203
+ console.log('OpenAI transcription response', data);
204
+ return response.json(data);
205
+ } catch (error) {
206
+ console.error('OpenAI transcription failed', error);
207
+ response.status(500).send('Internal server error');
208
+ }
209
+ });
210
+
211
+ router.post('/generate-voice', jsonParser, async (request, response) => {
212
+ try {
213
+ const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);
214
+
215
+ if (!key) {
216
+ console.log('No OpenAI key found');
217
+ return response.sendStatus(400);
218
+ }
219
+
220
+ const result = await fetch('https://api.openai.com/v1/audio/speech', {
221
+ method: 'POST',
222
+ headers: {
223
+ 'Content-Type': 'application/json',
224
+ Authorization: `Bearer ${key}`,
225
+ },
226
+ body: JSON.stringify({
227
+ input: request.body.text,
228
+ response_format: 'mp3',
229
+ voice: request.body.voice ?? 'alloy',
230
+ speed: request.body.speed ?? 1,
231
+ model: request.body.model ?? 'tts-1',
232
+ }),
233
+ });
234
+
235
+ if (!result.ok) {
236
+ const text = await result.text();
237
+ console.log('OpenAI request failed', result.statusText, text);
238
+ return response.status(500).send(text);
239
+ }
240
+
241
+ const buffer = await result.arrayBuffer();
242
+ response.setHeader('Content-Type', 'audio/mpeg');
243
+ return response.send(Buffer.from(buffer));
244
+ } catch (error) {
245
+ console.error('OpenAI TTS generation failed', error);
246
+ response.status(500).send('Internal server error');
247
+ }
248
+ });
249
+
250
+ router.post('/generate-image', jsonParser, async (request, response) => {
251
+ try {
252
+ const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);
253
+
254
+ if (!key) {
255
+ console.log('No OpenAI key found');
256
+ return response.sendStatus(400);
257
+ }
258
+
259
+ console.log('OpenAI request', request.body);
260
+
261
+ const result = await fetch('https://api.openai.com/v1/images/generations', {
262
+ method: 'POST',
263
+ headers: {
264
+ 'Content-Type': 'application/json',
265
+ Authorization: `Bearer ${key}`,
266
+ },
267
+ body: JSON.stringify(request.body),
268
+ timeout: 0,
269
+ });
270
+
271
+ if (!result.ok) {
272
+ const text = await result.text();
273
+ console.log('OpenAI request failed', result.statusText, text);
274
+ return response.status(500).send(text);
275
+ }
276
+
277
+ const data = await result.json();
278
+ return response.json(data);
279
+ } catch (error) {
280
+ console.error(error);
281
+ response.status(500).send('Internal server error');
282
+ }
283
+ });
284
+
285
+ const custom = express.Router();
286
+
287
+ custom.post('/generate-voice', jsonParser, async (request, response) => {
288
+ try {
289
+ const key = readSecret(request.user.directories, SECRET_KEYS.CUSTOM_OPENAI_TTS);
290
+ const { input, provider_endpoint, response_format, voice, speed, model } = request.body;
291
+
292
+ if (!provider_endpoint) {
293
+ console.log('No OpenAI-compatible TTS provider endpoint provided');
294
+ return response.sendStatus(400);
295
+ }
296
+
297
+ const result = await fetch(provider_endpoint, {
298
+ method: 'POST',
299
+ headers: {
300
+ 'Content-Type': 'application/json',
301
+ Authorization: `Bearer ${key ?? ''}`,
302
+ },
303
+ body: JSON.stringify({
304
+ input: input ?? '',
305
+ response_format: response_format ?? 'mp3',
306
+ voice: voice ?? 'alloy',
307
+ speed: speed ?? 1,
308
+ model: model ?? 'tts-1',
309
+ }),
310
+ });
311
+
312
+ if (!result.ok) {
313
+ const text = await result.text();
314
+ console.log('OpenAI request failed', result.statusText, text);
315
+ return response.status(500).send(text);
316
+ }
317
+
318
+ const buffer = await result.arrayBuffer();
319
+ response.setHeader('Content-Type', 'audio/mpeg');
320
+ return response.send(Buffer.from(buffer));
321
+ } catch (error) {
322
+ console.error('OpenAI TTS generation failed', error);
323
+ response.status(500).send('Internal server error');
324
+ }
325
+ });
326
+
327
+ router.use('/custom', custom);
328
+
329
+ module.exports = { router };
src/endpoints/presets.js ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const express = require('express');
4
+ const sanitize = require('sanitize-filename');
5
+ const writeFileAtomicSync = require('write-file-atomic').sync;
6
+ const { getDefaultPresetFile, getDefaultPresets } = require('./content-manager');
7
+ const { jsonParser } = require('../express-common');
8
+
9
+ /**
10
+ * Gets the folder and extension for the preset settings based on the API source ID.
11
+ * @param {string} apiId API source ID
12
+ * @param {import('../users').UserDirectoryList} directories User directories
13
+ * @returns {object} Object containing the folder and extension for the preset settings
14
+ */
15
+ function getPresetSettingsByAPI(apiId, directories) {
16
+ switch (apiId) {
17
+ case 'kobold':
18
+ case 'koboldhorde':
19
+ return { folder: directories.koboldAI_Settings, extension: '.json' };
20
+ case 'novel':
21
+ return { folder: directories.novelAI_Settings, extension: '.json' };
22
+ case 'textgenerationwebui':
23
+ return { folder: directories.textGen_Settings, extension: '.json' };
24
+ case 'openai':
25
+ return { folder: directories.openAI_Settings, extension: '.json' };
26
+ case 'instruct':
27
+ return { folder: directories.instruct, extension: '.json' };
28
+ case 'context':
29
+ return { folder: directories.context, extension: '.json' };
30
+ default:
31
+ return { folder: null, extension: null };
32
+ }
33
+ }
34
+
35
+ const router = express.Router();
36
+
37
+ router.post('/save', jsonParser, function (request, response) {
38
+ const name = sanitize(request.body.name);
39
+ if (!request.body.preset || !name) {
40
+ return response.sendStatus(400);
41
+ }
42
+
43
+ const settings = getPresetSettingsByAPI(request.body.apiId, request.user.directories);
44
+ const filename = name + settings.extension;
45
+
46
+ if (!settings.folder) {
47
+ return response.sendStatus(400);
48
+ }
49
+
50
+ const fullpath = path.join(settings.folder, filename);
51
+ writeFileAtomicSync(fullpath, JSON.stringify(request.body.preset, null, 4), 'utf-8');
52
+ return response.send({ name });
53
+ });
54
+
55
+ router.post('/delete', jsonParser, function (request, response) {
56
+ const name = sanitize(request.body.name);
57
+ if (!name) {
58
+ return response.sendStatus(400);
59
+ }
60
+
61
+ const settings = getPresetSettingsByAPI(request.body.apiId, request.user.directories);
62
+ const filename = name + settings.extension;
63
+
64
+ if (!settings.folder) {
65
+ return response.sendStatus(400);
66
+ }
67
+
68
+ const fullpath = path.join(settings.folder, filename);
69
+
70
+ if (fs.existsSync(fullpath)) {
71
+ fs.unlinkSync(fullpath);
72
+ return response.sendStatus(200);
73
+ } else {
74
+ return response.sendStatus(404);
75
+ }
76
+ });
77
+
78
+ router.post('/restore', jsonParser, function (request, response) {
79
+ try {
80
+ const settings = getPresetSettingsByAPI(request.body.apiId, request.user.directories);
81
+ const name = sanitize(request.body.name);
82
+ const defaultPresets = getDefaultPresets(request.user.directories);
83
+
84
+ const defaultPreset = defaultPresets.find(p => p.name === name && p.folder === settings.folder);
85
+
86
+ const result = { isDefault: false, preset: {} };
87
+
88
+ if (defaultPreset) {
89
+ result.isDefault = true;
90
+ result.preset = getDefaultPresetFile(defaultPreset.filename) || {};
91
+ }
92
+
93
+ return response.send(result);
94
+ } catch (error) {
95
+ console.log(error);
96
+ return response.sendStatus(500);
97
+ }
98
+ });
99
+
100
+ // TODO: Merge with /api/presets/save
101
+ router.post('/save-openai', jsonParser, function (request, response) {
102
+ if (!request.body || typeof request.query.name !== 'string') return response.sendStatus(400);
103
+ const name = sanitize(request.query.name);
104
+ if (!name) return response.sendStatus(400);
105
+
106
+ const filename = `${name}.json`;
107
+ const fullpath = path.join(request.user.directories.openAI_Settings, filename);
108
+ writeFileAtomicSync(fullpath, JSON.stringify(request.body, null, 4), 'utf-8');
109
+ return response.send({ name });
110
+ });
111
+
112
+ // TODO: Merge with /api/presets/delete
113
+ router.post('/delete-openai', jsonParser, function (request, response) {
114
+ if (!request.body || !request.body.name) {
115
+ return response.sendStatus(400);
116
+ }
117
+
118
+ const name = request.body.name;
119
+ const pathToFile = path.join(request.user.directories.openAI_Settings, `${name}.json`);
120
+
121
+ if (fs.existsSync(pathToFile)) {
122
+ fs.rmSync(pathToFile);
123
+ return response.send({ ok: true });
124
+ }
125
+
126
+ return response.send({ error: true });
127
+ });
128
+
129
+ module.exports = { router };
src/endpoints/quick-replies.js ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const express = require('express');
4
+ const sanitize = require('sanitize-filename');
5
+ const writeFileAtomicSync = require('write-file-atomic').sync;
6
+
7
+ const { jsonParser } = require('../express-common');
8
+
9
+ const router = express.Router();
10
+
11
+ router.post('/save', jsonParser, (request, response) => {
12
+ if (!request.body || !request.body.name) {
13
+ return response.sendStatus(400);
14
+ }
15
+
16
+ const filename = path.join(request.user.directories.quickreplies, sanitize(request.body.name) + '.json');
17
+ writeFileAtomicSync(filename, JSON.stringify(request.body, null, 4), 'utf8');
18
+
19
+ return response.sendStatus(200);
20
+ });
21
+
22
+ router.post('/delete', jsonParser, (request, response) => {
23
+ if (!request.body || !request.body.name) {
24
+ return response.sendStatus(400);
25
+ }
26
+
27
+ const filename = path.join(request.user.directories.quickreplies, sanitize(request.body.name) + '.json');
28
+ if (fs.existsSync(filename)) {
29
+ fs.unlinkSync(filename);
30
+ }
31
+
32
+ return response.sendStatus(200);
33
+ });
34
+
35
+ module.exports = { router };
src/endpoints/search.js ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fetch = require('node-fetch').default;
2
+ const express = require('express');
3
+ const { readSecret, SECRET_KEYS } = require('./secrets');
4
+ const { jsonParser } = require('../express-common');
5
+
6
+ const router = express.Router();
7
+
8
+ // Cosplay as Chrome
9
+ const visitHeaders = {
10
+ 'Accept': 'text/html',
11
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
12
+ 'Accept-Language': 'en-US,en;q=0.5',
13
+ 'Accept-Encoding': 'gzip, deflate, br',
14
+ 'Connection': 'keep-alive',
15
+ 'Cache-Control': 'no-cache',
16
+ 'Pragma': 'no-cache',
17
+ 'TE': 'trailers',
18
+ 'DNT': '1',
19
+ 'Sec-Fetch-Dest': 'document',
20
+ 'Sec-Fetch-Mode': 'navigate',
21
+ 'Sec-Fetch-Site': 'none',
22
+ 'Sec-Fetch-User': '?1',
23
+ };
24
+
25
+ router.post('/serpapi', jsonParser, async (request, response) => {
26
+ try {
27
+ const key = readSecret(request.user.directories, SECRET_KEYS.SERPAPI);
28
+
29
+ if (!key) {
30
+ console.log('No SerpApi key found');
31
+ return response.sendStatus(400);
32
+ }
33
+
34
+ const { query } = request.body;
35
+ const result = await fetch(`https://serpapi.com/search.json?q=${encodeURIComponent(query)}&api_key=${key}`);
36
+
37
+ console.log('SerpApi query', query);
38
+
39
+ if (!result.ok) {
40
+ const text = await result.text();
41
+ console.log('SerpApi request failed', result.statusText, text);
42
+ return response.status(500).send(text);
43
+ }
44
+
45
+ const data = await result.json();
46
+ return response.json(data);
47
+ } catch (error) {
48
+ console.log(error);
49
+ return response.sendStatus(500);
50
+ }
51
+ });
52
+
53
+ /**
54
+ * Get the transcript of a YouTube video
55
+ * @copyright https://github.com/Kakulukian/youtube-transcript (MIT License)
56
+ */
57
+ router.post('/transcript', jsonParser, async (request, response) => {
58
+ try {
59
+ const he = require('he');
60
+ const RE_XML_TRANSCRIPT = /<text start="([^"]*)" dur="([^"]*)">([^<]*)<\/text>/g;
61
+ const id = request.body.id;
62
+ const lang = request.body.lang;
63
+
64
+ if (!id) {
65
+ console.log('Id is required for /transcript');
66
+ return response.sendStatus(400);
67
+ }
68
+
69
+ const videoPageResponse = await fetch(`https://www.youtube.com/watch?v=${id}`, {
70
+ headers: {
71
+ ...(lang && { 'Accept-Language': lang }),
72
+ 'User-Agent': visitHeaders['User-Agent'],
73
+ },
74
+ });
75
+
76
+ const videoPageBody = await videoPageResponse.text();
77
+ const splittedHTML = videoPageBody.split('"captions":');
78
+
79
+ if (splittedHTML.length <= 1) {
80
+ if (videoPageBody.includes('class="g-recaptcha"')) {
81
+ throw new Error('Too many requests');
82
+ }
83
+ if (!videoPageBody.includes('"playabilityStatus":')) {
84
+ throw new Error('Video is not available');
85
+ }
86
+ throw new Error('Transcript not available');
87
+ }
88
+
89
+ const captions = (() => {
90
+ try {
91
+ return JSON.parse(splittedHTML[1].split(',"videoDetails')[0].replace('\n', ''));
92
+ } catch (e) {
93
+ return undefined;
94
+ }
95
+ })()?.['playerCaptionsTracklistRenderer'];
96
+
97
+ if (!captions) {
98
+ throw new Error('Transcript disabled');
99
+ }
100
+
101
+ if (!('captionTracks' in captions)) {
102
+ throw new Error('Transcript not available');
103
+ }
104
+
105
+ if (lang && !captions.captionTracks.some(track => track.languageCode === lang)) {
106
+ throw new Error('Transcript not available in this language');
107
+ }
108
+
109
+ const transcriptURL = (lang ? captions.captionTracks.find(track => track.languageCode === lang) : captions.captionTracks[0]).baseUrl;
110
+ const transcriptResponse = await fetch(transcriptURL, {
111
+ headers: {
112
+ ...(lang && { 'Accept-Language': lang }),
113
+ 'User-Agent': visitHeaders['User-Agent'],
114
+ },
115
+ });
116
+
117
+ if (!transcriptResponse.ok) {
118
+ throw new Error('Transcript request failed');
119
+ }
120
+
121
+ const transcriptBody = await transcriptResponse.text();
122
+ const results = [...transcriptBody.matchAll(RE_XML_TRANSCRIPT)];
123
+ const transcript = results.map((result) => ({
124
+ text: result[3],
125
+ duration: parseFloat(result[2]),
126
+ offset: parseFloat(result[1]),
127
+ lang: lang ?? captions.captionTracks[0].languageCode,
128
+ }));
129
+ // The text is double-encoded
130
+ const transcriptText = transcript.map((line) => he.decode(he.decode(line.text))).join(' ');
131
+
132
+ return response.send(transcriptText);
133
+ } catch (error) {
134
+ console.log(error);
135
+ return response.sendStatus(500);
136
+ }
137
+ });
138
+
139
+ router.post('/searxng', jsonParser, async (request, response) => {
140
+ try {
141
+ const { baseUrl, query } = request.body;
142
+
143
+ if (!baseUrl || !query) {
144
+ console.log('Missing required parameters for /searxng');
145
+ return response.sendStatus(400);
146
+ }
147
+
148
+ console.log('SearXNG query', baseUrl, query);
149
+
150
+ const mainPageUrl = new URL(baseUrl);
151
+ const mainPageRequest = await fetch(mainPageUrl, { headers: visitHeaders });
152
+
153
+ if (!mainPageRequest.ok) {
154
+ console.log('SearXNG request failed', mainPageRequest.statusText);
155
+ return response.sendStatus(500);
156
+ }
157
+
158
+ const mainPageText = await mainPageRequest.text();
159
+ const clientHref = mainPageText.match(/href="(\/client.+\.css)"/)?.[1];
160
+
161
+ if (clientHref) {
162
+ const clientUrl = new URL(clientHref, baseUrl);
163
+ await fetch(clientUrl, { headers: visitHeaders });
164
+ }
165
+
166
+ const searchUrl = new URL('/search', baseUrl);
167
+ const searchParams = new URLSearchParams();
168
+ searchParams.append('q', query);
169
+ searchUrl.search = searchParams.toString();
170
+
171
+ const searchResult = await fetch(searchUrl, { headers: visitHeaders });
172
+
173
+ if (!searchResult.ok) {
174
+ const text = await searchResult.text();
175
+ console.log('SearXNG request failed', searchResult.statusText, text);
176
+ return response.sendStatus(500);
177
+ }
178
+
179
+ const data = await searchResult.text();
180
+ return response.send(data);
181
+ } catch (error) {
182
+ console.log('SearXNG request failed', error);
183
+ return response.sendStatus(500);
184
+ }
185
+ });
186
+
187
+ router.post('/visit', jsonParser, async (request, response) => {
188
+ try {
189
+ const url = request.body.url;
190
+
191
+ if (!url) {
192
+ console.log('No url provided for /visit');
193
+ return response.sendStatus(400);
194
+ }
195
+
196
+ try {
197
+ const urlObj = new URL(url);
198
+
199
+ // Reject relative URLs
200
+ if (urlObj.protocol === null || urlObj.host === null) {
201
+ throw new Error('Invalid URL format');
202
+ }
203
+
204
+ // Reject non-HTTP URLs
205
+ if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') {
206
+ throw new Error('Invalid protocol');
207
+ }
208
+
209
+ // Reject URLs with a non-standard port
210
+ if (urlObj.port !== '') {
211
+ throw new Error('Invalid port');
212
+ }
213
+
214
+ // Reject IP addresses
215
+ if (urlObj.hostname.match(/^\d+\.\d+\.\d+\.\d+$/)) {
216
+ throw new Error('Invalid hostname');
217
+ }
218
+ } catch (error) {
219
+ console.log('Invalid url provided for /visit', url);
220
+ return response.sendStatus(400);
221
+ }
222
+
223
+ console.log('Visiting web URL', url);
224
+
225
+ const result = await fetch(url, { headers: visitHeaders });
226
+
227
+ if (!result.ok) {
228
+ console.log(`Visit failed ${result.status} ${result.statusText}`);
229
+ return response.sendStatus(500);
230
+ }
231
+
232
+ const contentType = String(result.headers.get('content-type'));
233
+ if (!contentType.includes('text/html')) {
234
+ console.log(`Visit failed, content-type is ${contentType}, expected text/html`);
235
+ return response.sendStatus(500);
236
+ }
237
+
238
+ const text = await result.text();
239
+ return response.send(text);
240
+ } catch (error) {
241
+ console.log(error);
242
+ return response.sendStatus(500);
243
+ }
244
+ });
245
+
246
+ module.exports = { router };
src/endpoints/secrets.js ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const express = require('express');
4
+ const { getConfigValue } = require('../util');
5
+ const writeFileAtomicSync = require('write-file-atomic').sync;
6
+ const { jsonParser } = require('../express-common');
7
+
8
+ const SECRETS_FILE = 'secrets.json';
9
+ const SECRET_KEYS = {
10
+ HORDE: 'api_key_horde',
11
+ MANCER: 'api_key_mancer',
12
+ VLLM: 'api_key_vllm',
13
+ APHRODITE: 'api_key_aphrodite',
14
+ TABBY: 'api_key_tabby',
15
+ OPENAI: 'api_key_openai',
16
+ NOVEL: 'api_key_novel',
17
+ CLAUDE: 'api_key_claude',
18
+ DEEPL: 'deepl',
19
+ LIBRE: 'libre',
20
+ LIBRE_URL: 'libre_url',
21
+ LINGVA_URL: 'lingva_url',
22
+ OPENROUTER: 'api_key_openrouter',
23
+ SCALE: 'api_key_scale',
24
+ AI21: 'api_key_ai21',
25
+ SCALE_COOKIE: 'scale_cookie',
26
+ ONERING_URL: 'oneringtranslator_url',
27
+ DEEPLX_URL: 'deeplx_url',
28
+ MAKERSUITE: 'api_key_makersuite',
29
+ SERPAPI: 'api_key_serpapi',
30
+ TOGETHERAI: 'api_key_togetherai',
31
+ MISTRALAI: 'api_key_mistralai',
32
+ CUSTOM: 'api_key_custom',
33
+ OOBA: 'api_key_ooba',
34
+ INFERMATICAI: 'api_key_infermaticai',
35
+ DREAMGEN: 'api_key_dreamgen',
36
+ NOMICAI: 'api_key_nomicai',
37
+ KOBOLDCPP: 'api_key_koboldcpp',
38
+ LLAMACPP: 'api_key_llamacpp',
39
+ COHERE: 'api_key_cohere',
40
+ PERPLEXITY: 'api_key_perplexity',
41
+ GROQ: 'api_key_groq',
42
+ AZURE_TTS: 'api_key_azure_tts',
43
+ FEATHERLESS: 'api_key_featherless',
44
+ ZEROONEAI: 'api_key_01ai',
45
+ HUGGINGFACE: 'api_key_huggingface',
46
+ STABILITY: 'api_key_stability',
47
+ BLOCKENTROPY: 'api_key_blockentropy',
48
+ CUSTOM_OPENAI_TTS: 'api_key_custom_openai_tts',
49
+ };
50
+
51
+ // These are the keys that are safe to expose, even if allowKeysExposure is false
52
+ const EXPORTABLE_KEYS = [
53
+ SECRET_KEYS.LIBRE_URL,
54
+ SECRET_KEYS.LINGVA_URL,
55
+ SECRET_KEYS.ONERING_URL,
56
+ SECRET_KEYS.DEEPLX_URL,
57
+ ];
58
+
59
+ /**
60
+ * Writes a secret to the secrets file
61
+ * @param {import('../users').UserDirectoryList} directories User directories
62
+ * @param {string} key Secret key
63
+ * @param {string} value Secret value
64
+ */
65
+ function writeSecret(directories, key, value) {
66
+ const filePath = path.join(directories.root, SECRETS_FILE);
67
+
68
+ if (!fs.existsSync(filePath)) {
69
+ const emptyFile = JSON.stringify({});
70
+ writeFileAtomicSync(filePath, emptyFile, 'utf-8');
71
+ }
72
+
73
+ const fileContents = fs.readFileSync(filePath, 'utf-8');
74
+ const secrets = JSON.parse(fileContents);
75
+ secrets[key] = value;
76
+ writeFileAtomicSync(filePath, JSON.stringify(secrets, null, 4), 'utf-8');
77
+ }
78
+
79
+ /**
80
+ * Deletes a secret from the secrets file
81
+ * @param {import('../users').UserDirectoryList} directories User directories
82
+ * @param {string} key Secret key
83
+ * @returns
84
+ */
85
+ function deleteSecret(directories, key) {
86
+ const filePath = path.join(directories.root, SECRETS_FILE);
87
+
88
+ if (!fs.existsSync(filePath)) {
89
+ return;
90
+ }
91
+
92
+ const fileContents = fs.readFileSync(filePath, 'utf-8');
93
+ const secrets = JSON.parse(fileContents);
94
+ delete secrets[key];
95
+ writeFileAtomicSync(filePath, JSON.stringify(secrets, null, 4), 'utf-8');
96
+ }
97
+
98
+ /**
99
+ * Reads a secret from the secrets file
100
+ * @param {import('../users').UserDirectoryList} directories User directories
101
+ * @param {string} key Secret key
102
+ * @returns {string} Secret value
103
+ */
104
+ function readSecret(directories, key) {
105
+ const filePath = path.join(directories.root, SECRETS_FILE);
106
+
107
+ if (!fs.existsSync(filePath)) {
108
+ return '';
109
+ }
110
+
111
+ const fileContents = fs.readFileSync(filePath, 'utf-8');
112
+ const secrets = JSON.parse(fileContents);
113
+ return secrets[key];
114
+ }
115
+
116
+ /**
117
+ * Reads the secret state from the secrets file
118
+ * @param {import('../users').UserDirectoryList} directories User directories
119
+ * @returns {object} Secret state
120
+ */
121
+ function readSecretState(directories) {
122
+ const filePath = path.join(directories.root, SECRETS_FILE);
123
+
124
+ if (!fs.existsSync(filePath)) {
125
+ return {};
126
+ }
127
+
128
+ const fileContents = fs.readFileSync(filePath, 'utf8');
129
+ const secrets = JSON.parse(fileContents);
130
+ const state = {};
131
+
132
+ for (const key of Object.values(SECRET_KEYS)) {
133
+ state[key] = !!secrets[key]; // convert to boolean
134
+ }
135
+
136
+ return state;
137
+ }
138
+
139
+ /**
140
+ * Reads all secrets from the secrets file
141
+ * @param {import('../users').UserDirectoryList} directories User directories
142
+ * @returns {Record<string, string> | undefined} Secrets
143
+ */
144
+ function getAllSecrets(directories) {
145
+ const filePath = path.join(directories.root, SECRETS_FILE);
146
+
147
+ if (!fs.existsSync(filePath)) {
148
+ console.log('Secrets file does not exist');
149
+ return undefined;
150
+ }
151
+
152
+ const fileContents = fs.readFileSync(filePath, 'utf8');
153
+ const secrets = JSON.parse(fileContents);
154
+ return secrets;
155
+ }
156
+
157
+ const router = express.Router();
158
+
159
+ router.post('/write', jsonParser, (request, response) => {
160
+ const key = request.body.key;
161
+ const value = request.body.value;
162
+
163
+ writeSecret(request.user.directories, key, value);
164
+ return response.send('ok');
165
+ });
166
+
167
+ router.post('/read', jsonParser, (request, response) => {
168
+ try {
169
+ const state = readSecretState(request.user.directories);
170
+ return response.send(state);
171
+ } catch (error) {
172
+ console.error(error);
173
+ return response.send({});
174
+ }
175
+ });
176
+
177
+ router.post('/view', jsonParser, async (request, response) => {
178
+ const allowKeysExposure = getConfigValue('allowKeysExposure', false);
179
+
180
+ if (!allowKeysExposure) {
181
+ console.error('secrets.json could not be viewed unless the value of allowKeysExposure in config.yaml is set to true');
182
+ return response.sendStatus(403);
183
+ }
184
+
185
+ try {
186
+ const secrets = getAllSecrets(request.user.directories);
187
+
188
+ if (!secrets) {
189
+ return response.sendStatus(404);
190
+ }
191
+
192
+ return response.send(secrets);
193
+ } catch (error) {
194
+ console.error(error);
195
+ return response.sendStatus(500);
196
+ }
197
+ });
198
+
199
+ router.post('/find', jsonParser, (request, response) => {
200
+ const allowKeysExposure = getConfigValue('allowKeysExposure', false);
201
+ const key = request.body.key;
202
+
203
+ if (!allowKeysExposure && !EXPORTABLE_KEYS.includes(key)) {
204
+ console.error('Cannot fetch secrets unless allowKeysExposure in config.yaml is set to true');
205
+ return response.sendStatus(403);
206
+ }
207
+
208
+ try {
209
+ const secret = readSecret(request.user.directories, key);
210
+
211
+ if (!secret) {
212
+ response.sendStatus(404);
213
+ }
214
+
215
+ return response.send({ value: secret });
216
+ } catch (error) {
217
+ console.error(error);
218
+ return response.sendStatus(500);
219
+ }
220
+ });
221
+
222
+ module.exports = {
223
+ writeSecret,
224
+ readSecret,
225
+ deleteSecret,
226
+ readSecretState,
227
+ getAllSecrets,
228
+ SECRET_KEYS,
229
+ router,
230
+ };
src/endpoints/settings.js ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const express = require('express');
4
+ const _ = require('lodash');
5
+ const writeFileAtomicSync = require('write-file-atomic').sync;
6
+ const { SETTINGS_FILE } = require('../constants');
7
+ const { getConfigValue, generateTimestamp, removeOldBackups } = require('../util');
8
+ const { jsonParser } = require('../express-common');
9
+ const { getAllUserHandles, getUserDirectories } = require('../users');
10
+
11
+ const ENABLE_EXTENSIONS = getConfigValue('enableExtensions', true);
12
+ const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false);
13
+
14
+ // 10 minutes
15
+ const AUTOSAVE_INTERVAL = 10 * 60 * 1000;
16
+
17
+ /**
18
+ * Map of functions to trigger settings autosave for a user.
19
+ * @type {Map<string, function>}
20
+ */
21
+ const AUTOSAVE_FUNCTIONS = new Map();
22
+
23
+ /**
24
+ * Triggers autosave for a user every 10 minutes.
25
+ * @param {string} handle User handle
26
+ * @returns {void}
27
+ */
28
+ function triggerAutoSave(handle) {
29
+ if (!AUTOSAVE_FUNCTIONS.has(handle)) {
30
+ const throttledAutoSave = _.throttle(() => backupUserSettings(handle, true), AUTOSAVE_INTERVAL);
31
+ AUTOSAVE_FUNCTIONS.set(handle, throttledAutoSave);
32
+ }
33
+
34
+ const functionToCall = AUTOSAVE_FUNCTIONS.get(handle);
35
+ if (functionToCall && typeof functionToCall === 'function') {
36
+ functionToCall();
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Reads and parses files from a directory.
42
+ * @param {string} directoryPath Path to the directory
43
+ * @param {string} fileExtension File extension
44
+ * @returns {Array} Parsed files
45
+ */
46
+ function readAndParseFromDirectory(directoryPath, fileExtension = '.json') {
47
+ const files = fs
48
+ .readdirSync(directoryPath)
49
+ .filter(x => path.parse(x).ext == fileExtension)
50
+ .sort();
51
+
52
+ const parsedFiles = [];
53
+
54
+ files.forEach(item => {
55
+ try {
56
+ const file = fs.readFileSync(path.join(directoryPath, item), 'utf-8');
57
+ parsedFiles.push(fileExtension == '.json' ? JSON.parse(file) : file);
58
+ }
59
+ catch {
60
+ // skip
61
+ }
62
+ });
63
+
64
+ return parsedFiles;
65
+ }
66
+
67
+ /**
68
+ * Gets a sort function for sorting strings.
69
+ * @param {*} _
70
+ * @returns {(a: string, b: string) => number} Sort function
71
+ */
72
+ function sortByName(_) {
73
+ return (a, b) => a.localeCompare(b);
74
+ }
75
+
76
+ /**
77
+ * Gets backup file prefix for user settings.
78
+ * @param {string} handle User handle
79
+ * @returns {string} File prefix
80
+ */
81
+ function getFilePrefix(handle) {
82
+ return `settings_${handle}_`;
83
+ }
84
+
85
+ function readPresetsFromDirectory(directoryPath, options = {}) {
86
+ const {
87
+ sortFunction,
88
+ removeFileExtension = false,
89
+ fileExtension = '.json',
90
+ } = options;
91
+
92
+ const files = fs.readdirSync(directoryPath).sort(sortFunction).filter(x => path.parse(x).ext == fileExtension);
93
+ const fileContents = [];
94
+ const fileNames = [];
95
+
96
+ files.forEach(item => {
97
+ try {
98
+ const file = fs.readFileSync(path.join(directoryPath, item), 'utf8');
99
+ JSON.parse(file);
100
+ fileContents.push(file);
101
+ fileNames.push(removeFileExtension ? item.replace(/\.[^/.]+$/, '') : item);
102
+ } catch {
103
+ // skip
104
+ console.log(`${item} is not a valid JSON`);
105
+ }
106
+ });
107
+
108
+ return { fileContents, fileNames };
109
+ }
110
+
111
+ async function backupSettings() {
112
+ try {
113
+ const userHandles = await getAllUserHandles();
114
+
115
+ for (const handle of userHandles) {
116
+ backupUserSettings(handle, true);
117
+ }
118
+ } catch (err) {
119
+ console.log('Could not backup settings file', err);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Makes a backup of the user's settings file.
125
+ * @param {string} handle User handle
126
+ * @param {boolean} preventDuplicates Prevent duplicate backups
127
+ * @returns {void}
128
+ */
129
+ function backupUserSettings(handle, preventDuplicates) {
130
+ const userDirectories = getUserDirectories(handle);
131
+ const backupFile = path.join(userDirectories.backups, `${getFilePrefix(handle)}${generateTimestamp()}.json`);
132
+ const sourceFile = path.join(userDirectories.root, SETTINGS_FILE);
133
+
134
+ if (preventDuplicates && isDuplicateBackup(handle, sourceFile)) {
135
+ return;
136
+ }
137
+
138
+ if (!fs.existsSync(sourceFile)) {
139
+ return;
140
+ }
141
+
142
+ fs.copyFileSync(sourceFile, backupFile);
143
+ removeOldBackups(userDirectories.backups, `settings_${handle}`);
144
+ }
145
+
146
+ /**
147
+ * Checks if the backup would be a duplicate.
148
+ * @param {string} handle User handle
149
+ * @param {string} sourceFile Source file path
150
+ * @returns {boolean} True if the backup is a duplicate
151
+ */
152
+ function isDuplicateBackup(handle, sourceFile) {
153
+ const latestBackup = getLatestBackup(handle);
154
+ if (!latestBackup) {
155
+ return false;
156
+ }
157
+ return areFilesEqual(latestBackup, sourceFile);
158
+ }
159
+
160
+ /**
161
+ * Returns true if the two files are equal.
162
+ * @param {string} file1 File path
163
+ * @param {string} file2 File path
164
+ */
165
+ function areFilesEqual(file1, file2) {
166
+ if (!fs.existsSync(file1) || !fs.existsSync(file2)) {
167
+ return false;
168
+ }
169
+
170
+ const content1 = fs.readFileSync(file1);
171
+ const content2 = fs.readFileSync(file2);
172
+ return content1.toString() === content2.toString();
173
+ }
174
+
175
+ /**
176
+ * Gets the latest backup file for a user.
177
+ * @param {string} handle User handle
178
+ * @returns {string|null} Latest backup file. Null if no backup exists.
179
+ */
180
+ function getLatestBackup(handle) {
181
+ const userDirectories = getUserDirectories(handle);
182
+ const backupFiles = fs.readdirSync(userDirectories.backups)
183
+ .filter(x => x.startsWith(getFilePrefix(handle)))
184
+ .map(x => ({ name: x, ctime: fs.statSync(path.join(userDirectories.backups, x)).ctimeMs }));
185
+ const latestBackup = backupFiles.sort((a, b) => b.ctime - a.ctime)[0]?.name;
186
+ if (!latestBackup) {
187
+ return null;
188
+ }
189
+ return path.join(userDirectories.backups, latestBackup);
190
+ }
191
+
192
+ const router = express.Router();
193
+
194
+ router.post('/save', jsonParser, function (request, response) {
195
+ try {
196
+ const pathToSettings = path.join(request.user.directories.root, SETTINGS_FILE);
197
+ writeFileAtomicSync(pathToSettings, JSON.stringify(request.body, null, 4), 'utf8');
198
+ triggerAutoSave(request.user.profile.handle);
199
+ response.send({ result: 'ok' });
200
+ } catch (err) {
201
+ console.log(err);
202
+ response.send(err);
203
+ }
204
+ });
205
+
206
+ // Wintermute's code
207
+ router.post('/get', jsonParser, (request, response) => {
208
+ let settings;
209
+ try {
210
+ const pathToSettings = path.join(request.user.directories.root, SETTINGS_FILE);
211
+ settings = fs.readFileSync(pathToSettings, 'utf8');
212
+ } catch (e) {
213
+ return response.sendStatus(500);
214
+ }
215
+
216
+ // NovelAI Settings
217
+ const { fileContents: novelai_settings, fileNames: novelai_setting_names }
218
+ = readPresetsFromDirectory(request.user.directories.novelAI_Settings, {
219
+ sortFunction: sortByName(request.user.directories.novelAI_Settings),
220
+ removeFileExtension: true,
221
+ });
222
+
223
+ // OpenAI Settings
224
+ const { fileContents: openai_settings, fileNames: openai_setting_names }
225
+ = readPresetsFromDirectory(request.user.directories.openAI_Settings, {
226
+ sortFunction: sortByName(request.user.directories.openAI_Settings), removeFileExtension: true,
227
+ });
228
+
229
+ // TextGenerationWebUI Settings
230
+ const { fileContents: textgenerationwebui_presets, fileNames: textgenerationwebui_preset_names }
231
+ = readPresetsFromDirectory(request.user.directories.textGen_Settings, {
232
+ sortFunction: sortByName(request.user.directories.textGen_Settings), removeFileExtension: true,
233
+ });
234
+
235
+ //Kobold
236
+ const { fileContents: koboldai_settings, fileNames: koboldai_setting_names }
237
+ = readPresetsFromDirectory(request.user.directories.koboldAI_Settings, {
238
+ sortFunction: sortByName(request.user.directories.koboldAI_Settings), removeFileExtension: true,
239
+ });
240
+
241
+ const worldFiles = fs
242
+ .readdirSync(request.user.directories.worlds)
243
+ .filter(file => path.extname(file).toLowerCase() === '.json')
244
+ .sort((a, b) => a.localeCompare(b));
245
+ const world_names = worldFiles.map(item => path.parse(item).name);
246
+
247
+ const themes = readAndParseFromDirectory(request.user.directories.themes);
248
+ const movingUIPresets = readAndParseFromDirectory(request.user.directories.movingUI);
249
+ const quickReplyPresets = readAndParseFromDirectory(request.user.directories.quickreplies);
250
+
251
+ const instruct = readAndParseFromDirectory(request.user.directories.instruct);
252
+ const context = readAndParseFromDirectory(request.user.directories.context);
253
+
254
+ response.send({
255
+ settings,
256
+ koboldai_settings,
257
+ koboldai_setting_names,
258
+ world_names,
259
+ novelai_settings,
260
+ novelai_setting_names,
261
+ openai_settings,
262
+ openai_setting_names,
263
+ textgenerationwebui_presets,
264
+ textgenerationwebui_preset_names,
265
+ themes,
266
+ movingUIPresets,
267
+ quickReplyPresets,
268
+ instruct,
269
+ context,
270
+ enable_extensions: ENABLE_EXTENSIONS,
271
+ enable_accounts: ENABLE_ACCOUNTS,
272
+ });
273
+ });
274
+
275
+ router.post('/get-snapshots', jsonParser, async (request, response) => {
276
+ try {
277
+ const snapshots = fs.readdirSync(request.user.directories.backups);
278
+ const userFilesPattern = getFilePrefix(request.user.profile.handle);
279
+ const userSnapshots = snapshots.filter(x => x.startsWith(userFilesPattern));
280
+
281
+ const result = userSnapshots.map(x => {
282
+ const stat = fs.statSync(path.join(request.user.directories.backups, x));
283
+ return { date: stat.ctimeMs, name: x, size: stat.size };
284
+ });
285
+
286
+ response.json(result);
287
+ } catch (error) {
288
+ console.log(error);
289
+ response.sendStatus(500);
290
+ }
291
+ });
292
+
293
+ router.post('/load-snapshot', jsonParser, async (request, response) => {
294
+ try {
295
+ const userFilesPattern = getFilePrefix(request.user.profile.handle);
296
+
297
+ if (!request.body.name || !request.body.name.startsWith(userFilesPattern)) {
298
+ return response.status(400).send({ error: 'Invalid snapshot name' });
299
+ }
300
+
301
+ const snapshotName = request.body.name;
302
+ const snapshotPath = path.join(request.user.directories.backups, snapshotName);
303
+
304
+ if (!fs.existsSync(snapshotPath)) {
305
+ return response.sendStatus(404);
306
+ }
307
+
308
+ const content = fs.readFileSync(snapshotPath, 'utf8');
309
+
310
+ response.send(content);
311
+ } catch (error) {
312
+ console.log(error);
313
+ response.sendStatus(500);
314
+ }
315
+ });
316
+
317
+ router.post('/make-snapshot', jsonParser, async (request, response) => {
318
+ try {
319
+ backupUserSettings(request.user.profile.handle, false);
320
+ response.sendStatus(204);
321
+ } catch (error) {
322
+ console.log(error);
323
+ response.sendStatus(500);
324
+ }
325
+ });
326
+
327
+ router.post('/restore-snapshot', jsonParser, async (request, response) => {
328
+ try {
329
+ const userFilesPattern = getFilePrefix(request.user.profile.handle);
330
+
331
+ if (!request.body.name || !request.body.name.startsWith(userFilesPattern)) {
332
+ return response.status(400).send({ error: 'Invalid snapshot name' });
333
+ }
334
+
335
+ const snapshotName = request.body.name;
336
+ const snapshotPath = path.join(request.user.directories.backups, snapshotName);
337
+
338
+ if (!fs.existsSync(snapshotPath)) {
339
+ return response.sendStatus(404);
340
+ }
341
+
342
+ const pathToSettings = path.join(request.user.directories.root, SETTINGS_FILE);
343
+ fs.rmSync(pathToSettings, { force: true });
344
+ fs.copyFileSync(snapshotPath, pathToSettings);
345
+
346
+ response.sendStatus(204);
347
+ } catch (error) {
348
+ console.log(error);
349
+ response.sendStatus(500);
350
+ }
351
+ });
352
+
353
+ /**
354
+ * Initializes the settings endpoint
355
+ */
356
+ async function init() {
357
+ await backupSettings();
358
+ }
359
+
360
+ module.exports = { router, init };
src/endpoints/speech.js ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const { jsonParser } = require('../express-common');
3
+
4
+ const router = express.Router();
5
+
6
+ /**
7
+ * Gets the audio data from a base64-encoded audio file.
8
+ * @param {string} audio Base64-encoded audio
9
+ * @returns {Float64Array} Audio data
10
+ */
11
+ function getWaveFile(audio) {
12
+ const wavefile = require('wavefile');
13
+ const wav = new wavefile.WaveFile();
14
+ wav.fromDataURI(audio);
15
+ wav.toBitDepth('32f');
16
+ wav.toSampleRate(16000);
17
+ let audioData = wav.getSamples();
18
+ if (Array.isArray(audioData)) {
19
+ if (audioData.length > 1) {
20
+ const SCALING_FACTOR = Math.sqrt(2);
21
+
22
+ // Merge channels (into first channel to save memory)
23
+ for (let i = 0; i < audioData[0].length; ++i) {
24
+ audioData[0][i] = SCALING_FACTOR * (audioData[0][i] + audioData[1][i]) / 2;
25
+ }
26
+ }
27
+
28
+ // Select first channel
29
+ audioData = audioData[0];
30
+ }
31
+
32
+ return audioData;
33
+ }
34
+
35
+ router.post('/recognize', jsonParser, async (req, res) => {
36
+ try {
37
+ const TASK = 'automatic-speech-recognition';
38
+ const { model, audio, lang } = req.body;
39
+ const module = await import('../transformers.mjs');
40
+ const pipe = await module.default.getPipeline(TASK, model);
41
+ const wav = getWaveFile(audio);
42
+ const start = performance.now();
43
+ const result = await pipe(wav, { language: lang || null, task: 'transcribe' });
44
+ const end = performance.now();
45
+ console.log(`Execution duration: ${(end - start) / 1000} seconds`);
46
+ console.log('Transcribed audio:', result.text);
47
+
48
+ return res.json({ text: result.text });
49
+ } catch (error) {
50
+ console.error(error);
51
+ return res.sendStatus(500);
52
+ }
53
+ });
54
+
55
+ router.post('/synthesize', jsonParser, async (req, res) => {
56
+ try {
57
+ const wavefile = require('wavefile');
58
+ const TASK = 'text-to-speech';
59
+ const { text, model, speaker } = req.body;
60
+ const module = await import('../transformers.mjs');
61
+ const pipe = await module.default.getPipeline(TASK, model);
62
+ const speaker_embeddings = speaker
63
+ ? new Float32Array(new Uint8Array(Buffer.from(speaker.startsWith('data:') ? speaker.split(',')[1] : speaker, 'base64')).buffer)
64
+ : null;
65
+ const start = performance.now();
66
+ const result = await pipe(text, { speaker_embeddings: speaker_embeddings });
67
+ const end = performance.now();
68
+ console.log(`Execution duration: ${(end - start) / 1000} seconds`);
69
+
70
+ const wav = new wavefile.WaveFile();
71
+ wav.fromScratch(1, result.sampling_rate, '32f', result.audio);
72
+ const buffer = wav.toBuffer();
73
+
74
+ res.set('Content-Type', 'audio/wav');
75
+ return res.send(Buffer.from(buffer));
76
+ } catch (error) {
77
+ console.error(error);
78
+ return res.sendStatus(500);
79
+ }
80
+ });
81
+
82
+ module.exports = { router };
src/endpoints/sprites.js ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const express = require('express');
5
+ const mime = require('mime-types');
6
+ const sanitize = require('sanitize-filename');
7
+ const writeFileAtomicSync = require('write-file-atomic').sync;
8
+ const { getImageBuffers } = require('../util');
9
+ const { jsonParser, urlencodedParser } = require('../express-common');
10
+
11
+ /**
12
+ * Gets the path to the sprites folder for the provided character name
13
+ * @param {import('../users').UserDirectoryList} directories - User directories
14
+ * @param {string} name - The name of the character
15
+ * @param {boolean} isSubfolder - Whether the name contains a subfolder
16
+ * @returns {string | null} The path to the sprites folder. Null if the name is invalid.
17
+ */
18
+ function getSpritesPath(directories, name, isSubfolder) {
19
+ if (isSubfolder) {
20
+ const nameParts = name.split('/');
21
+ const characterName = sanitize(nameParts[0]);
22
+ const subfolderName = sanitize(nameParts[1]);
23
+
24
+ if (!characterName || !subfolderName) {
25
+ return null;
26
+ }
27
+
28
+ return path.join(directories.characters, characterName, subfolderName);
29
+ }
30
+
31
+ name = sanitize(name);
32
+
33
+ if (!name) {
34
+ return null;
35
+ }
36
+
37
+ return path.join(directories.characters, name);
38
+ }
39
+
40
+ /**
41
+ * Imports base64 encoded sprites from RisuAI character data.
42
+ * The sprites are saved in the character's sprites folder.
43
+ * The additionalAssets and emotions are removed from the data.
44
+ * @param {import('../users').UserDirectoryList} directories User directories
45
+ * @param {object} data RisuAI character data
46
+ * @returns {void}
47
+ */
48
+ function importRisuSprites(directories, data) {
49
+ try {
50
+ const name = data?.data?.name;
51
+ const risuData = data?.data?.extensions?.risuai;
52
+
53
+ // Not a Risu AI character
54
+ if (!risuData || !name) {
55
+ return;
56
+ }
57
+
58
+ let images = [];
59
+
60
+ if (Array.isArray(risuData.additionalAssets)) {
61
+ images = images.concat(risuData.additionalAssets);
62
+ }
63
+
64
+ if (Array.isArray(risuData.emotions)) {
65
+ images = images.concat(risuData.emotions);
66
+ }
67
+
68
+ // No sprites to import
69
+ if (images.length === 0) {
70
+ return;
71
+ }
72
+
73
+ // Create sprites folder if it doesn't exist
74
+ const spritesPath = path.join(directories.characters, name);
75
+ if (!fs.existsSync(spritesPath)) {
76
+ fs.mkdirSync(spritesPath);
77
+ }
78
+
79
+ // Path to sprites is not a directory. This should never happen.
80
+ if (!fs.statSync(spritesPath).isDirectory()) {
81
+ return;
82
+ }
83
+
84
+ console.log(`RisuAI: Found ${images.length} sprites for ${name}. Writing to disk.`);
85
+ const files = fs.readdirSync(spritesPath);
86
+
87
+ outer: for (const [label, fileBase64] of images) {
88
+ // Remove existing sprite with the same label
89
+ for (const file of files) {
90
+ if (path.parse(file).name === label) {
91
+ console.log(`RisuAI: The sprite ${label} for ${name} already exists. Skipping.`);
92
+ continue outer;
93
+ }
94
+ }
95
+
96
+ const filename = label + '.png';
97
+ const pathToFile = path.join(spritesPath, filename);
98
+ writeFileAtomicSync(pathToFile, fileBase64, { encoding: 'base64' });
99
+ }
100
+
101
+ // Remove additionalAssets and emotions from data (they are now in the sprites folder)
102
+ delete data.data.extensions.risuai.additionalAssets;
103
+ delete data.data.extensions.risuai.emotions;
104
+ } catch (error) {
105
+ console.error(error);
106
+ }
107
+ }
108
+
109
+ const router = express.Router();
110
+
111
+ router.get('/get', jsonParser, function (request, response) {
112
+ const name = String(request.query.name);
113
+ const isSubfolder = name.includes('/');
114
+ const spritesPath = getSpritesPath(request.user.directories, name, isSubfolder);
115
+ let sprites = [];
116
+
117
+ try {
118
+ if (spritesPath && fs.existsSync(spritesPath) && fs.statSync(spritesPath).isDirectory()) {
119
+ sprites = fs.readdirSync(spritesPath)
120
+ .filter(file => {
121
+ const mimeType = mime.lookup(file);
122
+ return mimeType && mimeType.startsWith('image/');
123
+ })
124
+ .map((file) => {
125
+ const pathToSprite = path.join(spritesPath, file);
126
+ return {
127
+ label: path.parse(pathToSprite).name.toLowerCase(),
128
+ path: `/characters/${name}/${file}`,
129
+ };
130
+ });
131
+ }
132
+ }
133
+ catch (err) {
134
+ console.log(err);
135
+ }
136
+ return response.send(sprites);
137
+ });
138
+
139
+ router.post('/delete', jsonParser, async (request, response) => {
140
+ const label = request.body.label;
141
+ const name = request.body.name;
142
+
143
+ if (!label || !name) {
144
+ return response.sendStatus(400);
145
+ }
146
+
147
+ try {
148
+ const spritesPath = path.join(request.user.directories.characters, name);
149
+
150
+ // No sprites folder exists, or not a directory
151
+ if (!fs.existsSync(spritesPath) || !fs.statSync(spritesPath).isDirectory()) {
152
+ return response.sendStatus(404);
153
+ }
154
+
155
+ const files = fs.readdirSync(spritesPath);
156
+
157
+ // Remove existing sprite with the same label
158
+ for (const file of files) {
159
+ if (path.parse(file).name === label) {
160
+ fs.rmSync(path.join(spritesPath, file));
161
+ }
162
+ }
163
+
164
+ return response.sendStatus(200);
165
+ } catch (error) {
166
+ console.error(error);
167
+ return response.sendStatus(500);
168
+ }
169
+ });
170
+
171
+ router.post('/upload-zip', urlencodedParser, async (request, response) => {
172
+ const file = request.file;
173
+ const name = request.body.name;
174
+
175
+ if (!file || !name) {
176
+ return response.sendStatus(400);
177
+ }
178
+
179
+ try {
180
+ const spritesPath = path.join(request.user.directories.characters, name);
181
+
182
+ // Create sprites folder if it doesn't exist
183
+ if (!fs.existsSync(spritesPath)) {
184
+ fs.mkdirSync(spritesPath);
185
+ }
186
+
187
+ // Path to sprites is not a directory. This should never happen.
188
+ if (!fs.statSync(spritesPath).isDirectory()) {
189
+ return response.sendStatus(404);
190
+ }
191
+
192
+ const spritePackPath = path.join(file.destination, file.filename);
193
+ const sprites = await getImageBuffers(spritePackPath);
194
+ const files = fs.readdirSync(spritesPath);
195
+
196
+ for (const [filename, buffer] of sprites) {
197
+ // Remove existing sprite with the same label
198
+ const existingFile = files.find(file => path.parse(file).name === path.parse(filename).name);
199
+
200
+ if (existingFile) {
201
+ fs.rmSync(path.join(spritesPath, existingFile));
202
+ }
203
+
204
+ // Write sprite buffer to disk
205
+ const pathToSprite = path.join(spritesPath, filename);
206
+ writeFileAtomicSync(pathToSprite, buffer);
207
+ }
208
+
209
+ // Remove uploaded ZIP file
210
+ fs.rmSync(spritePackPath);
211
+ return response.send({ count: sprites.length });
212
+ } catch (error) {
213
+ console.error(error);
214
+ return response.sendStatus(500);
215
+ }
216
+ });
217
+
218
+ router.post('/upload', urlencodedParser, async (request, response) => {
219
+ const file = request.file;
220
+ const label = request.body.label;
221
+ const name = request.body.name;
222
+
223
+ if (!file || !label || !name) {
224
+ return response.sendStatus(400);
225
+ }
226
+
227
+ try {
228
+ const spritesPath = path.join(request.user.directories.characters, name);
229
+
230
+ // Create sprites folder if it doesn't exist
231
+ if (!fs.existsSync(spritesPath)) {
232
+ fs.mkdirSync(spritesPath);
233
+ }
234
+
235
+ // Path to sprites is not a directory. This should never happen.
236
+ if (!fs.statSync(spritesPath).isDirectory()) {
237
+ return response.sendStatus(404);
238
+ }
239
+
240
+ const files = fs.readdirSync(spritesPath);
241
+
242
+ // Remove existing sprite with the same label
243
+ for (const file of files) {
244
+ if (path.parse(file).name === label) {
245
+ fs.rmSync(path.join(spritesPath, file));
246
+ }
247
+ }
248
+
249
+ const filename = label + path.parse(file.originalname).ext;
250
+ const spritePath = path.join(file.destination, file.filename);
251
+ const pathToFile = path.join(spritesPath, filename);
252
+ // Copy uploaded file to sprites folder
253
+ fs.cpSync(spritePath, pathToFile);
254
+ // Remove uploaded file
255
+ fs.rmSync(spritePath);
256
+ return response.sendStatus(200);
257
+ } catch (error) {
258
+ console.error(error);
259
+ return response.sendStatus(500);
260
+ }
261
+ });
262
+
263
+ module.exports = {
264
+ router,
265
+ importRisuSprites,
266
+ };
src/endpoints/stable-diffusion.js ADDED
@@ -0,0 +1,1042 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const fetch = require('node-fetch').default;
3
+ const sanitize = require('sanitize-filename');
4
+ const { getBasicAuthHeader, delay, getHexString } = require('../util.js');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const writeFileAtomicSync = require('write-file-atomic').sync;
8
+ const { jsonParser } = require('../express-common');
9
+ const { readSecret, SECRET_KEYS } = require('./secrets.js');
10
+ const FormData = require('form-data');
11
+
12
+ /**
13
+ * Sanitizes a string.
14
+ * @param {string} x String to sanitize
15
+ * @returns {string} Sanitized string
16
+ */
17
+ function safeStr(x) {
18
+ x = String(x);
19
+ x = x.replace(/ +/g, ' ');
20
+ x = x.trim();
21
+ x = x.replace(/^[\s,.]+|[\s,.]+$/g, '');
22
+ return x;
23
+ }
24
+
25
+ const splitStrings = [
26
+ ', extremely',
27
+ ', intricate,',
28
+ ];
29
+
30
+ const dangerousPatterns = '[]【】()()|::';
31
+
32
+ /**
33
+ * Removes patterns from a string.
34
+ * @param {string} x String to sanitize
35
+ * @param {string} pattern Pattern to remove
36
+ * @returns {string} Sanitized string
37
+ */
38
+ function removePattern(x, pattern) {
39
+ for (let i = 0; i < pattern.length; i++) {
40
+ let p = pattern[i];
41
+ let regex = new RegExp('\\' + p, 'g');
42
+ x = x.replace(regex, '');
43
+ }
44
+ return x;
45
+ }
46
+
47
+ /**
48
+ * Gets the comfy workflows.
49
+ * @param {import('../users.js').UserDirectoryList} directories
50
+ * @returns {string[]} List of comfy workflows
51
+ */
52
+ function getComfyWorkflows(directories) {
53
+ return fs
54
+ .readdirSync(directories.comfyWorkflows)
55
+ .filter(file => file[0] != '.' && file.toLowerCase().endsWith('.json'))
56
+ .sort(Intl.Collator().compare);
57
+ }
58
+
59
+ const router = express.Router();
60
+
61
+ router.post('/ping', jsonParser, async (request, response) => {
62
+ try {
63
+ const url = new URL(request.body.url);
64
+ url.pathname = '/sdapi/v1/options';
65
+
66
+ const result = await fetch(url, {
67
+ method: 'GET',
68
+ headers: {
69
+ 'Authorization': getBasicAuthHeader(request.body.auth),
70
+ },
71
+ });
72
+
73
+ if (!result.ok) {
74
+ throw new Error('SD WebUI returned an error.');
75
+ }
76
+
77
+ return response.sendStatus(200);
78
+ } catch (error) {
79
+ console.log(error);
80
+ return response.sendStatus(500);
81
+ }
82
+ });
83
+
84
+ router.post('/upscalers', jsonParser, async (request, response) => {
85
+ try {
86
+ async function getUpscalerModels() {
87
+ const url = new URL(request.body.url);
88
+ url.pathname = '/sdapi/v1/upscalers';
89
+
90
+ const result = await fetch(url, {
91
+ method: 'GET',
92
+ headers: {
93
+ 'Authorization': getBasicAuthHeader(request.body.auth),
94
+ },
95
+ });
96
+
97
+ if (!result.ok) {
98
+ throw new Error('SD WebUI returned an error.');
99
+ }
100
+
101
+ const data = await result.json();
102
+ const names = data.map(x => x.name);
103
+ return names;
104
+ }
105
+
106
+ async function getLatentUpscalers() {
107
+ const url = new URL(request.body.url);
108
+ url.pathname = '/sdapi/v1/latent-upscale-modes';
109
+
110
+ const result = await fetch(url, {
111
+ method: 'GET',
112
+ headers: {
113
+ 'Authorization': getBasicAuthHeader(request.body.auth),
114
+ },
115
+ });
116
+
117
+ if (!result.ok) {
118
+ throw new Error('SD WebUI returned an error.');
119
+ }
120
+
121
+ const data = await result.json();
122
+ const names = data.map(x => x.name);
123
+ return names;
124
+ }
125
+
126
+ const [upscalers, latentUpscalers] = await Promise.all([getUpscalerModels(), getLatentUpscalers()]);
127
+
128
+ // 0 = None, then Latent Upscalers, then Upscalers
129
+ upscalers.splice(1, 0, ...latentUpscalers);
130
+
131
+ return response.send(upscalers);
132
+ } catch (error) {
133
+ console.log(error);
134
+ return response.sendStatus(500);
135
+ }
136
+ });
137
+
138
+ router.post('/vaes', jsonParser, async (request, response) => {
139
+ try {
140
+ const url = new URL(request.body.url);
141
+ url.pathname = '/sdapi/v1/sd-vae';
142
+
143
+ const result = await fetch(url, {
144
+ method: 'GET',
145
+ headers: {
146
+ 'Authorization': getBasicAuthHeader(request.body.auth),
147
+ },
148
+ });
149
+
150
+ if (!result.ok) {
151
+ throw new Error('SD WebUI returned an error.');
152
+ }
153
+
154
+ const data = await result.json();
155
+ const names = data.map(x => x.model_name);
156
+ return response.send(names);
157
+ } catch (error) {
158
+ console.log(error);
159
+ return response.sendStatus(500);
160
+ }
161
+ });
162
+
163
+ router.post('/samplers', jsonParser, async (request, response) => {
164
+ try {
165
+ const url = new URL(request.body.url);
166
+ url.pathname = '/sdapi/v1/samplers';
167
+
168
+ const result = await fetch(url, {
169
+ method: 'GET',
170
+ headers: {
171
+ 'Authorization': getBasicAuthHeader(request.body.auth),
172
+ },
173
+ });
174
+
175
+ if (!result.ok) {
176
+ throw new Error('SD WebUI returned an error.');
177
+ }
178
+
179
+ const data = await result.json();
180
+ const names = data.map(x => x.name);
181
+ return response.send(names);
182
+
183
+ } catch (error) {
184
+ console.log(error);
185
+ return response.sendStatus(500);
186
+ }
187
+ });
188
+
189
+ router.post('/schedulers', jsonParser, async (request, response) => {
190
+ try {
191
+ const url = new URL(request.body.url);
192
+ url.pathname = '/sdapi/v1/schedulers';
193
+
194
+ const result = await fetch(url, {
195
+ method: 'GET',
196
+ headers: {
197
+ 'Authorization': getBasicAuthHeader(request.body.auth),
198
+ },
199
+ });
200
+
201
+ if (!result.ok) {
202
+ throw new Error('SD WebUI returned an error.');
203
+ }
204
+
205
+ const data = await result.json();
206
+ const names = data.map(x => x.name);
207
+ return response.send(names);
208
+ } catch (error) {
209
+ console.log(error);
210
+ return response.sendStatus(500);
211
+ }
212
+ });
213
+
214
+ router.post('/models', jsonParser, async (request, response) => {
215
+ try {
216
+ const url = new URL(request.body.url);
217
+ url.pathname = '/sdapi/v1/sd-models';
218
+
219
+ const result = await fetch(url, {
220
+ method: 'GET',
221
+ headers: {
222
+ 'Authorization': getBasicAuthHeader(request.body.auth),
223
+ },
224
+ });
225
+
226
+ if (!result.ok) {
227
+ throw new Error('SD WebUI returned an error.');
228
+ }
229
+
230
+ const data = await result.json();
231
+ const models = data.map(x => ({ value: x.title, text: x.title }));
232
+ return response.send(models);
233
+ } catch (error) {
234
+ console.log(error);
235
+ return response.sendStatus(500);
236
+ }
237
+ });
238
+
239
+ router.post('/get-model', jsonParser, async (request, response) => {
240
+ try {
241
+ const url = new URL(request.body.url);
242
+ url.pathname = '/sdapi/v1/options';
243
+
244
+ const result = await fetch(url, {
245
+ method: 'GET',
246
+ headers: {
247
+ 'Authorization': getBasicAuthHeader(request.body.auth),
248
+ },
249
+ });
250
+ const data = await result.json();
251
+ return response.send(data['sd_model_checkpoint']);
252
+ } catch (error) {
253
+ console.log(error);
254
+ return response.sendStatus(500);
255
+ }
256
+ });
257
+
258
+ router.post('/set-model', jsonParser, async (request, response) => {
259
+ try {
260
+ async function getProgress() {
261
+ const url = new URL(request.body.url);
262
+ url.pathname = '/sdapi/v1/progress';
263
+
264
+ const result = await fetch(url, {
265
+ method: 'GET',
266
+ headers: {
267
+ 'Authorization': getBasicAuthHeader(request.body.auth),
268
+ },
269
+ timeout: 0,
270
+ });
271
+ const data = await result.json();
272
+ return data;
273
+ }
274
+
275
+ const url = new URL(request.body.url);
276
+ url.pathname = '/sdapi/v1/options';
277
+
278
+ const options = {
279
+ sd_model_checkpoint: request.body.model,
280
+ };
281
+
282
+ const result = await fetch(url, {
283
+ method: 'POST',
284
+ body: JSON.stringify(options),
285
+ headers: {
286
+ 'Content-Type': 'application/json',
287
+ 'Authorization': getBasicAuthHeader(request.body.auth),
288
+ },
289
+ timeout: 0,
290
+ });
291
+
292
+ if (!result.ok) {
293
+ throw new Error('SD WebUI returned an error.');
294
+ }
295
+
296
+ const MAX_ATTEMPTS = 10;
297
+ const CHECK_INTERVAL = 2000;
298
+
299
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
300
+ const progressState = await getProgress();
301
+
302
+ const progress = progressState['progress'];
303
+ const jobCount = progressState['state']['job_count'];
304
+ if (progress == 0.0 && jobCount === 0) {
305
+ break;
306
+ }
307
+
308
+ console.log(`Waiting for SD WebUI to finish model loading... Progress: ${progress}; Job count: ${jobCount}`);
309
+ await delay(CHECK_INTERVAL);
310
+ }
311
+
312
+ return response.sendStatus(200);
313
+ } catch (error) {
314
+ console.log(error);
315
+ return response.sendStatus(500);
316
+ }
317
+ });
318
+
319
+ router.post('/generate', jsonParser, async (request, response) => {
320
+ try {
321
+ console.log('SD WebUI request:', request.body);
322
+
323
+ const url = new URL(request.body.url);
324
+ url.pathname = '/sdapi/v1/txt2img';
325
+
326
+ const controller = new AbortController();
327
+ request.socket.removeAllListeners('close');
328
+ request.socket.on('close', function () {
329
+ if (!response.writableEnded) {
330
+ const url = new URL(request.body.url);
331
+ url.pathname = '/sdapi/v1/interrupt';
332
+ fetch(url, { method: 'POST', headers: { 'Authorization': getBasicAuthHeader(request.body.auth) } });
333
+ }
334
+ controller.abort();
335
+ });
336
+
337
+ const result = await fetch(url, {
338
+ method: 'POST',
339
+ body: JSON.stringify(request.body),
340
+ headers: {
341
+ 'Content-Type': 'application/json',
342
+ 'Authorization': getBasicAuthHeader(request.body.auth),
343
+ },
344
+ timeout: 0,
345
+ // @ts-ignore
346
+ signal: controller.signal,
347
+ });
348
+
349
+ if (!result.ok) {
350
+ const text = await result.text();
351
+ throw new Error('SD WebUI returned an error.', { cause: text });
352
+ }
353
+
354
+ const data = await result.json();
355
+ return response.send(data);
356
+ } catch (error) {
357
+ console.log(error);
358
+ return response.sendStatus(500);
359
+ }
360
+ });
361
+
362
+ router.post('/sd-next/upscalers', jsonParser, async (request, response) => {
363
+ try {
364
+ const url = new URL(request.body.url);
365
+ url.pathname = '/sdapi/v1/upscalers';
366
+
367
+ const result = await fetch(url, {
368
+ method: 'GET',
369
+ headers: {
370
+ 'Authorization': getBasicAuthHeader(request.body.auth),
371
+ },
372
+ });
373
+
374
+ if (!result.ok) {
375
+ throw new Error('SD WebUI returned an error.');
376
+ }
377
+
378
+ // Vlad doesn't provide Latent Upscalers in the API, so we have to hardcode them here
379
+ const latentUpscalers = ['Latent', 'Latent (antialiased)', 'Latent (bicubic)', 'Latent (bicubic antialiased)', 'Latent (nearest)', 'Latent (nearest-exact)'];
380
+
381
+ const data = await result.json();
382
+ const names = data.map(x => x.name);
383
+
384
+ // 0 = None, then Latent Upscalers, then Upscalers
385
+ names.splice(1, 0, ...latentUpscalers);
386
+
387
+ return response.send(names);
388
+ } catch (error) {
389
+ console.log(error);
390
+ return response.sendStatus(500);
391
+ }
392
+ });
393
+
394
+ /**
395
+ * SD prompt expansion using GPT-2 text generation model.
396
+ * Adapted from: https://github.com/lllyasviel/Fooocus/blob/main/modules/expansion.py
397
+ */
398
+ router.post('/expand', jsonParser, async (request, response) => {
399
+ const originalPrompt = request.body.prompt;
400
+
401
+ if (!originalPrompt) {
402
+ console.warn('No prompt provided for SD expansion.');
403
+ return response.send({ prompt: '' });
404
+ }
405
+
406
+ console.log('Refine prompt input:', originalPrompt);
407
+ const splitString = splitStrings[Math.floor(Math.random() * splitStrings.length)];
408
+ let prompt = safeStr(originalPrompt) + splitString;
409
+
410
+ try {
411
+ const task = 'text-generation';
412
+ const module = await import('../transformers.mjs');
413
+ const pipe = await module.default.getPipeline(task);
414
+
415
+ const result = await pipe(prompt, { num_beams: 1, max_new_tokens: 256, do_sample: true });
416
+
417
+ const newText = result[0].generated_text;
418
+ const newPrompt = safeStr(removePattern(newText, dangerousPatterns));
419
+ console.log('Refine prompt output:', newPrompt);
420
+
421
+ return response.send({ prompt: newPrompt });
422
+ } catch {
423
+ console.warn('Failed to load transformers.js pipeline.');
424
+ return response.send({ prompt: originalPrompt });
425
+ }
426
+ });
427
+
428
+ const comfy = express.Router();
429
+
430
+ comfy.post('/ping', jsonParser, async (request, response) => {
431
+ try {
432
+ const url = new URL(request.body.url);
433
+ url.pathname = '/system_stats';
434
+
435
+ const result = await fetch(url);
436
+ if (!result.ok) {
437
+ throw new Error('ComfyUI returned an error.');
438
+ }
439
+
440
+ return response.sendStatus(200);
441
+ } catch (error) {
442
+ console.log(error);
443
+ return response.sendStatus(500);
444
+ }
445
+ });
446
+
447
+ comfy.post('/samplers', jsonParser, async (request, response) => {
448
+ try {
449
+ const url = new URL(request.body.url);
450
+ url.pathname = '/object_info';
451
+
452
+ const result = await fetch(url);
453
+ if (!result.ok) {
454
+ throw new Error('ComfyUI returned an error.');
455
+ }
456
+
457
+ const data = await result.json();
458
+ return response.send(data.KSampler.input.required.sampler_name[0]);
459
+ } catch (error) {
460
+ console.log(error);
461
+ return response.sendStatus(500);
462
+ }
463
+ });
464
+
465
+ comfy.post('/models', jsonParser, async (request, response) => {
466
+ try {
467
+ const url = new URL(request.body.url);
468
+ url.pathname = '/object_info';
469
+
470
+ const result = await fetch(url);
471
+ if (!result.ok) {
472
+ throw new Error('ComfyUI returned an error.');
473
+ }
474
+ const data = await result.json();
475
+ return response.send(data.CheckpointLoaderSimple.input.required.ckpt_name[0].map(it => ({ value: it, text: it })));
476
+ } catch (error) {
477
+ console.log(error);
478
+ return response.sendStatus(500);
479
+ }
480
+ });
481
+
482
+ comfy.post('/schedulers', jsonParser, async (request, response) => {
483
+ try {
484
+ const url = new URL(request.body.url);
485
+ url.pathname = '/object_info';
486
+
487
+ const result = await fetch(url);
488
+ if (!result.ok) {
489
+ throw new Error('ComfyUI returned an error.');
490
+ }
491
+
492
+ const data = await result.json();
493
+ return response.send(data.KSampler.input.required.scheduler[0]);
494
+ } catch (error) {
495
+ console.log(error);
496
+ return response.sendStatus(500);
497
+ }
498
+ });
499
+
500
+ comfy.post('/vaes', jsonParser, async (request, response) => {
501
+ try {
502
+ const url = new URL(request.body.url);
503
+ url.pathname = '/object_info';
504
+
505
+ const result = await fetch(url);
506
+ if (!result.ok) {
507
+ throw new Error('ComfyUI returned an error.');
508
+ }
509
+
510
+ const data = await result.json();
511
+ return response.send(data.VAELoader.input.required.vae_name[0]);
512
+ } catch (error) {
513
+ console.log(error);
514
+ return response.sendStatus(500);
515
+ }
516
+ });
517
+
518
+ comfy.post('/workflows', jsonParser, async (request, response) => {
519
+ try {
520
+ const data = getComfyWorkflows(request.user.directories);
521
+ return response.send(data);
522
+ } catch (error) {
523
+ console.log(error);
524
+ return response.sendStatus(500);
525
+ }
526
+ });
527
+
528
+ comfy.post('/workflow', jsonParser, async (request, response) => {
529
+ try {
530
+ let filePath = path.join(request.user.directories.comfyWorkflows, sanitize(String(request.body.file_name)));
531
+ if (!fs.existsSync(filePath)) {
532
+ filePath = path.join(request.user.directories.comfyWorkflows, 'Default_Comfy_Workflow.json');
533
+ }
534
+ const data = fs.readFileSync(filePath, { encoding: 'utf-8' });
535
+ return response.send(JSON.stringify(data));
536
+ } catch (error) {
537
+ console.log(error);
538
+ return response.sendStatus(500);
539
+ }
540
+ });
541
+
542
+ comfy.post('/save-workflow', jsonParser, async (request, response) => {
543
+ try {
544
+ const filePath = path.join(request.user.directories.comfyWorkflows, sanitize(String(request.body.file_name)));
545
+ writeFileAtomicSync(filePath, request.body.workflow, 'utf8');
546
+ const data = getComfyWorkflows(request.user.directories);
547
+ return response.send(data);
548
+ } catch (error) {
549
+ console.log(error);
550
+ return response.sendStatus(500);
551
+ }
552
+ });
553
+
554
+ comfy.post('/delete-workflow', jsonParser, async (request, response) => {
555
+ try {
556
+ const filePath = path.join(request.user.directories.comfyWorkflows, sanitize(String(request.body.file_name)));
557
+ if (fs.existsSync(filePath)) {
558
+ fs.unlinkSync(filePath);
559
+ }
560
+ return response.sendStatus(200);
561
+ } catch (error) {
562
+ console.log(error);
563
+ return response.sendStatus(500);
564
+ }
565
+ });
566
+
567
+ comfy.post('/generate', jsonParser, async (request, response) => {
568
+ try {
569
+ const url = new URL(request.body.url);
570
+ url.pathname = '/prompt';
571
+
572
+ const controller = new AbortController();
573
+ request.socket.removeAllListeners('close');
574
+ request.socket.on('close', function () {
575
+ if (!response.writableEnded && !item) {
576
+ const interruptUrl = new URL(request.body.url);
577
+ interruptUrl.pathname = '/interrupt';
578
+ fetch(interruptUrl, { method: 'POST', headers: { 'Authorization': getBasicAuthHeader(request.body.auth) } });
579
+ }
580
+ controller.abort();
581
+ });
582
+
583
+ const promptResult = await fetch(url, {
584
+ method: 'POST',
585
+ body: request.body.prompt,
586
+ });
587
+ if (!promptResult.ok) {
588
+ throw new Error('ComfyUI returned an error.');
589
+ }
590
+
591
+ const data = await promptResult.json();
592
+ const id = data.prompt_id;
593
+ let item;
594
+ const historyUrl = new URL(request.body.url);
595
+ historyUrl.pathname = '/history';
596
+ while (true) {
597
+ const result = await fetch(historyUrl);
598
+ if (!result.ok) {
599
+ throw new Error('ComfyUI returned an error.');
600
+ }
601
+ const history = await result.json();
602
+ item = history[id];
603
+ if (item) {
604
+ break;
605
+ }
606
+ await delay(100);
607
+ }
608
+ if (item.status.status_str === 'error') {
609
+ throw new Error('ComfyUI generation did not succeed.');
610
+ }
611
+ const imgInfo = Object.keys(item.outputs).map(it => item.outputs[it].images).flat()[0];
612
+ const imgUrl = new URL(request.body.url);
613
+ imgUrl.pathname = '/view';
614
+ imgUrl.search = `?filename=${imgInfo.filename}&subfolder=${imgInfo.subfolder}&type=${imgInfo.type}`;
615
+ const imgResponse = await fetch(imgUrl);
616
+ if (!imgResponse.ok) {
617
+ throw new Error('ComfyUI returned an error.');
618
+ }
619
+ const imgBuffer = await imgResponse.buffer();
620
+ return response.send(imgBuffer.toString('base64'));
621
+ } catch (error) {
622
+ console.log(error);
623
+ return response.sendStatus(500);
624
+ }
625
+ });
626
+
627
+ const together = express.Router();
628
+
629
+ together.post('/models', jsonParser, async (request, response) => {
630
+ try {
631
+ const key = readSecret(request.user.directories, SECRET_KEYS.TOGETHERAI);
632
+
633
+ if (!key) {
634
+ console.log('TogetherAI key not found.');
635
+ return response.sendStatus(400);
636
+ }
637
+
638
+ const modelsResponse = await fetch('https://api.together.xyz/api/models', {
639
+ method: 'GET',
640
+ headers: {
641
+ 'Authorization': `Bearer ${key}`,
642
+ },
643
+ });
644
+
645
+ if (!modelsResponse.ok) {
646
+ console.log('TogetherAI returned an error.');
647
+ return response.sendStatus(500);
648
+ }
649
+
650
+ const data = await modelsResponse.json();
651
+
652
+ if (!Array.isArray(data)) {
653
+ console.log('TogetherAI returned invalid data.');
654
+ return response.sendStatus(500);
655
+ }
656
+
657
+ const models = data
658
+ .filter(x => x.display_type === 'image')
659
+ .map(x => ({ value: x.name, text: x.display_name }));
660
+
661
+ return response.send(models);
662
+ } catch (error) {
663
+ console.log(error);
664
+ return response.sendStatus(500);
665
+ }
666
+ });
667
+
668
+ together.post('/generate', jsonParser, async (request, response) => {
669
+ try {
670
+ const key = readSecret(request.user.directories, SECRET_KEYS.TOGETHERAI);
671
+
672
+ if (!key) {
673
+ console.log('TogetherAI key not found.');
674
+ return response.sendStatus(400);
675
+ }
676
+
677
+ console.log('TogetherAI request:', request.body);
678
+
679
+ const result = await fetch('https://api.together.xyz/api/inference', {
680
+ method: 'POST',
681
+ body: JSON.stringify({
682
+ request_type: 'image-model-inference',
683
+ prompt: request.body.prompt,
684
+ negative_prompt: request.body.negative_prompt,
685
+ height: request.body.height,
686
+ width: request.body.width,
687
+ model: request.body.model,
688
+ steps: request.body.steps,
689
+ n: 1,
690
+ // Limited to 10000 on playground, works fine with more.
691
+ seed: request.body.seed >= 0 ? request.body.seed : Math.floor(Math.random() * 10_000_000),
692
+ // Don't know if that's supposed to be random or not. It works either way.
693
+ sessionKey: getHexString(40),
694
+ }),
695
+ headers: {
696
+ 'Content-Type': 'application/json',
697
+ 'Authorization': `Bearer ${key}`,
698
+ },
699
+ });
700
+
701
+ if (!result.ok) {
702
+ console.log('TogetherAI returned an error.');
703
+ return response.sendStatus(500);
704
+ }
705
+
706
+ const data = await result.json();
707
+ console.log('TogetherAI response:', data);
708
+
709
+ if (data.status !== 'finished') {
710
+ console.log('TogetherAI job failed.');
711
+ return response.sendStatus(500);
712
+ }
713
+
714
+ return response.send(data);
715
+ } catch (error) {
716
+ console.log(error);
717
+ return response.sendStatus(500);
718
+ }
719
+ });
720
+
721
+ const drawthings = express.Router();
722
+
723
+ drawthings.post('/ping', jsonParser, async (request, response) => {
724
+ try {
725
+ const url = new URL(request.body.url);
726
+ url.pathname = '/';
727
+
728
+ const result = await fetch(url, {
729
+ method: 'HEAD',
730
+ });
731
+
732
+ if (!result.ok) {
733
+ throw new Error('SD DrawThings API returned an error.');
734
+ }
735
+
736
+ return response.sendStatus(200);
737
+ } catch (error) {
738
+ console.log(error);
739
+ return response.sendStatus(500);
740
+ }
741
+ });
742
+
743
+ drawthings.post('/get-model', jsonParser, async (request, response) => {
744
+ try {
745
+ const url = new URL(request.body.url);
746
+ url.pathname = '/';
747
+
748
+ const result = await fetch(url, {
749
+ method: 'GET',
750
+ });
751
+ const data = await result.json();
752
+
753
+ return response.send(data['model']);
754
+ } catch (error) {
755
+ console.log(error);
756
+ return response.sendStatus(500);
757
+ }
758
+ });
759
+
760
+ drawthings.post('/get-upscaler', jsonParser, async (request, response) => {
761
+ try {
762
+ const url = new URL(request.body.url);
763
+ url.pathname = '/';
764
+
765
+ const result = await fetch(url, {
766
+ method: 'GET',
767
+ });
768
+ const data = await result.json();
769
+
770
+ return response.send(data['upscaler']);
771
+ } catch (error) {
772
+ console.log(error);
773
+ return response.sendStatus(500);
774
+ }
775
+ });
776
+
777
+ drawthings.post('/generate', jsonParser, async (request, response) => {
778
+ try {
779
+ console.log('SD DrawThings API request:', request.body);
780
+
781
+ const url = new URL(request.body.url);
782
+ url.pathname = '/sdapi/v1/txt2img';
783
+
784
+ const body = { ...request.body };
785
+ const auth = getBasicAuthHeader(request.body.auth);
786
+ delete body.url;
787
+ delete body.auth;
788
+
789
+ const result = await fetch(url, {
790
+ method: 'POST',
791
+ body: JSON.stringify(body),
792
+ headers: {
793
+ 'Content-Type': 'application/json',
794
+ 'Authorization': auth,
795
+ },
796
+ timeout: 0,
797
+ });
798
+
799
+ if (!result.ok) {
800
+ const text = await result.text();
801
+ throw new Error('SD DrawThings API returned an error.', { cause: text });
802
+ }
803
+
804
+ const data = await result.json();
805
+ return response.send(data);
806
+ } catch (error) {
807
+ console.log(error);
808
+ return response.sendStatus(500);
809
+ }
810
+ });
811
+
812
+ const pollinations = express.Router();
813
+
814
+ pollinations.post('/generate', jsonParser, async (request, response) => {
815
+ try {
816
+ const promptUrl = new URL(`https://image.pollinations.ai/prompt/${encodeURIComponent(request.body.prompt)}`);
817
+ const params = new URLSearchParams({
818
+ model: String(request.body.model),
819
+ negative_prompt: String(request.body.negative_prompt),
820
+ seed: String(request.body.seed >= 0 ? request.body.seed : Math.floor(Math.random() * 10_000_000)),
821
+ enhance: String(request.body.enhance ?? false),
822
+ refine: String(request.body.refine ?? false),
823
+ width: String(request.body.width ?? 1024),
824
+ height: String(request.body.height ?? 1024),
825
+ nologo: String(true),
826
+ nofeed: String(true),
827
+ referer: 'sillytavern',
828
+ });
829
+ promptUrl.search = params.toString();
830
+
831
+ console.log('Pollinations request URL:', promptUrl.toString());
832
+
833
+ const result = await fetch(promptUrl);
834
+
835
+ if (!result.ok) {
836
+ console.log('Pollinations returned an error.', result.status, result.statusText);
837
+ throw new Error('Pollinations request failed.');
838
+ }
839
+
840
+ const buffer = await result.buffer();
841
+ const base64 = buffer.toString('base64');
842
+
843
+ return response.send({ image: base64 });
844
+ } catch (error) {
845
+ console.log(error);
846
+ return response.sendStatus(500);
847
+ }
848
+ });
849
+
850
+ const stability = express.Router();
851
+
852
+ stability.post('/generate', jsonParser, async (request, response) => {
853
+ try {
854
+ const key = readSecret(request.user.directories, SECRET_KEYS.STABILITY);
855
+
856
+ if (!key) {
857
+ console.log('Stability AI key not found.');
858
+ return response.sendStatus(400);
859
+ }
860
+
861
+ const { payload, model } = request.body;
862
+
863
+ console.log('Stability AI request:', model, payload);
864
+
865
+ const formData = new FormData();
866
+ for (const [key, value] of Object.entries(payload)) {
867
+ if (value !== undefined) {
868
+ formData.append(key, String(value));
869
+ }
870
+ }
871
+
872
+ let apiUrl;
873
+ switch (model) {
874
+ case 'stable-image-ultra':
875
+ apiUrl = 'https://api.stability.ai/v2beta/stable-image/generate/ultra';
876
+ break;
877
+ case 'stable-image-core':
878
+ apiUrl = 'https://api.stability.ai/v2beta/stable-image/generate/core';
879
+ break;
880
+ case 'stable-diffusion-3':
881
+ apiUrl = 'https://api.stability.ai/v2beta/stable-image/generate/sd3';
882
+ break;
883
+ default:
884
+ throw new Error('Invalid Stability AI model selected');
885
+ }
886
+
887
+ const result = await fetch(apiUrl, {
888
+ method: 'POST',
889
+ headers: {
890
+ 'Authorization': `Bearer ${key}`,
891
+ 'Accept': 'image/*',
892
+ },
893
+ body: formData,
894
+ timeout: 0,
895
+ });
896
+
897
+ if (!result.ok) {
898
+ const text = await result.text();
899
+ console.log('Stability AI returned an error.', result.status, result.statusText, text);
900
+ return response.sendStatus(500);
901
+ }
902
+
903
+ const buffer = await result.buffer();
904
+ return response.send(buffer.toString('base64'));
905
+ } catch (error) {
906
+ console.log(error);
907
+ return response.sendStatus(500);
908
+ }
909
+ });
910
+
911
+ const blockentropy = express.Router();
912
+
913
+ blockentropy.post('/models', jsonParser, async (request, response) => {
914
+ try {
915
+ const key = readSecret(request.user.directories, SECRET_KEYS.BLOCKENTROPY);
916
+
917
+ if (!key) {
918
+ console.log('Block Entropy key not found.');
919
+ return response.sendStatus(400);
920
+ }
921
+
922
+ const modelsResponse = await fetch('https://api.blockentropy.ai/sdapi/v1/sd-models', {
923
+ method: 'GET',
924
+ headers: {
925
+ 'Authorization': `Bearer ${key}`,
926
+ },
927
+ });
928
+
929
+ if (!modelsResponse.ok) {
930
+ console.log('Block Entropy returned an error.');
931
+ return response.sendStatus(500);
932
+ }
933
+
934
+ const data = await modelsResponse.json();
935
+
936
+ if (!Array.isArray(data)) {
937
+ console.log('Block Entropy returned invalid data.');
938
+ return response.sendStatus(500);
939
+ }
940
+ const models = data.map(x => ({ value: x.name, text: x.name }));
941
+ return response.send(models);
942
+
943
+ } catch (error) {
944
+ console.log(error);
945
+ return response.sendStatus(500);
946
+ }
947
+ });
948
+
949
+ blockentropy.post('/generate', jsonParser, async (request, response) => {
950
+ try {
951
+ const key = readSecret(request.user.directories, SECRET_KEYS.BLOCKENTROPY);
952
+
953
+ if (!key) {
954
+ console.log('Block Entropy key not found.');
955
+ return response.sendStatus(400);
956
+ }
957
+
958
+ console.log('Block Entropy request:', request.body);
959
+
960
+ const result = await fetch('https://api.blockentropy.ai/sdapi/v1/txt2img', {
961
+ method: 'POST',
962
+ body: JSON.stringify({
963
+ prompt: request.body.prompt,
964
+ negative_prompt: request.body.negative_prompt,
965
+ model: request.body.model,
966
+ steps: request.body.steps,
967
+ width: request.body.width,
968
+ height: request.body.height,
969
+ // Random seed if negative.
970
+ seed: request.body.seed >= 0 ? request.body.seed : Math.floor(Math.random() * 10_000_000),
971
+ }),
972
+ headers: {
973
+ 'Content-Type': 'application/json',
974
+ 'Authorization': `Bearer ${key}`,
975
+ },
976
+ });
977
+
978
+ if (!result.ok) {
979
+ console.log('Block Entropy returned an error.');
980
+ return response.sendStatus(500);
981
+ }
982
+
983
+ const data = await result.json();
984
+ console.log('Block Entropy response:', data);
985
+
986
+ return response.send(data);
987
+ } catch (error) {
988
+ console.log(error);
989
+ return response.sendStatus(500);
990
+ }
991
+ });
992
+
993
+
994
+ const huggingface = express.Router();
995
+
996
+ huggingface.post('/generate', jsonParser, async (request, response) => {
997
+ try {
998
+ const key = readSecret(request.user.directories, SECRET_KEYS.HUGGINGFACE);
999
+
1000
+ if (!key) {
1001
+ console.log('Hugging Face key not found.');
1002
+ return response.sendStatus(400);
1003
+ }
1004
+
1005
+ console.log('Hugging Face request:', request.body);
1006
+
1007
+ const result = await fetch(`https://api-inference.huggingface.co/models/${request.body.model}`, {
1008
+ method: 'POST',
1009
+ body: JSON.stringify({
1010
+ inputs: request.body.prompt,
1011
+ }),
1012
+ headers: {
1013
+ 'Content-Type': 'application/json',
1014
+ 'Authorization': `Bearer ${key}`,
1015
+ },
1016
+ });
1017
+
1018
+ if (!result.ok) {
1019
+ console.log('Hugging Face returned an error.');
1020
+ return response.sendStatus(500);
1021
+ }
1022
+
1023
+ const buffer = await result.buffer();
1024
+ return response.send({
1025
+ image: buffer.toString('base64'),
1026
+ });
1027
+ } catch (error) {
1028
+ console.log(error);
1029
+ return response.sendStatus(500);
1030
+ }
1031
+ });
1032
+
1033
+
1034
+ router.use('/comfy', comfy);
1035
+ router.use('/together', together);
1036
+ router.use('/drawthings', drawthings);
1037
+ router.use('/pollinations', pollinations);
1038
+ router.use('/stability', stability);
1039
+ router.use('/blockentropy', blockentropy);
1040
+ router.use('/huggingface', huggingface);
1041
+
1042
+ module.exports = { router };
src/endpoints/stats.js ADDED
@@ -0,0 +1,474 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const express = require('express');
4
+ const writeFileAtomic = require('write-file-atomic');
5
+ const crypto = require('crypto');
6
+
7
+ const readFile = fs.promises.readFile;
8
+ const readdir = fs.promises.readdir;
9
+
10
+ const { jsonParser } = require('../express-common');
11
+ const { getAllUserHandles, getUserDirectories } = require('../users');
12
+
13
+ const STATS_FILE = 'stats.json';
14
+
15
+ /**
16
+ * @type {Map<string, Object>} The stats object for each user.
17
+ */
18
+ const STATS = new Map();
19
+ /**
20
+ * @type {Map<string, number>} The timestamps for each user.
21
+ */
22
+ const TIMESTAMPS = new Map();
23
+
24
+ /**
25
+ * Convert a timestamp to an integer timestamp.
26
+ * (sorry, it's momentless for now, didn't want to add a package just for this)
27
+ * This function can handle several different timestamp formats:
28
+ * 1. Unix timestamps (the number of seconds since the Unix Epoch)
29
+ * 2. ST "humanized" timestamps, formatted like "YYYY-MM-DD @HHh MMm SSs ms"
30
+ * 3. Date strings in the format "Month DD, YYYY H:MMam/pm"
31
+ *
32
+ * The function returns the timestamp as the number of milliseconds since
33
+ * the Unix Epoch, which can be converted to a JavaScript Date object with new Date().
34
+ *
35
+ * @param {string|number} timestamp - The timestamp to convert.
36
+ * @returns {number} The timestamp in milliseconds since the Unix Epoch, or 0 if the input cannot be parsed.
37
+ *
38
+ * @example
39
+ * // Unix timestamp
40
+ * timestampToMoment(1609459200);
41
+ * // ST humanized timestamp
42
+ * timestampToMoment("2021-01-01 \@00h 00m 00s 000ms");
43
+ * // Date string
44
+ * timestampToMoment("January 1, 2021 12:00am");
45
+ */
46
+ function timestampToMoment(timestamp) {
47
+ if (!timestamp) {
48
+ return 0;
49
+ }
50
+
51
+ if (typeof timestamp === 'number') {
52
+ return timestamp;
53
+ }
54
+
55
+ const pattern1 =
56
+ /(\d{4})-(\d{1,2})-(\d{1,2}) @(\d{1,2})h (\d{1,2})m (\d{1,2})s (\d{1,3})ms/;
57
+ const replacement1 = (
58
+ match,
59
+ year,
60
+ month,
61
+ day,
62
+ hour,
63
+ minute,
64
+ second,
65
+ millisecond,
66
+ ) => {
67
+ return `${year}-${month.padStart(2, '0')}-${day.padStart(
68
+ 2,
69
+ '0',
70
+ )}T${hour.padStart(2, '0')}:${minute.padStart(
71
+ 2,
72
+ '0',
73
+ )}:${second.padStart(2, '0')}.${millisecond.padStart(3, '0')}Z`;
74
+ };
75
+ const isoTimestamp1 = timestamp.replace(pattern1, replacement1);
76
+ if (!isNaN(Number(new Date(isoTimestamp1)))) {
77
+ return new Date(isoTimestamp1).getTime();
78
+ }
79
+
80
+ const pattern2 = /(\w+)\s(\d{1,2}),\s(\d{4})\s(\d{1,2}):(\d{1,2})(am|pm)/i;
81
+ const replacement2 = (match, month, day, year, hour, minute, meridiem) => {
82
+ const monthNames = [
83
+ 'January',
84
+ 'February',
85
+ 'March',
86
+ 'April',
87
+ 'May',
88
+ 'June',
89
+ 'July',
90
+ 'August',
91
+ 'September',
92
+ 'October',
93
+ 'November',
94
+ 'December',
95
+ ];
96
+ const monthNum = monthNames.indexOf(month) + 1;
97
+ const hour24 =
98
+ meridiem.toLowerCase() === 'pm'
99
+ ? (parseInt(hour, 10) % 12) + 12
100
+ : parseInt(hour, 10) % 12;
101
+ return `${year}-${monthNum.toString().padStart(2, '0')}-${day.padStart(
102
+ 2,
103
+ '0',
104
+ )}T${hour24.toString().padStart(2, '0')}:${minute.padStart(
105
+ 2,
106
+ '0',
107
+ )}:00Z`;
108
+ };
109
+ const isoTimestamp2 = timestamp.replace(pattern2, replacement2);
110
+ if (!isNaN(Number(new Date(isoTimestamp2)))) {
111
+ return new Date(isoTimestamp2).getTime();
112
+ }
113
+
114
+ return 0;
115
+ }
116
+
117
+ /**
118
+ * Collects and aggregates stats for all characters.
119
+ *
120
+ * @param {string} chatsPath - The path to the directory containing the chat files.
121
+ * @param {string} charactersPath - The path to the directory containing the character files.
122
+ * @returns {Promise<Object>} The aggregated stats object.
123
+ */
124
+ async function collectAndCreateStats(chatsPath, charactersPath) {
125
+ const files = await readdir(charactersPath);
126
+
127
+ const pngFiles = files.filter((file) => file.endsWith('.png'));
128
+
129
+ let processingPromises = pngFiles.map((file) =>
130
+ calculateStats(chatsPath, file),
131
+ );
132
+ const statsArr = await Promise.all(processingPromises);
133
+
134
+ let finalStats = {};
135
+ for (let stat of statsArr) {
136
+ finalStats = { ...finalStats, ...stat };
137
+ }
138
+ // tag with timestamp on when stats were generated
139
+ finalStats.timestamp = Date.now();
140
+ return finalStats;
141
+ }
142
+
143
+ /**
144
+ * Recreates the stats object for a user.
145
+ * @param {string} handle User handle
146
+ * @param {string} chatsPath Path to the directory containing the chat files.
147
+ * @param {string} charactersPath Path to the directory containing the character files.
148
+ */
149
+ async function recreateStats(handle, chatsPath, charactersPath) {
150
+ console.log('Collecting and creating stats for user:', handle);
151
+ const stats = await collectAndCreateStats(chatsPath, charactersPath);
152
+ STATS.set(handle, stats);
153
+ await saveStatsToFile();
154
+ }
155
+
156
+ /**
157
+ * Loads the stats file into memory. If the file doesn't exist or is invalid,
158
+ * initializes stats by collecting and creating them for each character.
159
+ */
160
+ async function init() {
161
+ try {
162
+ const userHandles = await getAllUserHandles();
163
+ for (const handle of userHandles) {
164
+ const directories = getUserDirectories(handle);
165
+ try {
166
+ const statsFilePath = path.join(directories.root, STATS_FILE);
167
+ const statsFileContent = await readFile(statsFilePath, 'utf-8');
168
+ STATS.set(handle, JSON.parse(statsFileContent));
169
+ } catch (err) {
170
+ // If the file doesn't exist or is invalid, initialize stats
171
+ if (err.code === 'ENOENT' || err instanceof SyntaxError) {
172
+ await recreateStats(handle, directories.chats, directories.characters);
173
+ } else {
174
+ throw err; // Rethrow the error if it's something we didn't expect
175
+ }
176
+ }
177
+ }
178
+ } catch (err) {
179
+ console.error('Failed to initialize stats:', err);
180
+ }
181
+ // Save stats every 5 minutes
182
+ setInterval(saveStatsToFile, 5 * 60 * 1000);
183
+ }
184
+ /**
185
+ * Saves the current state of charStats to a file, only if the data has changed since the last save.
186
+ */
187
+ async function saveStatsToFile() {
188
+ const userHandles = await getAllUserHandles();
189
+ for (const handle of userHandles) {
190
+ if (!STATS.has(handle)) {
191
+ continue;
192
+ }
193
+ const charStats = STATS.get(handle);
194
+ const lastSaveTimestamp = TIMESTAMPS.get(handle) || 0;
195
+ if (charStats.timestamp > lastSaveTimestamp) {
196
+ try {
197
+ const directories = getUserDirectories(handle);
198
+ const statsFilePath = path.join(directories.root, STATS_FILE);
199
+ await writeFileAtomic(statsFilePath, JSON.stringify(charStats));
200
+ TIMESTAMPS.set(handle, Date.now());
201
+ } catch (error) {
202
+ console.log('Failed to save stats to file.', error);
203
+ }
204
+ }
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Attempts to save charStats to a file and then terminates the process.
210
+ * If an error occurs during the file write, it logs the error before exiting.
211
+ */
212
+ async function onExit() {
213
+ try {
214
+ await saveStatsToFile();
215
+ } catch (err) {
216
+ console.error('Failed to write stats to file:', err);
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Reads the contents of a file and returns the lines in the file as an array.
222
+ *
223
+ * @param {string} filepath - The path of the file to be read.
224
+ * @returns {Array<string>} - The lines in the file.
225
+ * @throws Will throw an error if the file cannot be read.
226
+ */
227
+ function readAndParseFile(filepath) {
228
+ try {
229
+ let file = fs.readFileSync(filepath, 'utf8');
230
+ let lines = file.split('\n');
231
+ return lines;
232
+ } catch (error) {
233
+ console.error(`Error reading file at ${filepath}: ${error}`);
234
+ return [];
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Calculates the time difference between two dates.
240
+ *
241
+ * @param {string} gen_started - The start time in ISO 8601 format.
242
+ * @param {string} gen_finished - The finish time in ISO 8601 format.
243
+ * @returns {number} - The difference in time in milliseconds.
244
+ */
245
+ function calculateGenTime(gen_started, gen_finished) {
246
+ let startDate = new Date(gen_started);
247
+ let endDate = new Date(gen_finished);
248
+ return Number(endDate) - Number(startDate);
249
+ }
250
+
251
+ /**
252
+ * Counts the number of words in a string.
253
+ *
254
+ * @param {string} str - The string to count words in.
255
+ * @returns {number} - The number of words in the string.
256
+ */
257
+ function countWordsInString(str) {
258
+ const match = str.match(/\b\w+\b/g);
259
+ return match ? match.length : 0;
260
+ }
261
+
262
+ /**
263
+ * calculateStats - Calculate statistics for a given character chat directory.
264
+ *
265
+ * @param {string} chatsPath The directory containing the chat files.
266
+ * @param {string} item The name of the character.
267
+ * @return {object} An object containing the calculated statistics.
268
+ */
269
+ const calculateStats = (chatsPath, item) => {
270
+ const chatDir = path.join(chatsPath, item.replace('.png', ''));
271
+ const stats = {
272
+ total_gen_time: 0,
273
+ user_word_count: 0,
274
+ non_user_word_count: 0,
275
+ user_msg_count: 0,
276
+ non_user_msg_count: 0,
277
+ total_swipe_count: 0,
278
+ chat_size: 0,
279
+ date_last_chat: 0,
280
+ date_first_chat: new Date('9999-12-31T23:59:59.999Z').getTime(),
281
+ };
282
+ let uniqueGenStartTimes = new Set();
283
+
284
+ if (fs.existsSync(chatDir)) {
285
+ const chats = fs.readdirSync(chatDir);
286
+ if (Array.isArray(chats) && chats.length) {
287
+ for (const chat of chats) {
288
+ const result = calculateTotalGenTimeAndWordCount(
289
+ chatDir,
290
+ chat,
291
+ uniqueGenStartTimes,
292
+ );
293
+ stats.total_gen_time += result.totalGenTime || 0;
294
+ stats.user_word_count += result.userWordCount || 0;
295
+ stats.non_user_word_count += result.nonUserWordCount || 0;
296
+ stats.user_msg_count += result.userMsgCount || 0;
297
+ stats.non_user_msg_count += result.nonUserMsgCount || 0;
298
+ stats.total_swipe_count += result.totalSwipeCount || 0;
299
+
300
+ const chatStat = fs.statSync(path.join(chatDir, chat));
301
+ stats.chat_size += chatStat.size;
302
+ stats.date_last_chat = Math.max(
303
+ stats.date_last_chat,
304
+ Math.floor(chatStat.mtimeMs),
305
+ );
306
+ stats.date_first_chat = Math.min(
307
+ stats.date_first_chat,
308
+ result.firstChatTime,
309
+ );
310
+ }
311
+ }
312
+ }
313
+
314
+ return { [item]: stats };
315
+ };
316
+
317
+ /**
318
+ * Sets the current charStats object.
319
+ * @param {string} handle - The user handle.
320
+ * @param {Object} stats - The new charStats object.
321
+ **/
322
+ function setCharStats(handle, stats) {
323
+ stats.timestamp = Date.now();
324
+ STATS.set(handle, stats);
325
+ }
326
+
327
+ /**
328
+ * Calculates the total generation time and word count for a chat with a character.
329
+ *
330
+ * @param {string} chatDir - The directory path where character chat files are stored.
331
+ * @param {string} chat - The name of the chat file.
332
+ * @returns {Object} - An object containing the total generation time, user word count, and non-user word count.
333
+ * @throws Will throw an error if the file cannot be read or parsed.
334
+ */
335
+ function calculateTotalGenTimeAndWordCount(
336
+ chatDir,
337
+ chat,
338
+ uniqueGenStartTimes,
339
+ ) {
340
+ let filepath = path.join(chatDir, chat);
341
+ let lines = readAndParseFile(filepath);
342
+
343
+ let totalGenTime = 0;
344
+ let userWordCount = 0;
345
+ let nonUserWordCount = 0;
346
+ let nonUserMsgCount = 0;
347
+ let userMsgCount = 0;
348
+ let totalSwipeCount = 0;
349
+ let firstChatTime = new Date('9999-12-31T23:59:59.999Z').getTime();
350
+
351
+ for (let line of lines) {
352
+ if (line.length) {
353
+ try {
354
+ let json = JSON.parse(line);
355
+ if (json.mes) {
356
+ let hash = crypto
357
+ .createHash('sha256')
358
+ .update(json.mes)
359
+ .digest('hex');
360
+ if (uniqueGenStartTimes.has(hash)) {
361
+ continue;
362
+ }
363
+ if (hash) {
364
+ uniqueGenStartTimes.add(hash);
365
+ }
366
+ }
367
+
368
+ if (json.gen_started && json.gen_finished) {
369
+ let genTime = calculateGenTime(
370
+ json.gen_started,
371
+ json.gen_finished,
372
+ );
373
+ totalGenTime += genTime;
374
+
375
+ if (json.swipes && !json.swipe_info) {
376
+ // If there are swipes but no swipe_info, estimate the genTime
377
+ totalGenTime += genTime * json.swipes.length;
378
+ }
379
+ }
380
+
381
+ if (json.mes) {
382
+ let wordCount = countWordsInString(json.mes);
383
+ json.is_user
384
+ ? (userWordCount += wordCount)
385
+ : (nonUserWordCount += wordCount);
386
+ json.is_user ? userMsgCount++ : nonUserMsgCount++;
387
+ }
388
+
389
+ if (json.swipes && json.swipes.length > 1) {
390
+ totalSwipeCount += json.swipes.length - 1; // Subtract 1 to not count the first swipe
391
+ for (let i = 1; i < json.swipes.length; i++) {
392
+ // Start from the second swipe
393
+ let swipeText = json.swipes[i];
394
+
395
+ let wordCount = countWordsInString(swipeText);
396
+ json.is_user
397
+ ? (userWordCount += wordCount)
398
+ : (nonUserWordCount += wordCount);
399
+ json.is_user ? userMsgCount++ : nonUserMsgCount++;
400
+ }
401
+ }
402
+
403
+ if (json.swipe_info && json.swipe_info.length > 1) {
404
+ for (let i = 1; i < json.swipe_info.length; i++) {
405
+ // Start from the second swipe
406
+ let swipe = json.swipe_info[i];
407
+ if (swipe.gen_started && swipe.gen_finished) {
408
+ totalGenTime += calculateGenTime(
409
+ swipe.gen_started,
410
+ swipe.gen_finished,
411
+ );
412
+ }
413
+ }
414
+ }
415
+
416
+ // If this is the first user message, set the first chat time
417
+ if (json.is_user) {
418
+ //get min between firstChatTime and timestampToMoment(json.send_date)
419
+ firstChatTime = Math.min(timestampToMoment(json.send_date), firstChatTime);
420
+ }
421
+ } catch (error) {
422
+ console.error(`Error parsing line ${line}: ${error}`);
423
+ }
424
+ }
425
+ }
426
+ return {
427
+ totalGenTime,
428
+ userWordCount,
429
+ nonUserWordCount,
430
+ userMsgCount,
431
+ nonUserMsgCount,
432
+ totalSwipeCount,
433
+ firstChatTime,
434
+ };
435
+ }
436
+
437
+ const router = express.Router();
438
+
439
+ /**
440
+ * Handle a POST request to get the stats object
441
+ */
442
+ router.post('/get', jsonParser, function (request, response) {
443
+ const stats = STATS.get(request.user.profile.handle) || {};
444
+ response.send(stats);
445
+ });
446
+
447
+ /**
448
+ * Triggers the recreation of statistics from chat files.
449
+ */
450
+ router.post('/recreate', jsonParser, async function (request, response) {
451
+ try {
452
+ await recreateStats(request.user.profile.handle, request.user.directories.chats, request.user.directories.characters);
453
+ return response.sendStatus(200);
454
+ } catch (error) {
455
+ console.error(error);
456
+ return response.sendStatus(500);
457
+ }
458
+ });
459
+
460
+ /**
461
+ * Handle a POST request to update the stats object
462
+ */
463
+ router.post('/update', jsonParser, function (request, response) {
464
+ if (!request.body) return response.sendStatus(400);
465
+ setCharStats(request.user.profile.handle, request.body);
466
+ return response.sendStatus(200);
467
+ });
468
+
469
+ module.exports = {
470
+ router,
471
+ recreateStats,
472
+ init,
473
+ onExit,
474
+ };
src/endpoints/themes.js ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+ const sanitize = require('sanitize-filename');
5
+ const writeFileAtomicSync = require('write-file-atomic').sync;
6
+ const { jsonParser } = require('../express-common');
7
+
8
+ const router = express.Router();
9
+
10
+ router.post('/save', jsonParser, (request, response) => {
11
+ if (!request.body || !request.body.name) {
12
+ return response.sendStatus(400);
13
+ }
14
+
15
+ const filename = path.join(request.user.directories.themes, sanitize(request.body.name) + '.json');
16
+ writeFileAtomicSync(filename, JSON.stringify(request.body, null, 4), 'utf8');
17
+
18
+ return response.sendStatus(200);
19
+ });
20
+
21
+ router.post('/delete', jsonParser, function (request, response) {
22
+ if (!request.body || !request.body.name) {
23
+ return response.sendStatus(400);
24
+ }
25
+
26
+ try {
27
+ const filename = path.join(request.user.directories.themes, sanitize(request.body.name) + '.json');
28
+ if (!fs.existsSync(filename)) {
29
+ console.error('Theme file not found:', filename);
30
+ return response.sendStatus(404);
31
+ }
32
+ fs.rmSync(filename);
33
+ return response.sendStatus(200);
34
+ } catch (error) {
35
+ console.error(error);
36
+ return response.sendStatus(500);
37
+ }
38
+ });
39
+
40
+ module.exports = { router };
src/endpoints/thumbnails.js ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require('fs');
2
+ const fsPromises = require('fs').promises;
3
+ const path = require('path');
4
+ const mime = require('mime-types');
5
+ const express = require('express');
6
+ const sanitize = require('sanitize-filename');
7
+ const jimp = require('jimp');
8
+ const writeFileAtomicSync = require('write-file-atomic').sync;
9
+ const { getAllUserHandles, getUserDirectories } = require('../users');
10
+ const { getConfigValue } = require('../util');
11
+ const { jsonParser } = require('../express-common');
12
+
13
+ const thumbnailsDisabled = getConfigValue('disableThumbnails', false);
14
+ const quality = getConfigValue('thumbnailsQuality', 95);
15
+ const pngFormat = getConfigValue('avatarThumbnailsPng', false);
16
+
17
+ /**
18
+ * Gets a path to thumbnail folder based on the type.
19
+ * @param {import('../users').UserDirectoryList} directories User directories
20
+ * @param {'bg' | 'avatar'} type Thumbnail type
21
+ * @returns {string} Path to the thumbnails folder
22
+ */
23
+ function getThumbnailFolder(directories, type) {
24
+ let thumbnailFolder;
25
+
26
+ switch (type) {
27
+ case 'bg':
28
+ thumbnailFolder = directories.thumbnailsBg;
29
+ break;
30
+ case 'avatar':
31
+ thumbnailFolder = directories.thumbnailsAvatar;
32
+ break;
33
+ }
34
+
35
+ return thumbnailFolder;
36
+ }
37
+
38
+ /**
39
+ * Gets a path to the original images folder based on the type.
40
+ * @param {import('../users').UserDirectoryList} directories User directories
41
+ * @param {'bg' | 'avatar'} type Thumbnail type
42
+ * @returns {string} Path to the original images folder
43
+ */
44
+ function getOriginalFolder(directories, type) {
45
+ let originalFolder;
46
+
47
+ switch (type) {
48
+ case 'bg':
49
+ originalFolder = directories.backgrounds;
50
+ break;
51
+ case 'avatar':
52
+ originalFolder = directories.characters;
53
+ break;
54
+ }
55
+
56
+ return originalFolder;
57
+ }
58
+
59
+ /**
60
+ * Removes the generated thumbnail from the disk.
61
+ * @param {import('../users').UserDirectoryList} directories User directories
62
+ * @param {'bg' | 'avatar'} type Type of the thumbnail
63
+ * @param {string} file Name of the file
64
+ */
65
+ function invalidateThumbnail(directories, type, file) {
66
+ const folder = getThumbnailFolder(directories, type);
67
+ if (folder === undefined) throw new Error('Invalid thumbnail type');
68
+
69
+ const pathToThumbnail = path.join(folder, file);
70
+
71
+ if (fs.existsSync(pathToThumbnail)) {
72
+ fs.rmSync(pathToThumbnail);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Generates a thumbnail for the given file.
78
+ * @param {import('../users').UserDirectoryList} directories User directories
79
+ * @param {'bg' | 'avatar'} type Type of the thumbnail
80
+ * @param {string} file Name of the file
81
+ * @returns
82
+ */
83
+ async function generateThumbnail(directories, type, file) {
84
+ let thumbnailFolder = getThumbnailFolder(directories, type);
85
+ let originalFolder = getOriginalFolder(directories, type);
86
+ if (thumbnailFolder === undefined || originalFolder === undefined) throw new Error('Invalid thumbnail type');
87
+
88
+ const pathToCachedFile = path.join(thumbnailFolder, file);
89
+ const pathToOriginalFile = path.join(originalFolder, file);
90
+
91
+ const cachedFileExists = fs.existsSync(pathToCachedFile);
92
+ const originalFileExists = fs.existsSync(pathToOriginalFile);
93
+
94
+ // to handle cases when original image was updated after thumb creation
95
+ let shouldRegenerate = false;
96
+
97
+ if (cachedFileExists && originalFileExists) {
98
+ const originalStat = fs.statSync(pathToOriginalFile);
99
+ const cachedStat = fs.statSync(pathToCachedFile);
100
+
101
+ if (originalStat.mtimeMs > cachedStat.ctimeMs) {
102
+ //console.log('Original file changed. Regenerating thumbnail...');
103
+ shouldRegenerate = true;
104
+ }
105
+ }
106
+
107
+ if (cachedFileExists && !shouldRegenerate) {
108
+ return pathToCachedFile;
109
+ }
110
+
111
+ if (!originalFileExists) {
112
+ return null;
113
+ }
114
+
115
+ const imageSizes = { 'bg': [160, 90], 'avatar': [96, 144] };
116
+ const mySize = imageSizes[type];
117
+
118
+ try {
119
+ let buffer;
120
+
121
+ try {
122
+ const image = await jimp.read(pathToOriginalFile);
123
+ const imgType = type == 'avatar' && pngFormat ? 'image/png' : 'image/jpeg';
124
+ buffer = await image.cover(mySize[0], mySize[1]).quality(quality).getBufferAsync(imgType);
125
+ }
126
+ catch (inner) {
127
+ console.warn(`Thumbnailer can not process the image: ${pathToOriginalFile}. Using original size`);
128
+ buffer = fs.readFileSync(pathToOriginalFile);
129
+ }
130
+
131
+ writeFileAtomicSync(pathToCachedFile, buffer);
132
+ }
133
+ catch (outer) {
134
+ return null;
135
+ }
136
+
137
+ return pathToCachedFile;
138
+ }
139
+
140
+ /**
141
+ * Ensures that the thumbnail cache for backgrounds is valid.
142
+ * @returns {Promise<void>} Promise that resolves when the cache is validated
143
+ */
144
+ async function ensureThumbnailCache() {
145
+ const userHandles = await getAllUserHandles();
146
+ for (const handle of userHandles) {
147
+ const directories = getUserDirectories(handle);
148
+ const cacheFiles = fs.readdirSync(directories.thumbnailsBg);
149
+
150
+ // files exist, all ok
151
+ if (cacheFiles.length) {
152
+ return;
153
+ }
154
+
155
+ console.log('Generating thumbnails cache. Please wait...');
156
+
157
+ const bgFiles = fs.readdirSync(directories.backgrounds);
158
+ const tasks = [];
159
+
160
+ for (const file of bgFiles) {
161
+ tasks.push(generateThumbnail(directories, 'bg', file));
162
+ }
163
+
164
+ await Promise.all(tasks);
165
+ console.log(`Done! Generated: ${bgFiles.length} preview images`);
166
+ }
167
+ }
168
+
169
+ const router = express.Router();
170
+
171
+ // Important: This route must be mounted as '/thumbnail'. It is used in the client code and saved to chat files.
172
+ router.get('/', jsonParser, async function (request, response) {
173
+ try{
174
+ if (typeof request.query.file !== 'string' || typeof request.query.type !== 'string') {
175
+ return response.sendStatus(400);
176
+ }
177
+
178
+ const type = request.query.type;
179
+ const file = sanitize(request.query.file);
180
+
181
+ if (!type || !file) {
182
+ return response.sendStatus(400);
183
+ }
184
+
185
+ if (!(type == 'bg' || type == 'avatar')) {
186
+ return response.sendStatus(400);
187
+ }
188
+
189
+ if (sanitize(file) !== file) {
190
+ console.error('Malicious filename prevented');
191
+ return response.sendStatus(403);
192
+ }
193
+
194
+ if (thumbnailsDisabled) {
195
+ const folder = getOriginalFolder(request.user.directories, type);
196
+
197
+ if (folder === undefined) {
198
+ return response.sendStatus(400);
199
+ }
200
+
201
+ const pathToOriginalFile = path.join(folder, file);
202
+ if (!fs.existsSync(pathToOriginalFile)) {
203
+ return response.sendStatus(404);
204
+ }
205
+ const contentType = mime.lookup(pathToOriginalFile) || 'image/png';
206
+ const originalFile = await fsPromises.readFile(pathToOriginalFile);
207
+ response.setHeader('Content-Type', contentType);
208
+ return response.send(originalFile);
209
+ }
210
+
211
+ const pathToCachedFile = await generateThumbnail(request.user.directories, type, file);
212
+
213
+ if (!pathToCachedFile) {
214
+ return response.sendStatus(404);
215
+ }
216
+
217
+ if (!fs.existsSync(pathToCachedFile)) {
218
+ return response.sendStatus(404);
219
+ }
220
+
221
+ const contentType = mime.lookup(pathToCachedFile) || 'image/jpeg';
222
+ const cachedFile = await fsPromises.readFile(pathToCachedFile);
223
+ response.setHeader('Content-Type', contentType);
224
+ return response.send(cachedFile);
225
+ } catch (error) {
226
+ console.error('Failed getting thumbnail', error);
227
+ return response.sendStatus(500);
228
+ }
229
+ });
230
+
231
+ module.exports = {
232
+ invalidateThumbnail,
233
+ ensureThumbnailCache,
234
+ router,
235
+ };
src/endpoints/tokenizers.js ADDED
@@ -0,0 +1,885 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const express = require('express');
4
+ const { SentencePieceProcessor } = require('@agnai/sentencepiece-js');
5
+ const tiktoken = require('tiktoken');
6
+ const { Tokenizer } = require('@agnai/web-tokenizers');
7
+ const { convertClaudePrompt, convertGooglePrompt } = require('../prompt-converters');
8
+ const { readSecret, SECRET_KEYS } = require('./secrets');
9
+ const { TEXTGEN_TYPES } = require('../constants');
10
+ const { jsonParser } = require('../express-common');
11
+ const { setAdditionalHeaders } = require('../additional-headers');
12
+
13
+ const API_MAKERSUITE = 'https://generativelanguage.googleapis.com';
14
+
15
+ /**
16
+ * @typedef { (req: import('express').Request, res: import('express').Response) => Promise<any> } TokenizationHandler
17
+ */
18
+
19
+ /**
20
+ * @type {{[key: string]: import('tiktoken').Tiktoken}} Tokenizers cache
21
+ */
22
+ const tokenizersCache = {};
23
+
24
+ /**
25
+ * @type {string[]}
26
+ */
27
+ const TEXT_COMPLETION_MODELS = [
28
+ 'gpt-3.5-turbo-instruct',
29
+ 'gpt-3.5-turbo-instruct-0914',
30
+ 'text-davinci-003',
31
+ 'text-davinci-002',
32
+ 'text-davinci-001',
33
+ 'text-curie-001',
34
+ 'text-babbage-001',
35
+ 'text-ada-001',
36
+ 'code-davinci-002',
37
+ 'code-davinci-001',
38
+ 'code-cushman-002',
39
+ 'code-cushman-001',
40
+ 'text-davinci-edit-001',
41
+ 'code-davinci-edit-001',
42
+ 'text-embedding-ada-002',
43
+ 'text-similarity-davinci-001',
44
+ 'text-similarity-curie-001',
45
+ 'text-similarity-babbage-001',
46
+ 'text-similarity-ada-001',
47
+ 'text-search-davinci-doc-001',
48
+ 'text-search-curie-doc-001',
49
+ 'text-search-babbage-doc-001',
50
+ 'text-search-ada-doc-001',
51
+ 'code-search-babbage-code-001',
52
+ 'code-search-ada-code-001',
53
+ ];
54
+
55
+ const CHARS_PER_TOKEN = 3.35;
56
+
57
+ /**
58
+ * Sentencepiece tokenizer for tokenizing text.
59
+ */
60
+ class SentencePieceTokenizer {
61
+ /**
62
+ * @type {import('@agnai/sentencepiece-js').SentencePieceProcessor} Sentencepiece tokenizer instance
63
+ */
64
+ #instance;
65
+ /**
66
+ * @type {string} Path to the tokenizer model
67
+ */
68
+ #model;
69
+
70
+ /**
71
+ * Creates a new Sentencepiece tokenizer.
72
+ * @param {string} model Path to the tokenizer model
73
+ */
74
+ constructor(model) {
75
+ this.#model = model;
76
+ }
77
+
78
+ /**
79
+ * Gets the Sentencepiece tokenizer instance.
80
+ * @returns {Promise<import('@agnai/sentencepiece-js').SentencePieceProcessor|null>} Sentencepiece tokenizer instance
81
+ */
82
+ async get() {
83
+ if (this.#instance) {
84
+ return this.#instance;
85
+ }
86
+
87
+ try {
88
+ this.#instance = new SentencePieceProcessor();
89
+ await this.#instance.load(this.#model);
90
+ console.log('Instantiated the tokenizer for', path.parse(this.#model).name);
91
+ return this.#instance;
92
+ } catch (error) {
93
+ console.error('Sentencepiece tokenizer failed to load: ' + this.#model, error);
94
+ return null;
95
+ }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Web tokenizer for tokenizing text.
101
+ */
102
+ class WebTokenizer {
103
+ /**
104
+ * @type {Tokenizer} Web tokenizer instance
105
+ */
106
+ #instance;
107
+ /**
108
+ * @type {string} Path to the tokenizer model
109
+ */
110
+ #model;
111
+
112
+ /**
113
+ * Creates a new Web tokenizer.
114
+ * @param {string} model Path to the tokenizer model
115
+ */
116
+ constructor(model) {
117
+ this.#model = model;
118
+ }
119
+
120
+ /**
121
+ * Gets the Web tokenizer instance.
122
+ * @returns {Promise<Tokenizer|null>} Web tokenizer instance
123
+ */
124
+ async get() {
125
+ if (this.#instance) {
126
+ return this.#instance;
127
+ }
128
+
129
+ try {
130
+ const arrayBuffer = fs.readFileSync(this.#model).buffer;
131
+ this.#instance = await Tokenizer.fromJSON(arrayBuffer);
132
+ console.log('Instantiated the tokenizer for', path.parse(this.#model).name);
133
+ return this.#instance;
134
+ } catch (error) {
135
+ console.error('Web tokenizer failed to load: ' + this.#model, error);
136
+ return null;
137
+ }
138
+ }
139
+ }
140
+
141
+ const spp_llama = new SentencePieceTokenizer('src/tokenizers/llama.model');
142
+ const spp_nerd = new SentencePieceTokenizer('src/tokenizers/nerdstash.model');
143
+ const spp_nerd_v2 = new SentencePieceTokenizer('src/tokenizers/nerdstash_v2.model');
144
+ const spp_mistral = new SentencePieceTokenizer('src/tokenizers/mistral.model');
145
+ const spp_yi = new SentencePieceTokenizer('src/tokenizers/yi.model');
146
+ const spp_gemma = new SentencePieceTokenizer('src/tokenizers/gemma.model');
147
+ const claude_tokenizer = new WebTokenizer('src/tokenizers/claude.json');
148
+ const llama3_tokenizer = new WebTokenizer('src/tokenizers/llama3.json');
149
+
150
+ const sentencepieceTokenizers = [
151
+ 'llama',
152
+ 'nerdstash',
153
+ 'nerdstash_v2',
154
+ 'mistral',
155
+ 'yi',
156
+ 'gemma',
157
+ ];
158
+
159
+ /**
160
+ * Gets the Sentencepiece tokenizer by the model name.
161
+ * @param {string} model Sentencepiece model name
162
+ * @returns {SentencePieceTokenizer|null} Sentencepiece tokenizer
163
+ */
164
+ function getSentencepiceTokenizer(model) {
165
+ if (model.includes('llama')) {
166
+ return spp_llama;
167
+ }
168
+
169
+ if (model.includes('nerdstash')) {
170
+ return spp_nerd;
171
+ }
172
+
173
+ if (model.includes('mistral')) {
174
+ return spp_mistral;
175
+ }
176
+
177
+ if (model.includes('nerdstash_v2')) {
178
+ return spp_nerd_v2;
179
+ }
180
+
181
+ if (model.includes('yi')) {
182
+ return spp_yi;
183
+ }
184
+
185
+ if (model.includes('gemma')) {
186
+ return spp_gemma;
187
+ }
188
+
189
+ return null;
190
+ }
191
+
192
+ /**
193
+ * Counts the token ids for the given text using the Sentencepiece tokenizer.
194
+ * @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer
195
+ * @param {string} text Text to tokenize
196
+ * @returns { Promise<{ids: number[], count: number}> } Tokenization result
197
+ */
198
+ async function countSentencepieceTokens(tokenizer, text) {
199
+ const instance = await tokenizer?.get();
200
+
201
+ // Fallback to strlen estimation
202
+ if (!instance) {
203
+ return {
204
+ ids: [],
205
+ count: Math.ceil(text.length / CHARS_PER_TOKEN),
206
+ };
207
+ }
208
+
209
+ let cleaned = text; // cleanText(text); <-- cleaning text can result in an incorrect tokenization
210
+
211
+ let ids = instance.encodeIds(cleaned);
212
+ return {
213
+ ids,
214
+ count: ids.length,
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Counts the tokens in the given array of objects using the Sentencepiece tokenizer.
220
+ * @param {SentencePieceTokenizer} tokenizer
221
+ * @param {object[]} array Array of objects to tokenize
222
+ * @returns {Promise<number>} Number of tokens
223
+ */
224
+ async function countSentencepieceArrayTokens(tokenizer, array) {
225
+ const jsonBody = array.flatMap(x => Object.values(x)).join('\n\n');
226
+ const result = await countSentencepieceTokens(tokenizer, jsonBody);
227
+ const num_tokens = result.count;
228
+ return num_tokens;
229
+ }
230
+
231
+ async function getTiktokenChunks(tokenizer, ids) {
232
+ const decoder = new TextDecoder();
233
+ const chunks = [];
234
+
235
+ for (let i = 0; i < ids.length; i++) {
236
+ const id = ids[i];
237
+ const chunkTextBytes = await tokenizer.decode(new Uint32Array([id]));
238
+ const chunkText = decoder.decode(chunkTextBytes);
239
+ chunks.push(chunkText);
240
+ }
241
+
242
+ return chunks;
243
+ }
244
+
245
+ /**
246
+ * Gets the token chunks for the given token IDs using the Web tokenizer.
247
+ * @param {Tokenizer} tokenizer Web tokenizer instance
248
+ * @param {number[]} ids Token IDs
249
+ * @returns {string[]} Token chunks
250
+ */
251
+ function getWebTokenizersChunks(tokenizer, ids) {
252
+ const chunks = [];
253
+
254
+ for (let i = 0, lastProcessed = 0; i < ids.length; i++) {
255
+ const chunkIds = ids.slice(lastProcessed, i + 1);
256
+ const chunkText = tokenizer.decode(new Int32Array(chunkIds));
257
+ if (chunkText === '�') {
258
+ continue;
259
+ }
260
+ chunks.push(chunkText);
261
+ lastProcessed = i + 1;
262
+ }
263
+
264
+ return chunks;
265
+ }
266
+
267
+ /**
268
+ * Gets the tokenizer model by the model name.
269
+ * @param {string} requestModel Models to use for tokenization
270
+ * @returns {string} Tokenizer model to use
271
+ */
272
+ function getTokenizerModel(requestModel) {
273
+ if (requestModel.includes('gpt-4o')) {
274
+ return 'gpt-4o';
275
+ }
276
+
277
+ if (requestModel.includes('chatgpt-4o-latest')) {
278
+ return 'gpt-4o';
279
+ }
280
+
281
+ if (requestModel.includes('gpt-4-32k')) {
282
+ return 'gpt-4-32k';
283
+ }
284
+
285
+ if (requestModel.includes('gpt-4')) {
286
+ return 'gpt-4';
287
+ }
288
+
289
+ if (requestModel.includes('gpt-3.5-turbo-0301')) {
290
+ return 'gpt-3.5-turbo-0301';
291
+ }
292
+
293
+ if (requestModel.includes('gpt-3.5-turbo')) {
294
+ return 'gpt-3.5-turbo';
295
+ }
296
+
297
+ if (TEXT_COMPLETION_MODELS.includes(requestModel)) {
298
+ return requestModel;
299
+ }
300
+
301
+ if (requestModel.includes('claude')) {
302
+ return 'claude';
303
+ }
304
+
305
+ if (requestModel.includes('llama3') || requestModel.includes('llama-3')) {
306
+ return 'llama3';
307
+ }
308
+
309
+ if (requestModel.includes('llama')) {
310
+ return 'llama';
311
+ }
312
+
313
+ if (requestModel.includes('mistral')) {
314
+ return 'mistral';
315
+ }
316
+
317
+ if (requestModel.includes('yi')) {
318
+ return 'yi';
319
+ }
320
+
321
+ if (requestModel.includes('gemma') || requestModel.includes('gemini')) {
322
+ return 'gemma';
323
+ }
324
+
325
+ // default
326
+ return 'gpt-3.5-turbo';
327
+ }
328
+
329
+ function getTiktokenTokenizer(model) {
330
+ if (tokenizersCache[model]) {
331
+ return tokenizersCache[model];
332
+ }
333
+
334
+ const tokenizer = tiktoken.encoding_for_model(model);
335
+ console.log('Instantiated the tokenizer for', model);
336
+ tokenizersCache[model] = tokenizer;
337
+ return tokenizer;
338
+ }
339
+
340
+ /**
341
+ * Counts the tokens for the given messages using the WebTokenizer and Claude prompt conversion.
342
+ * @param {Tokenizer} tokenizer Web tokenizer
343
+ * @param {object[]} messages Array of messages
344
+ * @returns {number} Number of tokens
345
+ */
346
+ function countWebTokenizerTokens(tokenizer, messages) {
347
+ // Should be fine if we use the old conversion method instead of the messages API one i think?
348
+ const convertedPrompt = convertClaudePrompt(messages, false, '', false, false, '', false);
349
+
350
+ // Fallback to strlen estimation
351
+ if (!tokenizer) {
352
+ return Math.ceil(convertedPrompt.length / CHARS_PER_TOKEN);
353
+ }
354
+
355
+ const count = tokenizer.encode(convertedPrompt).length;
356
+ return count;
357
+ }
358
+
359
+ /**
360
+ * Creates an API handler for encoding Sentencepiece tokens.
361
+ * @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer
362
+ * @returns {TokenizationHandler} Handler function
363
+ */
364
+ function createSentencepieceEncodingHandler(tokenizer) {
365
+ /**
366
+ * Request handler for encoding Sentencepiece tokens.
367
+ * @param {import('express').Request} request
368
+ * @param {import('express').Response} response
369
+ */
370
+ return async function (request, response) {
371
+ try {
372
+ if (!request.body) {
373
+ return response.sendStatus(400);
374
+ }
375
+
376
+ const text = request.body.text || '';
377
+ const instance = await tokenizer?.get();
378
+ const { ids, count } = await countSentencepieceTokens(tokenizer, text);
379
+ const chunks = instance?.encodePieces(text);
380
+ return response.send({ ids, count, chunks });
381
+ } catch (error) {
382
+ console.log(error);
383
+ return response.send({ ids: [], count: 0, chunks: [] });
384
+ }
385
+ };
386
+ }
387
+
388
+ /**
389
+ * Creates an API handler for decoding Sentencepiece tokens.
390
+ * @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer
391
+ * @returns {TokenizationHandler} Handler function
392
+ */
393
+ function createSentencepieceDecodingHandler(tokenizer) {
394
+ /**
395
+ * Request handler for decoding Sentencepiece tokens.
396
+ * @param {import('express').Request} request
397
+ * @param {import('express').Response} response
398
+ */
399
+ return async function (request, response) {
400
+ try {
401
+ if (!request.body) {
402
+ return response.sendStatus(400);
403
+ }
404
+
405
+ const ids = request.body.ids || [];
406
+ const instance = await tokenizer?.get();
407
+ if (!instance) throw new Error('Failed to load the Sentencepiece tokenizer');
408
+ const ops = ids.map(id => instance.decodeIds([id]));
409
+ const chunks = await Promise.all(ops);
410
+ const text = chunks.join('');
411
+ return response.send({ text, chunks });
412
+ } catch (error) {
413
+ console.log(error);
414
+ return response.send({ text: '', chunks: [] });
415
+ }
416
+ };
417
+ }
418
+
419
+ /**
420
+ * Creates an API handler for encoding Tiktoken tokens.
421
+ * @param {string} modelId Tiktoken model ID
422
+ * @returns {TokenizationHandler} Handler function
423
+ */
424
+ function createTiktokenEncodingHandler(modelId) {
425
+ /**
426
+ * Request handler for encoding Tiktoken tokens.
427
+ * @param {import('express').Request} request
428
+ * @param {import('express').Response} response
429
+ */
430
+ return async function (request, response) {
431
+ try {
432
+ if (!request.body) {
433
+ return response.sendStatus(400);
434
+ }
435
+
436
+ const text = request.body.text || '';
437
+ const tokenizer = getTiktokenTokenizer(modelId);
438
+ const tokens = Object.values(tokenizer.encode(text));
439
+ const chunks = await getTiktokenChunks(tokenizer, tokens);
440
+ return response.send({ ids: tokens, count: tokens.length, chunks });
441
+ } catch (error) {
442
+ console.log(error);
443
+ return response.send({ ids: [], count: 0, chunks: [] });
444
+ }
445
+ };
446
+ }
447
+
448
+ /**
449
+ * Creates an API handler for decoding Tiktoken tokens.
450
+ * @param {string} modelId Tiktoken model ID
451
+ * @returns {TokenizationHandler} Handler function
452
+ */
453
+ function createTiktokenDecodingHandler(modelId) {
454
+ /**
455
+ * Request handler for decoding Tiktoken tokens.
456
+ * @param {import('express').Request} request
457
+ * @param {import('express').Response} response
458
+ */
459
+ return async function (request, response) {
460
+ try {
461
+ if (!request.body) {
462
+ return response.sendStatus(400);
463
+ }
464
+
465
+ const ids = request.body.ids || [];
466
+ const tokenizer = getTiktokenTokenizer(modelId);
467
+ const textBytes = tokenizer.decode(new Uint32Array(ids));
468
+ const text = new TextDecoder().decode(textBytes);
469
+ return response.send({ text });
470
+ } catch (error) {
471
+ console.log(error);
472
+ return response.send({ text: '' });
473
+ }
474
+ };
475
+ }
476
+
477
+ /**
478
+ * Creates an API handler for encoding WebTokenizer tokens.
479
+ * @param {WebTokenizer} tokenizer WebTokenizer instance
480
+ * @returns {TokenizationHandler} Handler function
481
+ */
482
+ function createWebTokenizerEncodingHandler(tokenizer) {
483
+ /**
484
+ * Request handler for encoding WebTokenizer tokens.
485
+ * @param {import('express').Request} request
486
+ * @param {import('express').Response} response
487
+ */
488
+ return async function (request, response) {
489
+ try {
490
+ if (!request.body) {
491
+ return response.sendStatus(400);
492
+ }
493
+
494
+ const text = request.body.text || '';
495
+ const instance = await tokenizer?.get();
496
+ if (!instance) throw new Error('Failed to load the Web tokenizer');
497
+ const tokens = Array.from(instance.encode(text));
498
+ const chunks = getWebTokenizersChunks(instance, tokens);
499
+ return response.send({ ids: tokens, count: tokens.length, chunks });
500
+ } catch (error) {
501
+ console.log(error);
502
+ return response.send({ ids: [], count: 0, chunks: [] });
503
+ }
504
+ };
505
+ }
506
+
507
+ /**
508
+ * Creates an API handler for decoding WebTokenizer tokens.
509
+ * @param {WebTokenizer} tokenizer WebTokenizer instance
510
+ * @returns {TokenizationHandler} Handler function
511
+ */
512
+ function createWebTokenizerDecodingHandler(tokenizer) {
513
+ /**
514
+ * Request handler for decoding WebTokenizer tokens.
515
+ * @param {import('express').Request} request
516
+ * @param {import('express').Response} response
517
+ * @returns {Promise<any>}
518
+ */
519
+ return async function (request, response) {
520
+ try {
521
+ if (!request.body) {
522
+ return response.sendStatus(400);
523
+ }
524
+
525
+ const ids = request.body.ids || [];
526
+ const instance = await tokenizer?.get();
527
+ if (!instance) throw new Error('Failed to load the Web tokenizer');
528
+ const chunks = getWebTokenizersChunks(instance, ids);
529
+ const text = instance.decode(new Int32Array(ids));
530
+ return response.send({ text, chunks });
531
+ } catch (error) {
532
+ console.log(error);
533
+ return response.send({ text: '', chunks: [] });
534
+ }
535
+ };
536
+ }
537
+
538
+ const router = express.Router();
539
+
540
+ router.post('/ai21/count', jsonParser, async function (req, res) {
541
+ if (!req.body) return res.sendStatus(400);
542
+ const key = readSecret(req.user.directories, SECRET_KEYS.AI21);
543
+ const options = {
544
+ method: 'POST',
545
+ headers: {
546
+ accept: 'application/json',
547
+ 'content-type': 'application/json',
548
+ Authorization: `Bearer ${key}`,
549
+ },
550
+ body: JSON.stringify({ text: req.body[0].content }),
551
+ };
552
+
553
+ try {
554
+ const response = await fetch('https://api.ai21.com/studio/v1/tokenize', options);
555
+ const data = await response.json();
556
+ return res.send({ 'token_count': data?.tokens?.length || 0 });
557
+ } catch (err) {
558
+ console.error(err);
559
+ return res.send({ 'token_count': 0 });
560
+ }
561
+ });
562
+
563
+ router.post('/google/count', jsonParser, async function (req, res) {
564
+ if (!req.body) return res.sendStatus(400);
565
+ const options = {
566
+ method: 'POST',
567
+ headers: {
568
+ accept: 'application/json',
569
+ 'content-type': 'application/json',
570
+ },
571
+ body: JSON.stringify({ contents: convertGooglePrompt(req.body, String(req.query.model)).contents }),
572
+ };
573
+ try {
574
+ const reverseProxy = req.query.reverse_proxy?.toString() || '';
575
+ const proxyPassword = req.query.proxy_password?.toString() || '';
576
+ const apiKey = reverseProxy ? proxyPassword : readSecret(req.user.directories, SECRET_KEYS.MAKERSUITE);
577
+ const apiUrl = new URL(reverseProxy || API_MAKERSUITE);
578
+ const response = await fetch(`${apiUrl.origin}/v1beta/models/${req.query.model}:countTokens?key=${apiKey}`, options);
579
+ const data = await response.json();
580
+ return res.send({ 'token_count': data?.totalTokens || 0 });
581
+ } catch (err) {
582
+ console.error(err);
583
+ return res.send({ 'token_count': 0 });
584
+ }
585
+ });
586
+
587
+ router.post('/llama/encode', jsonParser, createSentencepieceEncodingHandler(spp_llama));
588
+ router.post('/nerdstash/encode', jsonParser, createSentencepieceEncodingHandler(spp_nerd));
589
+ router.post('/nerdstash_v2/encode', jsonParser, createSentencepieceEncodingHandler(spp_nerd_v2));
590
+ router.post('/mistral/encode', jsonParser, createSentencepieceEncodingHandler(spp_mistral));
591
+ router.post('/yi/encode', jsonParser, createSentencepieceEncodingHandler(spp_yi));
592
+ router.post('/gemma/encode', jsonParser, createSentencepieceEncodingHandler(spp_gemma));
593
+ router.post('/gpt2/encode', jsonParser, createTiktokenEncodingHandler('gpt2'));
594
+ router.post('/claude/encode', jsonParser, createWebTokenizerEncodingHandler(claude_tokenizer));
595
+ router.post('/llama3/encode', jsonParser, createWebTokenizerEncodingHandler(llama3_tokenizer));
596
+ router.post('/llama/decode', jsonParser, createSentencepieceDecodingHandler(spp_llama));
597
+ router.post('/nerdstash/decode', jsonParser, createSentencepieceDecodingHandler(spp_nerd));
598
+ router.post('/nerdstash_v2/decode', jsonParser, createSentencepieceDecodingHandler(spp_nerd_v2));
599
+ router.post('/mistral/decode', jsonParser, createSentencepieceDecodingHandler(spp_mistral));
600
+ router.post('/yi/decode', jsonParser, createSentencepieceDecodingHandler(spp_yi));
601
+ router.post('/gemma/decode', jsonParser, createSentencepieceDecodingHandler(spp_gemma));
602
+ router.post('/gpt2/decode', jsonParser, createTiktokenDecodingHandler('gpt2'));
603
+ router.post('/claude/decode', jsonParser, createWebTokenizerDecodingHandler(claude_tokenizer));
604
+ router.post('/llama3/decode', jsonParser, createWebTokenizerDecodingHandler(llama3_tokenizer));
605
+
606
+ router.post('/openai/encode', jsonParser, async function (req, res) {
607
+ try {
608
+ const queryModel = String(req.query.model || '');
609
+
610
+ if (queryModel.includes('llama3') || queryModel.includes('llama-3')) {
611
+ const handler = createWebTokenizerEncodingHandler(llama3_tokenizer);
612
+ return handler(req, res);
613
+ }
614
+
615
+ if (queryModel.includes('llama')) {
616
+ const handler = createSentencepieceEncodingHandler(spp_llama);
617
+ return handler(req, res);
618
+ }
619
+
620
+ if (queryModel.includes('mistral')) {
621
+ const handler = createSentencepieceEncodingHandler(spp_mistral);
622
+ return handler(req, res);
623
+ }
624
+
625
+ if (queryModel.includes('yi')) {
626
+ const handler = createSentencepieceEncodingHandler(spp_yi);
627
+ return handler(req, res);
628
+ }
629
+
630
+ if (queryModel.includes('claude')) {
631
+ const handler = createWebTokenizerEncodingHandler(claude_tokenizer);
632
+ return handler(req, res);
633
+ }
634
+
635
+ if (queryModel.includes('gemma') || queryModel.includes('gemini')) {
636
+ const handler = createSentencepieceEncodingHandler(spp_gemma);
637
+ return handler(req, res);
638
+ }
639
+
640
+ const model = getTokenizerModel(queryModel);
641
+ const handler = createTiktokenEncodingHandler(model);
642
+ return handler(req, res);
643
+ } catch (error) {
644
+ console.log(error);
645
+ return res.send({ ids: [], count: 0, chunks: [] });
646
+ }
647
+ });
648
+
649
+ router.post('/openai/decode', jsonParser, async function (req, res) {
650
+ try {
651
+ const queryModel = String(req.query.model || '');
652
+
653
+ if (queryModel.includes('llama3') || queryModel.includes('llama-3')) {
654
+ const handler = createWebTokenizerDecodingHandler(llama3_tokenizer);
655
+ return handler(req, res);
656
+ }
657
+
658
+ if (queryModel.includes('llama')) {
659
+ const handler = createSentencepieceDecodingHandler(spp_llama);
660
+ return handler(req, res);
661
+ }
662
+
663
+ if (queryModel.includes('mistral')) {
664
+ const handler = createSentencepieceDecodingHandler(spp_mistral);
665
+ return handler(req, res);
666
+ }
667
+
668
+ if (queryModel.includes('yi')) {
669
+ const handler = createSentencepieceDecodingHandler(spp_yi);
670
+ return handler(req, res);
671
+ }
672
+
673
+ if (queryModel.includes('claude')) {
674
+ const handler = createWebTokenizerDecodingHandler(claude_tokenizer);
675
+ return handler(req, res);
676
+ }
677
+
678
+ if (queryModel.includes('gemma') || queryModel.includes('gemini')) {
679
+ const handler = createSentencepieceDecodingHandler(spp_gemma);
680
+ return handler(req, res);
681
+ }
682
+
683
+ const model = getTokenizerModel(queryModel);
684
+ const handler = createTiktokenDecodingHandler(model);
685
+ return handler(req, res);
686
+ } catch (error) {
687
+ console.log(error);
688
+ return res.send({ text: '' });
689
+ }
690
+ });
691
+
692
+ router.post('/openai/count', jsonParser, async function (req, res) {
693
+ try {
694
+ if (!req.body) return res.sendStatus(400);
695
+
696
+ let num_tokens = 0;
697
+ const queryModel = String(req.query.model || '');
698
+ const model = getTokenizerModel(queryModel);
699
+
700
+ if (model === 'claude') {
701
+ const instance = await claude_tokenizer.get();
702
+ if (!instance) throw new Error('Failed to load the Claude tokenizer');
703
+ num_tokens = countWebTokenizerTokens(instance, req.body);
704
+ return res.send({ 'token_count': num_tokens });
705
+ }
706
+
707
+ if (model === 'llama3' || model === 'llama-3') {
708
+ const instance = await llama3_tokenizer.get();
709
+ if (!instance) throw new Error('Failed to load the Llama3 tokenizer');
710
+ num_tokens = countWebTokenizerTokens(instance, req.body);
711
+ return res.send({ 'token_count': num_tokens });
712
+ }
713
+
714
+ if (model === 'llama') {
715
+ num_tokens = await countSentencepieceArrayTokens(spp_llama, req.body);
716
+ return res.send({ 'token_count': num_tokens });
717
+ }
718
+
719
+ if (model === 'mistral') {
720
+ num_tokens = await countSentencepieceArrayTokens(spp_mistral, req.body);
721
+ return res.send({ 'token_count': num_tokens });
722
+ }
723
+
724
+ if (model === 'yi') {
725
+ num_tokens = await countSentencepieceArrayTokens(spp_yi, req.body);
726
+ return res.send({ 'token_count': num_tokens });
727
+ }
728
+
729
+ if (model === 'gemma' || model === 'gemini') {
730
+ num_tokens = await countSentencepieceArrayTokens(spp_gemma, req.body);
731
+ return res.send({ 'token_count': num_tokens });
732
+ }
733
+
734
+ const tokensPerName = queryModel.includes('gpt-3.5-turbo-0301') ? -1 : 1;
735
+ const tokensPerMessage = queryModel.includes('gpt-3.5-turbo-0301') ? 4 : 3;
736
+ const tokensPadding = 3;
737
+
738
+ const tokenizer = getTiktokenTokenizer(model);
739
+
740
+ for (const msg of req.body) {
741
+ try {
742
+ num_tokens += tokensPerMessage;
743
+ for (const [key, value] of Object.entries(msg)) {
744
+ num_tokens += tokenizer.encode(value).length;
745
+ if (key == 'name') {
746
+ num_tokens += tokensPerName;
747
+ }
748
+ }
749
+ } catch {
750
+ console.warn('Error tokenizing message:', msg);
751
+ }
752
+ }
753
+ num_tokens += tokensPadding;
754
+
755
+ // NB: Since 2023-10-14, the GPT-3.5 Turbo 0301 model shoves in 7-9 extra tokens to every message.
756
+ // More details: https://community.openai.com/t/gpt-3-5-turbo-0301-showing-different-behavior-suddenly/431326/14
757
+ if (queryModel.includes('gpt-3.5-turbo-0301')) {
758
+ num_tokens += 9;
759
+ }
760
+
761
+ // not needed for cached tokenizers
762
+ //tokenizer.free();
763
+
764
+ res.send({ 'token_count': num_tokens });
765
+ } catch (error) {
766
+ console.error('An error counting tokens, using fallback estimation method', error);
767
+ const jsonBody = JSON.stringify(req.body);
768
+ const num_tokens = Math.ceil(jsonBody.length / CHARS_PER_TOKEN);
769
+ res.send({ 'token_count': num_tokens });
770
+ }
771
+ });
772
+
773
+ router.post('/remote/kobold/count', jsonParser, async function (request, response) {
774
+ if (!request.body) {
775
+ return response.sendStatus(400);
776
+ }
777
+ const text = String(request.body.text) || '';
778
+ const baseUrl = String(request.body.url);
779
+
780
+ try {
781
+ const args = {
782
+ method: 'POST',
783
+ body: JSON.stringify({ 'prompt': text }),
784
+ headers: { 'Content-Type': 'application/json' },
785
+ };
786
+
787
+ let url = String(baseUrl).replace(/\/$/, '');
788
+ url += '/extra/tokencount';
789
+
790
+ const result = await fetch(url, args);
791
+
792
+ if (!result.ok) {
793
+ console.log(`API returned error: ${result.status} ${result.statusText}`);
794
+ return response.send({ error: true });
795
+ }
796
+
797
+ const data = await result.json();
798
+ const count = data['value'];
799
+ const ids = data['ids'] ?? [];
800
+ return response.send({ count, ids });
801
+ } catch (error) {
802
+ console.log(error);
803
+ return response.send({ error: true });
804
+ }
805
+ });
806
+
807
+ router.post('/remote/textgenerationwebui/encode', jsonParser, async function (request, response) {
808
+ if (!request.body) {
809
+ return response.sendStatus(400);
810
+ }
811
+ const text = String(request.body.text) || '';
812
+ const baseUrl = String(request.body.url);
813
+ const legacyApi = Boolean(request.body.legacy_api);
814
+ const vllmModel = String(request.body.vllm_model) || '';
815
+
816
+ try {
817
+ const args = {
818
+ method: 'POST',
819
+ headers: { 'Content-Type': 'application/json' },
820
+ };
821
+
822
+ setAdditionalHeaders(request, args, baseUrl);
823
+
824
+ // Convert to string + remove trailing slash + /v1 suffix
825
+ let url = String(baseUrl).replace(/\/$/, '').replace(/\/v1$/, '');
826
+
827
+ if (legacyApi) {
828
+ url += '/v1/token-count';
829
+ args.body = JSON.stringify({ 'prompt': text });
830
+ } else {
831
+ switch (request.body.api_type) {
832
+ case TEXTGEN_TYPES.TABBY:
833
+ url += '/v1/token/encode';
834
+ args.body = JSON.stringify({ 'text': text });
835
+ break;
836
+ case TEXTGEN_TYPES.KOBOLDCPP:
837
+ url += '/api/extra/tokencount';
838
+ args.body = JSON.stringify({ 'prompt': text });
839
+ break;
840
+ case TEXTGEN_TYPES.LLAMACPP:
841
+ url += '/tokenize';
842
+ args.body = JSON.stringify({ 'content': text });
843
+ break;
844
+ case TEXTGEN_TYPES.VLLM:
845
+ url += '/tokenize';
846
+ args.body = JSON.stringify({ 'model': vllmModel, 'prompt': text });
847
+ break;
848
+ case TEXTGEN_TYPES.APHRODITE:
849
+ url += '/v1/tokenize';
850
+ args.body = JSON.stringify({ 'prompt': text });
851
+ break;
852
+ default:
853
+ url += '/v1/internal/encode';
854
+ args.body = JSON.stringify({ 'text': text });
855
+ break;
856
+ }
857
+ }
858
+
859
+ const result = await fetch(url, args);
860
+
861
+ if (!result.ok) {
862
+ console.log(`API returned error: ${result.status} ${result.statusText}`);
863
+ return response.send({ error: true });
864
+ }
865
+
866
+ const data = await result.json();
867
+ const count = legacyApi ? data?.results[0]?.tokens : (data?.length ?? data?.count ?? data?.value ?? data?.tokens?.length);
868
+ const ids = legacyApi ? [] : (data?.tokens ?? data?.ids ?? []);
869
+
870
+ return response.send({ count, ids });
871
+ } catch (error) {
872
+ console.log(error);
873
+ return response.send({ error: true });
874
+ }
875
+ });
876
+
877
+ module.exports = {
878
+ TEXT_COMPLETION_MODELS,
879
+ getTokenizerModel,
880
+ getTiktokenTokenizer,
881
+ countWebTokenizerTokens,
882
+ getSentencepiceTokenizer,
883
+ sentencepieceTokenizers,
884
+ router,
885
+ };
src/endpoints/translate.js ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fetch = require('node-fetch').default;
2
+ const https = require('https');
3
+ const express = require('express');
4
+ const iconv = require('iconv-lite');
5
+ const { readSecret, SECRET_KEYS } = require('./secrets');
6
+ const { getConfigValue, uuidv4 } = require('../util');
7
+ const { jsonParser } = require('../express-common');
8
+
9
+ const DEEPLX_URL_DEFAULT = 'http://127.0.0.1:1188/translate';
10
+ const ONERING_URL_DEFAULT = 'http://127.0.0.1:4990/translate';
11
+
12
+ const router = express.Router();
13
+
14
+ router.post('/libre', jsonParser, async (request, response) => {
15
+ const key = readSecret(request.user.directories, SECRET_KEYS.LIBRE);
16
+ const url = readSecret(request.user.directories, SECRET_KEYS.LIBRE_URL);
17
+
18
+ if (!url) {
19
+ console.log('LibreTranslate URL is not configured.');
20
+ return response.sendStatus(400);
21
+ }
22
+
23
+ if (request.body.lang === 'zh-CN') {
24
+ request.body.lang = 'zh';
25
+ }
26
+
27
+ if (request.body.lang === 'zh-TW') {
28
+ request.body.lang = 'zt';
29
+ }
30
+
31
+ const text = request.body.text;
32
+ const lang = request.body.lang;
33
+
34
+ if (!text || !lang) {
35
+ return response.sendStatus(400);
36
+ }
37
+
38
+ console.log('Input text: ' + text);
39
+
40
+ try {
41
+ const result = await fetch(url, {
42
+ method: 'POST',
43
+ body: JSON.stringify({
44
+ q: text,
45
+ source: 'auto',
46
+ target: lang,
47
+ format: 'text',
48
+ api_key: key,
49
+ }),
50
+ headers: { 'Content-Type': 'application/json' },
51
+ });
52
+
53
+ if (!result.ok) {
54
+ const error = await result.text();
55
+ console.log('LibreTranslate error: ', result.statusText, error);
56
+ return response.sendStatus(result.status);
57
+ }
58
+
59
+ const json = await result.json();
60
+ console.log('Translated text: ' + json.translatedText);
61
+
62
+ return response.send(json.translatedText);
63
+ } catch (error) {
64
+ console.log('Translation error: ' + error.message);
65
+ return response.sendStatus(500);
66
+ }
67
+ });
68
+
69
+ router.post('/google', jsonParser, async (request, response) => {
70
+ try {
71
+ const { generateRequestUrl, normaliseResponse } = require('google-translate-api-browser');
72
+ const text = request.body.text;
73
+ const lang = request.body.lang;
74
+
75
+ if (!text || !lang) {
76
+ return response.sendStatus(400);
77
+ }
78
+
79
+ console.log('Input text: ' + text);
80
+
81
+ const url = generateRequestUrl(text, { to: lang });
82
+
83
+ https.get(url, (resp) => {
84
+ const data = [];
85
+
86
+ resp.on('data', (chunk) => {
87
+ data.push(chunk);
88
+ });
89
+
90
+ resp.on('end', () => {
91
+ try {
92
+ const decodedData = iconv.decode(Buffer.concat(data), 'utf-8');
93
+ const result = normaliseResponse(JSON.parse(decodedData));
94
+ console.log('Translated text: ' + result.text);
95
+ response.setHeader('Content-Type', 'text/plain; charset=utf-8');
96
+ return response.send(result.text);
97
+ } catch (error) {
98
+ console.log('Translation error', error);
99
+ return response.sendStatus(500);
100
+ }
101
+ });
102
+ }).on('error', (err) => {
103
+ console.log('Translation error: ' + err.message);
104
+ return response.sendStatus(500);
105
+ });
106
+ } catch (error) {
107
+ console.log('Translation error', error);
108
+ return response.sendStatus(500);
109
+ }
110
+ });
111
+
112
+ router.post('/yandex', jsonParser, async (request, response) => {
113
+ const chunks = request.body.chunks;
114
+ const lang = request.body.lang;
115
+
116
+ if (!chunks || !lang) {
117
+ return response.sendStatus(400);
118
+ }
119
+
120
+ // reconstruct original text to log
121
+ let inputText = '';
122
+
123
+ const params = new URLSearchParams();
124
+ for (const chunk of chunks) {
125
+ params.append('text', chunk);
126
+ inputText += chunk;
127
+ }
128
+ params.append('lang', lang);
129
+ const ucid = uuidv4().replaceAll('-', '');
130
+
131
+ console.log('Input text: ' + inputText);
132
+
133
+ try {
134
+ const result = await fetch(`https://translate.yandex.net/api/v1/tr.json/translate?ucid=${ucid}&srv=android&format=text`, {
135
+ method: 'POST',
136
+ body: params,
137
+ headers: {
138
+ 'Content-Type': 'application/x-www-form-urlencoded',
139
+ },
140
+ timeout: 0,
141
+ });
142
+
143
+ if (!result.ok) {
144
+ const error = await result.text();
145
+ console.log('Yandex error: ', result.statusText, error);
146
+ return response.sendStatus(500);
147
+ }
148
+
149
+ const json = await result.json();
150
+ const translated = json.text.join();
151
+ console.log('Translated text: ' + translated);
152
+
153
+ return response.send(translated);
154
+ } catch (error) {
155
+ console.log('Translation error: ' + error.message);
156
+ return response.sendStatus(500);
157
+ }
158
+ });
159
+
160
+ router.post('/lingva', jsonParser, async (request, response) => {
161
+ try {
162
+ const baseUrl = readSecret(request.user.directories, SECRET_KEYS.LINGVA_URL);
163
+
164
+ if (!baseUrl) {
165
+ console.log('Lingva URL is not configured.');
166
+ return response.sendStatus(400);
167
+ }
168
+
169
+ const text = request.body.text;
170
+ const lang = request.body.lang;
171
+
172
+ if (!text || !lang) {
173
+ return response.sendStatus(400);
174
+ }
175
+
176
+ console.log('Input text: ' + text);
177
+ const url = `${baseUrl}/auto/${lang}/${encodeURIComponent(text)}`;
178
+
179
+ https.get(url, (resp) => {
180
+ let data = '';
181
+
182
+ resp.on('data', (chunk) => {
183
+ data += chunk;
184
+ });
185
+
186
+ resp.on('end', () => {
187
+ try {
188
+ const result = JSON.parse(data);
189
+ console.log('Translated text: ' + result.translation);
190
+ return response.send(result.translation);
191
+ } catch (error) {
192
+ console.log('Translation error', error);
193
+ return response.sendStatus(500);
194
+ }
195
+ });
196
+ }).on('error', (err) => {
197
+ console.log('Translation error: ' + err.message);
198
+ return response.sendStatus(500);
199
+ });
200
+ } catch (error) {
201
+ console.log('Translation error', error);
202
+ return response.sendStatus(500);
203
+ }
204
+ });
205
+
206
+ router.post('/deepl', jsonParser, async (request, response) => {
207
+ const key = readSecret(request.user.directories, SECRET_KEYS.DEEPL);
208
+
209
+ if (!key) {
210
+ console.log('DeepL key is not configured.');
211
+ return response.sendStatus(400);
212
+ }
213
+
214
+ if (request.body.lang === 'zh-CN' || request.body.lang === 'zh-TW') {
215
+ request.body.lang = 'ZH';
216
+ }
217
+
218
+ const text = request.body.text;
219
+ const lang = request.body.lang;
220
+ const formality = getConfigValue('deepl.formality', 'default');
221
+
222
+ if (!text || !lang) {
223
+ return response.sendStatus(400);
224
+ }
225
+
226
+ console.log('Input text: ' + text);
227
+
228
+ const params = new URLSearchParams();
229
+ params.append('text', text);
230
+ params.append('target_lang', lang);
231
+
232
+ if (['de', 'fr', 'it', 'es', 'nl', 'ja', 'ru'].includes(lang)) {
233
+ // We don't specify a Portuguese variant, so ignore formality for it.
234
+ params.append('formality', formality);
235
+ }
236
+
237
+ try {
238
+ const result = await fetch('https://api-free.deepl.com/v2/translate', {
239
+ method: 'POST',
240
+ body: params,
241
+ headers: {
242
+ 'Accept': 'application/json',
243
+ 'Authorization': `DeepL-Auth-Key ${key}`,
244
+ 'Content-Type': 'application/x-www-form-urlencoded',
245
+ },
246
+ timeout: 0,
247
+ });
248
+
249
+ if (!result.ok) {
250
+ const error = await result.text();
251
+ console.log('DeepL error: ', result.statusText, error);
252
+ return response.sendStatus(result.status);
253
+ }
254
+
255
+ const json = await result.json();
256
+ console.log('Translated text: ' + json.translations[0].text);
257
+
258
+ return response.send(json.translations[0].text);
259
+ } catch (error) {
260
+ console.log('Translation error: ' + error.message);
261
+ return response.sendStatus(500);
262
+ }
263
+ });
264
+
265
+ router.post('/onering', jsonParser, async (request, response) => {
266
+ const secretUrl = readSecret(request.user.directories, SECRET_KEYS.ONERING_URL);
267
+ const url = secretUrl || ONERING_URL_DEFAULT;
268
+
269
+ if (!url) {
270
+ console.log('OneRing URL is not configured.');
271
+ return response.sendStatus(400);
272
+ }
273
+
274
+ if (!secretUrl && url === ONERING_URL_DEFAULT) {
275
+ console.log('OneRing URL is using default value.', ONERING_URL_DEFAULT);
276
+ }
277
+
278
+ const text = request.body.text;
279
+ const from_lang = request.body.from_lang;
280
+ const to_lang = request.body.to_lang;
281
+
282
+ if (!text || !from_lang || !to_lang) {
283
+ return response.sendStatus(400);
284
+ }
285
+
286
+ const params = new URLSearchParams();
287
+ params.append('text', text);
288
+ params.append('from_lang', from_lang);
289
+ params.append('to_lang', to_lang);
290
+
291
+ console.log('Input text: ' + text);
292
+
293
+ try {
294
+ const fetchUrl = new URL(url);
295
+ fetchUrl.search = params.toString();
296
+
297
+ const result = await fetch(fetchUrl, {
298
+ method: 'GET',
299
+ timeout: 0,
300
+ });
301
+
302
+ if (!result.ok) {
303
+ const error = await result.text();
304
+ console.log('OneRing error: ', result.statusText, error);
305
+ return response.sendStatus(result.status);
306
+ }
307
+
308
+ const data = await result.json();
309
+ console.log('Translated text: ' + data.result);
310
+
311
+ return response.send(data.result);
312
+ } catch (error) {
313
+ console.log('Translation error: ' + error.message);
314
+ return response.sendStatus(500);
315
+ }
316
+ });
317
+
318
+ router.post('/deeplx', jsonParser, async (request, response) => {
319
+ const secretUrl = readSecret(request.user.directories, SECRET_KEYS.DEEPLX_URL);
320
+ const url = secretUrl || DEEPLX_URL_DEFAULT;
321
+
322
+ if (!url) {
323
+ console.log('DeepLX URL is not configured.');
324
+ return response.sendStatus(400);
325
+ }
326
+
327
+ if (!secretUrl && url === DEEPLX_URL_DEFAULT) {
328
+ console.log('DeepLX URL is using default value.', DEEPLX_URL_DEFAULT);
329
+ }
330
+
331
+ const text = request.body.text;
332
+ let lang = request.body.lang;
333
+ if (request.body.lang === 'zh-CN' || request.body.lang === 'zh-TW') {
334
+ lang = 'ZH';
335
+ }
336
+
337
+ if (!text || !lang) {
338
+ return response.sendStatus(400);
339
+ }
340
+
341
+ console.log('Input text: ' + text);
342
+
343
+ try {
344
+ const result = await fetch(url, {
345
+ method: 'POST',
346
+ body: JSON.stringify({
347
+ text: text,
348
+ source_lang: 'auto',
349
+ target_lang: lang,
350
+ }),
351
+ headers: {
352
+ 'Accept': 'application/json',
353
+ 'Content-Type': 'application/json',
354
+ },
355
+ timeout: 0,
356
+ });
357
+
358
+ if (!result.ok) {
359
+ const error = await result.text();
360
+ console.log('DeepLX error: ', result.statusText, error);
361
+ return response.sendStatus(result.status);
362
+ }
363
+
364
+ const json = await result.json();
365
+ console.log('Translated text: ' + json.data);
366
+
367
+ return response.send(json.data);
368
+ } catch (error) {
369
+ console.log('DeepLX translation error: ' + error.message);
370
+ return response.sendStatus(500);
371
+ }
372
+ });
373
+
374
+ router.post('/bing', jsonParser, async (request, response) => {
375
+ const bingTranslateApi = require('bing-translate-api');
376
+ const text = request.body.text;
377
+ let lang = request.body.lang;
378
+
379
+ if (request.body.lang === 'zh-CN') {
380
+ lang = 'zh-Hans';
381
+ }
382
+
383
+ if (!text || !lang) {
384
+ return response.sendStatus(400);
385
+ }
386
+
387
+ console.log('Input text: ' + text);
388
+
389
+ bingTranslateApi.translate(text, null, lang).then(result => {
390
+ console.log('Translated text: ' + result.translation);
391
+ return response.send(result.translation);
392
+ }).catch(err => {
393
+ console.log('Translation error: ' + err.message);
394
+ return response.sendStatus(500);
395
+ });
396
+ });
397
+
398
+ module.exports = { router };
src/endpoints/users-admin.js ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fsPromises = require('fs').promises;
2
+ const storage = require('node-persist');
3
+ const express = require('express');
4
+ const lodash = require('lodash');
5
+ const { jsonParser } = require('../express-common');
6
+ const { checkForNewContent } = require('./content-manager');
7
+ const {
8
+ KEY_PREFIX,
9
+ toKey,
10
+ requireAdminMiddleware,
11
+ getUserAvatar,
12
+ getAllUserHandles,
13
+ getPasswordSalt,
14
+ getPasswordHash,
15
+ getUserDirectories,
16
+ ensurePublicDirectoriesExist,
17
+ } = require('../users');
18
+ const { DEFAULT_USER } = require('../constants');
19
+
20
+ const router = express.Router();
21
+
22
+ router.post('/get', requireAdminMiddleware, jsonParser, async (_request, response) => {
23
+ try {
24
+ /** @type {import('../users').User[]} */
25
+ const users = await storage.values(x => x.key.startsWith(KEY_PREFIX));
26
+
27
+ /** @type {Promise<import('../users').UserViewModel>[]} */
28
+ const viewModelPromises = users
29
+ .map(user => new Promise(resolve => {
30
+ getUserAvatar(user.handle).then(avatar =>
31
+ resolve({
32
+ handle: user.handle,
33
+ name: user.name,
34
+ avatar: avatar,
35
+ admin: user.admin,
36
+ enabled: user.enabled,
37
+ created: user.created,
38
+ password: !!user.password,
39
+ }),
40
+ );
41
+ }));
42
+
43
+ const viewModels = await Promise.all(viewModelPromises);
44
+ viewModels.sort((x, y) => (x.created ?? 0) - (y.created ?? 0));
45
+ return response.json(viewModels);
46
+ } catch (error) {
47
+ console.error('User list failed:', error);
48
+ return response.sendStatus(500);
49
+ }
50
+ });
51
+
52
+ router.post('/disable', requireAdminMiddleware, jsonParser, async (request, response) => {
53
+ try {
54
+ if (!request.body.handle) {
55
+ console.log('Disable user failed: Missing required fields');
56
+ return response.status(400).json({ error: 'Missing required fields' });
57
+ }
58
+
59
+ if (request.body.handle === request.user.profile.handle) {
60
+ console.log('Disable user failed: Cannot disable yourself');
61
+ return response.status(400).json({ error: 'Cannot disable yourself' });
62
+ }
63
+
64
+ /** @type {import('../users').User} */
65
+ const user = await storage.getItem(toKey(request.body.handle));
66
+
67
+ if (!user) {
68
+ console.log('Disable user failed: User not found');
69
+ return response.status(404).json({ error: 'User not found' });
70
+ }
71
+
72
+ user.enabled = false;
73
+ await storage.setItem(toKey(request.body.handle), user);
74
+ return response.sendStatus(204);
75
+ } catch (error) {
76
+ console.error('User disable failed:', error);
77
+ return response.sendStatus(500);
78
+ }
79
+ });
80
+
81
+ router.post('/enable', requireAdminMiddleware, jsonParser, async (request, response) => {
82
+ try {
83
+ if (!request.body.handle) {
84
+ console.log('Enable user failed: Missing required fields');
85
+ return response.status(400).json({ error: 'Missing required fields' });
86
+ }
87
+
88
+ /** @type {import('../users').User} */
89
+ const user = await storage.getItem(toKey(request.body.handle));
90
+
91
+ if (!user) {
92
+ console.log('Enable user failed: User not found');
93
+ return response.status(404).json({ error: 'User not found' });
94
+ }
95
+
96
+ user.enabled = true;
97
+ await storage.setItem(toKey(request.body.handle), user);
98
+ return response.sendStatus(204);
99
+ } catch (error) {
100
+ console.error('User enable failed:', error);
101
+ return response.sendStatus(500);
102
+ }
103
+ });
104
+
105
+ router.post('/promote', requireAdminMiddleware, jsonParser, async (request, response) => {
106
+ try {
107
+ if (!request.body.handle) {
108
+ console.log('Promote user failed: Missing required fields');
109
+ return response.status(400).json({ error: 'Missing required fields' });
110
+ }
111
+
112
+ /** @type {import('../users').User} */
113
+ const user = await storage.getItem(toKey(request.body.handle));
114
+
115
+ if (!user) {
116
+ console.log('Promote user failed: User not found');
117
+ return response.status(404).json({ error: 'User not found' });
118
+ }
119
+
120
+ user.admin = true;
121
+ await storage.setItem(toKey(request.body.handle), user);
122
+ return response.sendStatus(204);
123
+ } catch (error) {
124
+ console.error('User promote failed:', error);
125
+ return response.sendStatus(500);
126
+ }
127
+ });
128
+
129
+ router.post('/demote', requireAdminMiddleware, jsonParser, async (request, response) => {
130
+ try {
131
+ if (!request.body.handle) {
132
+ console.log('Demote user failed: Missing required fields');
133
+ return response.status(400).json({ error: 'Missing required fields' });
134
+ }
135
+
136
+ if (request.body.handle === request.user.profile.handle) {
137
+ console.log('Demote user failed: Cannot demote yourself');
138
+ return response.status(400).json({ error: 'Cannot demote yourself' });
139
+ }
140
+
141
+ /** @type {import('../users').User} */
142
+ const user = await storage.getItem(toKey(request.body.handle));
143
+
144
+ if (!user) {
145
+ console.log('Demote user failed: User not found');
146
+ return response.status(404).json({ error: 'User not found' });
147
+ }
148
+
149
+ user.admin = false;
150
+ await storage.setItem(toKey(request.body.handle), user);
151
+ return response.sendStatus(204);
152
+ } catch (error) {
153
+ console.error('User demote failed:', error);
154
+ return response.sendStatus(500);
155
+ }
156
+ });
157
+
158
+ router.post('/create', requireAdminMiddleware, jsonParser, async (request, response) => {
159
+ try {
160
+ if (!request.body.handle || !request.body.name) {
161
+ console.log('Create user failed: Missing required fields');
162
+ return response.status(400).json({ error: 'Missing required fields' });
163
+ }
164
+
165
+ const handles = await getAllUserHandles();
166
+ const handle = lodash.kebabCase(String(request.body.handle).toLowerCase().trim());
167
+
168
+ if (!handle) {
169
+ console.log('Create user failed: Invalid handle');
170
+ return response.status(400).json({ error: 'Invalid handle' });
171
+ }
172
+
173
+ if (handles.some(x => x === handle)) {
174
+ console.log('Create user failed: User with that handle already exists');
175
+ return response.status(409).json({ error: 'User already exists' });
176
+ }
177
+
178
+ const salt = getPasswordSalt();
179
+ const password = request.body.password ? getPasswordHash(request.body.password, salt) : '';
180
+
181
+ const newUser = {
182
+ handle: handle,
183
+ name: request.body.name || 'Anonymous',
184
+ created: Date.now(),
185
+ password: password,
186
+ salt: salt,
187
+ admin: !!request.body.admin,
188
+ enabled: true,
189
+ };
190
+
191
+ await storage.setItem(toKey(handle), newUser);
192
+
193
+ // Create user directories
194
+ console.log('Creating data directories for', newUser.handle);
195
+ await ensurePublicDirectoriesExist();
196
+ const directories = getUserDirectories(newUser.handle);
197
+ await checkForNewContent([directories]);
198
+ return response.json({ handle: newUser.handle });
199
+ } catch (error) {
200
+ console.error('User create failed:', error);
201
+ return response.sendStatus(500);
202
+ }
203
+ });
204
+
205
+ router.post('/delete', requireAdminMiddleware, jsonParser, async (request, response) => {
206
+ try {
207
+ if (!request.body.handle) {
208
+ console.log('Delete user failed: Missing required fields');
209
+ return response.status(400).json({ error: 'Missing required fields' });
210
+ }
211
+
212
+ if (request.body.handle === request.user.profile.handle) {
213
+ console.log('Delete user failed: Cannot delete yourself');
214
+ return response.status(400).json({ error: 'Cannot delete yourself' });
215
+ }
216
+
217
+ if (request.body.handle === DEFAULT_USER.handle) {
218
+ console.log('Delete user failed: Cannot delete default user');
219
+ return response.status(400).json({ error: 'Sorry, but the default user cannot be deleted. It is required as a fallback.' });
220
+ }
221
+
222
+ await storage.removeItem(toKey(request.body.handle));
223
+
224
+ if (request.body.purge) {
225
+ const directories = getUserDirectories(request.body.handle);
226
+ console.log('Deleting data directories for', request.body.handle);
227
+ await fsPromises.rm(directories.root, { recursive: true, force: true });
228
+ }
229
+
230
+ return response.sendStatus(204);
231
+ } catch (error) {
232
+ console.error('User delete failed:', error);
233
+ return response.sendStatus(500);
234
+ }
235
+ });
236
+
237
+ router.post('/slugify', requireAdminMiddleware, jsonParser, async (request, response) => {
238
+ try {
239
+ if (!request.body.text) {
240
+ console.log('Slugify failed: Missing required fields');
241
+ return response.status(400).json({ error: 'Missing required fields' });
242
+ }
243
+
244
+ const text = lodash.kebabCase(String(request.body.text).toLowerCase().trim());
245
+
246
+ return response.send(text);
247
+ } catch (error) {
248
+ console.error('Slugify failed:', error);
249
+ return response.sendStatus(500);
250
+ }
251
+ });
252
+
253
+ module.exports = {
254
+ router,
255
+ };
src/endpoints/users-private.js ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const path = require('path');
2
+ const fsPromises = require('fs').promises;
3
+ const storage = require('node-persist');
4
+ const express = require('express');
5
+ const crypto = require('crypto');
6
+ const { jsonParser } = require('../express-common');
7
+ const { getUserAvatar, toKey, getPasswordHash, getPasswordSalt, createBackupArchive, ensurePublicDirectoriesExist, toAvatarKey } = require('../users');
8
+ const { SETTINGS_FILE } = require('../constants');
9
+ const contentManager = require('./content-manager');
10
+ const { color, Cache } = require('../util');
11
+ const { checkForNewContent } = require('./content-manager');
12
+
13
+ const RESET_CACHE = new Cache(5 * 60 * 1000);
14
+
15
+ const router = express.Router();
16
+
17
+ router.post('/logout', async (request, response) => {
18
+ try {
19
+ if (!request.session) {
20
+ console.error('Session not available');
21
+ return response.sendStatus(500);
22
+ }
23
+
24
+ request.session.handle = null;
25
+ return response.sendStatus(204);
26
+ } catch (error) {
27
+ console.error(error);
28
+ return response.sendStatus(500);
29
+ }
30
+ });
31
+
32
+ router.get('/me', async (request, response) => {
33
+ try {
34
+ if (!request.user) {
35
+ return response.sendStatus(403);
36
+ }
37
+
38
+ const user = request.user.profile;
39
+ const viewModel = {
40
+ handle: user.handle,
41
+ name: user.name,
42
+ avatar: await getUserAvatar(user.handle),
43
+ admin: user.admin,
44
+ password: !!user.password,
45
+ created: user.created,
46
+ };
47
+
48
+ return response.json(viewModel);
49
+ } catch (error) {
50
+ console.error(error);
51
+ return response.sendStatus(500);
52
+ }
53
+ });
54
+
55
+ router.post('/change-avatar', jsonParser, async (request, response) => {
56
+ try {
57
+ if (!request.body.handle) {
58
+ console.log('Change avatar failed: Missing required fields');
59
+ return response.status(400).json({ error: 'Missing required fields' });
60
+ }
61
+
62
+ if (request.body.handle !== request.user.profile.handle && !request.user.profile.admin) {
63
+ console.log('Change avatar failed: Unauthorized');
64
+ return response.status(403).json({ error: 'Unauthorized' });
65
+ }
66
+
67
+ // Avatar is not a data URL or not an empty string
68
+ if (!request.body.avatar.startsWith('data:image/') && request.body.avatar !== '') {
69
+ console.log('Change avatar failed: Invalid data URL');
70
+ return response.status(400).json({ error: 'Invalid data URL' });
71
+ }
72
+
73
+ /** @type {import('../users').User} */
74
+ const user = await storage.getItem(toKey(request.body.handle));
75
+
76
+ if (!user) {
77
+ console.log('Change avatar failed: User not found');
78
+ return response.status(404).json({ error: 'User not found' });
79
+ }
80
+
81
+ await storage.setItem(toAvatarKey(request.body.handle), request.body.avatar);
82
+
83
+ return response.sendStatus(204);
84
+ } catch (error) {
85
+ console.error(error);
86
+ return response.sendStatus(500);
87
+ }
88
+ });
89
+
90
+ router.post('/change-password', jsonParser, async (request, response) => {
91
+ try {
92
+ if (!request.body.handle) {
93
+ console.log('Change password failed: Missing required fields');
94
+ return response.status(400).json({ error: 'Missing required fields' });
95
+ }
96
+
97
+ if (request.body.handle !== request.user.profile.handle && !request.user.profile.admin) {
98
+ console.log('Change password failed: Unauthorized');
99
+ return response.status(403).json({ error: 'Unauthorized' });
100
+ }
101
+
102
+ /** @type {import('../users').User} */
103
+ const user = await storage.getItem(toKey(request.body.handle));
104
+
105
+ if (!user) {
106
+ console.log('Change password failed: User not found');
107
+ return response.status(404).json({ error: 'User not found' });
108
+ }
109
+
110
+ if (!user.enabled) {
111
+ console.log('Change password failed: User is disabled');
112
+ return response.status(403).json({ error: 'User is disabled' });
113
+ }
114
+
115
+ if (!request.user.profile.admin && user.password && user.password !== getPasswordHash(request.body.oldPassword, user.salt)) {
116
+ console.log('Change password failed: Incorrect password');
117
+ return response.status(403).json({ error: 'Incorrect password' });
118
+ }
119
+
120
+ if (request.body.newPassword) {
121
+ const salt = getPasswordSalt();
122
+ user.password = getPasswordHash(request.body.newPassword, salt);
123
+ user.salt = salt;
124
+ } else {
125
+ user.password = '';
126
+ user.salt = '';
127
+ }
128
+
129
+ await storage.setItem(toKey(request.body.handle), user);
130
+ return response.sendStatus(204);
131
+ } catch (error) {
132
+ console.error(error);
133
+ return response.sendStatus(500);
134
+ }
135
+ });
136
+
137
+ router.post('/backup', jsonParser, async (request, response) => {
138
+ try {
139
+ const handle = request.body.handle;
140
+
141
+ if (!handle) {
142
+ console.log('Backup failed: Missing required fields');
143
+ return response.status(400).json({ error: 'Missing required fields' });
144
+ }
145
+
146
+ if (handle !== request.user.profile.handle && !request.user.profile.admin) {
147
+ console.log('Backup failed: Unauthorized');
148
+ return response.status(403).json({ error: 'Unauthorized' });
149
+ }
150
+
151
+ await createBackupArchive(handle, response);
152
+ } catch (error) {
153
+ console.error('Backup failed', error);
154
+ return response.sendStatus(500);
155
+ }
156
+ });
157
+
158
+ router.post('/reset-settings', jsonParser, async (request, response) => {
159
+ try {
160
+ const password = request.body.password;
161
+
162
+ if (request.user.profile.password && request.user.profile.password !== getPasswordHash(password, request.user.profile.salt)) {
163
+ console.log('Reset settings failed: Incorrect password');
164
+ return response.status(403).json({ error: 'Incorrect password' });
165
+ }
166
+
167
+ const pathToFile = path.join(request.user.directories.root, SETTINGS_FILE);
168
+ await fsPromises.rm(pathToFile, { force: true });
169
+ await contentManager.checkForNewContent([request.user.directories], [contentManager.CONTENT_TYPES.SETTINGS]);
170
+
171
+ return response.sendStatus(204);
172
+ } catch (error) {
173
+ console.error('Reset settings failed', error);
174
+ return response.sendStatus(500);
175
+ }
176
+ });
177
+
178
+ router.post('/change-name', jsonParser, async (request, response) => {
179
+ try {
180
+ if (!request.body.name || !request.body.handle) {
181
+ console.log('Change name failed: Missing required fields');
182
+ return response.status(400).json({ error: 'Missing required fields' });
183
+ }
184
+
185
+ if (request.body.handle !== request.user.profile.handle && !request.user.profile.admin) {
186
+ console.log('Change name failed: Unauthorized');
187
+ return response.status(403).json({ error: 'Unauthorized' });
188
+ }
189
+
190
+ /** @type {import('../users').User} */
191
+ const user = await storage.getItem(toKey(request.body.handle));
192
+
193
+ if (!user) {
194
+ console.log('Change name failed: User not found');
195
+ return response.status(404).json({ error: 'User not found' });
196
+ }
197
+
198
+ user.name = request.body.name;
199
+ await storage.setItem(toKey(request.body.handle), user);
200
+
201
+ return response.sendStatus(204);
202
+ } catch (error) {
203
+ console.error('Change name failed', error);
204
+ return response.sendStatus(500);
205
+ }
206
+ });
207
+
208
+ router.post('/reset-step1', jsonParser, async (request, response) => {
209
+ try {
210
+ const resetCode = String(crypto.randomInt(1000, 9999));
211
+ console.log();
212
+ console.log(color.magenta(`${request.user.profile.name}, your account reset code is: `) + color.red(resetCode));
213
+ console.log();
214
+ RESET_CACHE.set(request.user.profile.handle, resetCode);
215
+ return response.sendStatus(204);
216
+ } catch (error) {
217
+ console.error('Recover step 1 failed:', error);
218
+ return response.sendStatus(500);
219
+ }
220
+ });
221
+
222
+ router.post('/reset-step2', jsonParser, async (request, response) => {
223
+ try {
224
+ if (!request.body.code) {
225
+ console.log('Recover step 2 failed: Missing required fields');
226
+ return response.status(400).json({ error: 'Missing required fields' });
227
+ }
228
+
229
+ if (request.user.profile.password && request.user.profile.password !== getPasswordHash(request.body.password, request.user.profile.salt)) {
230
+ console.log('Recover step 2 failed: Incorrect password');
231
+ return response.status(400).json({ error: 'Incorrect password' });
232
+ }
233
+
234
+ const code = RESET_CACHE.get(request.user.profile.handle);
235
+
236
+ if (!code || code !== request.body.code) {
237
+ console.log('Recover step 2 failed: Incorrect code');
238
+ return response.status(400).json({ error: 'Incorrect code' });
239
+ }
240
+
241
+ console.log('Resetting account data:', request.user.profile.handle);
242
+ await fsPromises.rm(request.user.directories.root, { recursive: true, force: true });
243
+
244
+ await ensurePublicDirectoriesExist();
245
+ await checkForNewContent([request.user.directories]);
246
+
247
+ RESET_CACHE.remove(request.user.profile.handle);
248
+ return response.sendStatus(204);
249
+ } catch (error) {
250
+ console.error('Recover step 2 failed:', error);
251
+ return response.sendStatus(500);
252
+ }
253
+ });
254
+
255
+ module.exports = {
256
+ router,
257
+ };
src/endpoints/users-public.js ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const crypto = require('crypto');
2
+ const storage = require('node-persist');
3
+ const express = require('express');
4
+ const { RateLimiterMemory, RateLimiterRes } = require('rate-limiter-flexible');
5
+ const { jsonParser, getIpFromRequest } = require('../express-common');
6
+ const { color, Cache, getConfigValue } = require('../util');
7
+ const { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt } = require('../users');
8
+
9
+ const DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false);
10
+ const MFA_CACHE = new Cache(5 * 60 * 1000);
11
+
12
+ const router = express.Router();
13
+ const loginLimiter = new RateLimiterMemory({
14
+ points: 5,
15
+ duration: 60,
16
+ });
17
+ const recoverLimiter = new RateLimiterMemory({
18
+ points: 5,
19
+ duration: 300,
20
+ });
21
+
22
+ router.post('/list', async (_request, response) => {
23
+ try {
24
+ if (DISCREET_LOGIN) {
25
+ return response.sendStatus(204);
26
+ }
27
+
28
+ /** @type {import('../users').User[]} */
29
+ const users = await storage.values(x => x.key.startsWith(KEY_PREFIX));
30
+
31
+ /** @type {Promise<import('../users').UserViewModel>[]} */
32
+ const viewModelPromises = users
33
+ .filter(x => x.enabled)
34
+ .map(user => new Promise(async (resolve) => {
35
+ getUserAvatar(user.handle).then(avatar =>
36
+ resolve({
37
+ handle: user.handle,
38
+ name: user.name,
39
+ created: user.created,
40
+ avatar: avatar,
41
+ password: !!user.password,
42
+ }),
43
+ );
44
+ }));
45
+
46
+ const viewModels = await Promise.all(viewModelPromises);
47
+ viewModels.sort((x, y) => (x.created ?? 0) - (y.created ?? 0));
48
+ return response.json(viewModels);
49
+ } catch (error) {
50
+ console.error('User list failed:', error);
51
+ return response.sendStatus(500);
52
+ }
53
+ });
54
+
55
+ router.post('/login', jsonParser, async (request, response) => {
56
+ try {
57
+ if (!request.body.handle) {
58
+ console.log('Login failed: Missing required fields');
59
+ return response.status(400).json({ error: 'Missing required fields' });
60
+ }
61
+
62
+ const ip = getIpFromRequest(request);
63
+ await loginLimiter.consume(ip);
64
+
65
+ /** @type {import('../users').User} */
66
+ const user = await storage.getItem(toKey(request.body.handle));
67
+
68
+ if (!user) {
69
+ console.log('Login failed: User not found');
70
+ return response.status(403).json({ error: 'Incorrect credentials' });
71
+ }
72
+
73
+ if (!user.enabled) {
74
+ console.log('Login failed: User is disabled');
75
+ return response.status(403).json({ error: 'User is disabled' });
76
+ }
77
+
78
+ if (user.password && user.password !== getPasswordHash(request.body.password, user.salt)) {
79
+ console.log('Login failed: Incorrect password');
80
+ return response.status(403).json({ error: 'Incorrect credentials' });
81
+ }
82
+
83
+ if (!request.session) {
84
+ console.error('Session not available');
85
+ return response.sendStatus(500);
86
+ }
87
+
88
+ await loginLimiter.delete(ip);
89
+ request.session.handle = user.handle;
90
+ console.log('Login successful:', user.handle, request.session);
91
+ return response.json({ handle: user.handle });
92
+ } catch (error) {
93
+ if (error instanceof RateLimiterRes) {
94
+ console.log('Login failed: Rate limited from', getIpFromRequest(request));
95
+ return response.status(429).send({ error: 'Too many attempts. Try again later or recover your password.' });
96
+ }
97
+
98
+ console.error('Login failed:', error);
99
+ return response.sendStatus(500);
100
+ }
101
+ });
102
+
103
+ router.post('/recover-step1', jsonParser, async (request, response) => {
104
+ try {
105
+ if (!request.body.handle) {
106
+ console.log('Recover step 1 failed: Missing required fields');
107
+ return response.status(400).json({ error: 'Missing required fields' });
108
+ }
109
+
110
+ const ip = getIpFromRequest(request);
111
+ await recoverLimiter.consume(ip);
112
+
113
+ /** @type {import('../users').User} */
114
+ const user = await storage.getItem(toKey(request.body.handle));
115
+
116
+ if (!user) {
117
+ console.log('Recover step 1 failed: User not found');
118
+ return response.status(404).json({ error: 'User not found' });
119
+ }
120
+
121
+ if (!user.enabled) {
122
+ console.log('Recover step 1 failed: User is disabled');
123
+ return response.status(403).json({ error: 'User is disabled' });
124
+ }
125
+
126
+ const mfaCode = String(crypto.randomInt(1000, 9999));
127
+ console.log();
128
+ console.log(color.blue(`${user.name}, your password recovery code is: `) + color.magenta(mfaCode));
129
+ console.log();
130
+ MFA_CACHE.set(user.handle, mfaCode);
131
+ return response.sendStatus(204);
132
+ } catch (error) {
133
+ if (error instanceof RateLimiterRes) {
134
+ console.log('Recover step 1 failed: Rate limited from', getIpFromRequest(request));
135
+ return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });
136
+ }
137
+
138
+ console.error('Recover step 1 failed:', error);
139
+ return response.sendStatus(500);
140
+ }
141
+ });
142
+
143
+ router.post('/recover-step2', jsonParser, async (request, response) => {
144
+ try {
145
+ if (!request.body.handle || !request.body.code) {
146
+ console.log('Recover step 2 failed: Missing required fields');
147
+ return response.status(400).json({ error: 'Missing required fields' });
148
+ }
149
+
150
+ /** @type {import('../users').User} */
151
+ const user = await storage.getItem(toKey(request.body.handle));
152
+ const ip = getIpFromRequest(request);
153
+
154
+ if (!user) {
155
+ console.log('Recover step 2 failed: User not found');
156
+ return response.status(404).json({ error: 'User not found' });
157
+ }
158
+
159
+ if (!user.enabled) {
160
+ console.log('Recover step 2 failed: User is disabled');
161
+ return response.status(403).json({ error: 'User is disabled' });
162
+ }
163
+
164
+ const mfaCode = MFA_CACHE.get(user.handle);
165
+
166
+ if (request.body.code !== mfaCode) {
167
+ await recoverLimiter.consume(ip);
168
+ console.log('Recover step 2 failed: Incorrect code');
169
+ return response.status(403).json({ error: 'Incorrect code' });
170
+ }
171
+
172
+ if (request.body.newPassword) {
173
+ const salt = getPasswordSalt();
174
+ user.password = getPasswordHash(request.body.newPassword, salt);
175
+ user.salt = salt;
176
+ await storage.setItem(toKey(user.handle), user);
177
+ } else {
178
+ user.password = '';
179
+ user.salt = '';
180
+ await storage.setItem(toKey(user.handle), user);
181
+ }
182
+
183
+ await recoverLimiter.delete(ip);
184
+ MFA_CACHE.remove(user.handle);
185
+ return response.sendStatus(204);
186
+ } catch (error) {
187
+ if (error instanceof RateLimiterRes) {
188
+ console.log('Recover step 2 failed: Rate limited from', getIpFromRequest(request));
189
+ return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });
190
+ }
191
+
192
+ console.error('Recover step 2 failed:', error);
193
+ return response.sendStatus(500);
194
+ }
195
+ });
196
+
197
+ module.exports = {
198
+ router,
199
+ };
src/endpoints/vectors.js ADDED
@@ -0,0 +1,491 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const vectra = require('vectra');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+ const express = require('express');
5
+ const sanitize = require('sanitize-filename');
6
+ const { jsonParser } = require('../express-common');
7
+
8
+ // Don't forget to add new sources to the SOURCES array
9
+ const SOURCES = [
10
+ 'transformers',
11
+ 'mistral',
12
+ 'openai',
13
+ 'extras',
14
+ 'palm',
15
+ 'togetherai',
16
+ 'nomicai',
17
+ 'cohere',
18
+ 'ollama',
19
+ 'llamacpp',
20
+ 'vllm',
21
+ ];
22
+
23
+ /**
24
+ * Gets the vector for the given text from the given source.
25
+ * @param {string} source - The source of the vector
26
+ * @param {Object} sourceSettings - Settings for the source, if it needs any
27
+ * @param {string} text - The text to get the vector for
28
+ * @param {boolean} isQuery - If the text is a query for embedding search
29
+ * @param {import('../users').UserDirectoryList} directories - The directories object for the user
30
+ * @returns {Promise<number[]>} - The vector for the text
31
+ */
32
+ async function getVector(source, sourceSettings, text, isQuery, directories) {
33
+ switch (source) {
34
+ case 'nomicai':
35
+ return require('../vectors/nomicai-vectors').getNomicAIVector(text, source, directories);
36
+ case 'togetherai':
37
+ case 'mistral':
38
+ case 'openai':
39
+ return require('../vectors/openai-vectors').getOpenAIVector(text, source, directories, sourceSettings.model);
40
+ case 'transformers':
41
+ return require('../vectors/embedding').getTransformersVector(text);
42
+ case 'extras':
43
+ return require('../vectors/extras-vectors').getExtrasVector(text, sourceSettings.extrasUrl, sourceSettings.extrasKey);
44
+ case 'palm':
45
+ return require('../vectors/makersuite-vectors').getMakerSuiteVector(text, directories);
46
+ case 'cohere':
47
+ return require('../vectors/cohere-vectors').getCohereVector(text, isQuery, directories, sourceSettings.model);
48
+ case 'llamacpp':
49
+ return require('../vectors/llamacpp-vectors').getLlamaCppVector(text, sourceSettings.apiUrl, directories);
50
+ case 'vllm':
51
+ return require('../vectors/vllm-vectors').getVllmVector(text, sourceSettings.apiUrl, sourceSettings.model, directories);
52
+ case 'ollama':
53
+ return require('../vectors/ollama-vectors').getOllamaVector(text, sourceSettings.apiUrl, sourceSettings.model, sourceSettings.keep, directories);
54
+ }
55
+
56
+ throw new Error(`Unknown vector source ${source}`);
57
+ }
58
+
59
+ /**
60
+ * Gets the vector for the given text batch from the given source.
61
+ * @param {string} source - The source of the vector
62
+ * @param {Object} sourceSettings - Settings for the source, if it needs any
63
+ * @param {string[]} texts - The array of texts to get the vector for
64
+ * @param {boolean} isQuery - If the text is a query for embedding search
65
+ * @param {import('../users').UserDirectoryList} directories - The directories object for the user
66
+ * @returns {Promise<number[][]>} - The array of vectors for the texts
67
+ */
68
+ async function getBatchVector(source, sourceSettings, texts, isQuery, directories) {
69
+ const batchSize = 10;
70
+ const batches = Array(Math.ceil(texts.length / batchSize)).fill(undefined).map((_, i) => texts.slice(i * batchSize, i * batchSize + batchSize));
71
+
72
+ let results = [];
73
+ for (let batch of batches) {
74
+ switch (source) {
75
+ case 'nomicai':
76
+ results.push(...await require('../vectors/nomicai-vectors').getNomicAIBatchVector(batch, source, directories));
77
+ break;
78
+ case 'togetherai':
79
+ case 'mistral':
80
+ case 'openai':
81
+ results.push(...await require('../vectors/openai-vectors').getOpenAIBatchVector(batch, source, directories, sourceSettings.model));
82
+ break;
83
+ case 'transformers':
84
+ results.push(...await require('../vectors/embedding').getTransformersBatchVector(batch));
85
+ break;
86
+ case 'extras':
87
+ results.push(...await require('../vectors/extras-vectors').getExtrasBatchVector(batch, sourceSettings.extrasUrl, sourceSettings.extrasKey));
88
+ break;
89
+ case 'palm':
90
+ results.push(...await require('../vectors/makersuite-vectors').getMakerSuiteBatchVector(batch, directories));
91
+ break;
92
+ case 'cohere':
93
+ results.push(...await require('../vectors/cohere-vectors').getCohereBatchVector(batch, isQuery, directories, sourceSettings.model));
94
+ break;
95
+ case 'llamacpp':
96
+ results.push(...await require('../vectors/llamacpp-vectors').getLlamaCppBatchVector(batch, sourceSettings.apiUrl, directories));
97
+ break;
98
+ case 'vllm':
99
+ results.push(...await require('../vectors/vllm-vectors').getVllmBatchVector(batch, sourceSettings.apiUrl, sourceSettings.model, directories));
100
+ break;
101
+ case 'ollama':
102
+ results.push(...await require('../vectors/ollama-vectors').getOllamaBatchVector(batch, sourceSettings.apiUrl, sourceSettings.model, sourceSettings.keep, directories));
103
+ break;
104
+ default:
105
+ throw new Error(`Unknown vector source ${source}`);
106
+ }
107
+ }
108
+
109
+ return results;
110
+ }
111
+
112
+ /**
113
+ * Gets the index for the vector collection
114
+ * @param {import('../users').UserDirectoryList} directories - User directories
115
+ * @param {string} collectionId - The collection ID
116
+ * @param {string} source - The source of the vector
117
+ * @param {boolean} create - Whether to create the index if it doesn't exist
118
+ * @returns {Promise<vectra.LocalIndex>} - The index for the collection
119
+ */
120
+ async function getIndex(directories, collectionId, source, create = true) {
121
+ const pathToFile = path.join(directories.vectors, sanitize(source), sanitize(collectionId));
122
+ const store = new vectra.LocalIndex(pathToFile);
123
+
124
+ if (create && !await store.isIndexCreated()) {
125
+ await store.createIndex();
126
+ }
127
+
128
+ return store;
129
+ }
130
+
131
+ /**
132
+ * Inserts items into the vector collection
133
+ * @param {import('../users').UserDirectoryList} directories - User directories
134
+ * @param {string} collectionId - The collection ID
135
+ * @param {string} source - The source of the vector
136
+ * @param {Object} sourceSettings - Settings for the source, if it needs any
137
+ * @param {{ hash: number; text: string; index: number; }[]} items - The items to insert
138
+ */
139
+ async function insertVectorItems(directories, collectionId, source, sourceSettings, items) {
140
+ const store = await getIndex(directories, collectionId, source);
141
+
142
+ await store.beginUpdate();
143
+
144
+ const vectors = await getBatchVector(source, sourceSettings, items.map(x => x.text), false, directories);
145
+
146
+ for (let i = 0; i < items.length; i++) {
147
+ const item = items[i];
148
+ const vector = vectors[i];
149
+ await store.upsertItem({ vector: vector, metadata: { hash: item.hash, text: item.text, index: item.index } });
150
+ }
151
+
152
+ await store.endUpdate();
153
+ }
154
+
155
+ /**
156
+ * Gets the hashes of the items in the vector collection
157
+ * @param {import('../users').UserDirectoryList} directories - User directories
158
+ * @param {string} collectionId - The collection ID
159
+ * @param {string} source - The source of the vector
160
+ * @returns {Promise<number[]>} - The hashes of the items in the collection
161
+ */
162
+ async function getSavedHashes(directories, collectionId, source) {
163
+ const store = await getIndex(directories, collectionId, source);
164
+
165
+ const items = await store.listItems();
166
+ const hashes = items.map(x => Number(x.metadata.hash));
167
+
168
+ return hashes;
169
+ }
170
+
171
+ /**
172
+ * Deletes items from the vector collection by hash
173
+ * @param {import('../users').UserDirectoryList} directories - User directories
174
+ * @param {string} collectionId - The collection ID
175
+ * @param {string} source - The source of the vector
176
+ * @param {number[]} hashes - The hashes of the items to delete
177
+ */
178
+ async function deleteVectorItems(directories, collectionId, source, hashes) {
179
+ const store = await getIndex(directories, collectionId, source);
180
+ const items = await store.listItemsByMetadata({ hash: { '$in': hashes } });
181
+
182
+ await store.beginUpdate();
183
+
184
+ for (const item of items) {
185
+ await store.deleteItem(item.id);
186
+ }
187
+
188
+ await store.endUpdate();
189
+ }
190
+
191
+ /**
192
+ * Gets the hashes of the items in the vector collection that match the search text
193
+ * @param {import('../users').UserDirectoryList} directories - User directories
194
+ * @param {string} collectionId - The collection ID
195
+ * @param {string} source - The source of the vector
196
+ * @param {Object} sourceSettings - Settings for the source, if it needs any
197
+ * @param {string} searchText - The text to search for
198
+ * @param {number} topK - The number of results to return
199
+ * @param {number} threshold - The threshold for the search
200
+ * @returns {Promise<{hashes: number[], metadata: object[]}>} - The metadata of the items that match the search text
201
+ */
202
+ async function queryCollection(directories, collectionId, source, sourceSettings, searchText, topK, threshold) {
203
+ const store = await getIndex(directories, collectionId, source);
204
+ const vector = await getVector(source, sourceSettings, searchText, true, directories);
205
+
206
+ const result = await store.queryItems(vector, topK);
207
+ const metadata = result.filter(x => x.score >= threshold).map(x => x.item.metadata);
208
+ const hashes = result.map(x => Number(x.item.metadata.hash));
209
+ return { metadata, hashes };
210
+ }
211
+
212
+ /**
213
+ * Queries multiple collections for the given search queries. Returns the overall top K results.
214
+ * @param {import('../users').UserDirectoryList} directories - User directories
215
+ * @param {string[]} collectionIds - The collection IDs to query
216
+ * @param {string} source - The source of the vector
217
+ * @param {Object} sourceSettings - Settings for the source, if it needs any
218
+ * @param {string} searchText - The text to search for
219
+ * @param {number} topK - The number of results to return
220
+ * @param {number} threshold - The threshold for the search
221
+ *
222
+ * @returns {Promise<Record<string, { hashes: number[], metadata: object[] }>>} - The top K results from each collection
223
+ */
224
+ async function multiQueryCollection(directories, collectionIds, source, sourceSettings, searchText, topK, threshold) {
225
+ const vector = await getVector(source, sourceSettings, searchText, true, directories);
226
+ const results = [];
227
+
228
+ for (const collectionId of collectionIds) {
229
+ const store = await getIndex(directories, collectionId, source);
230
+ const result = await store.queryItems(vector, topK);
231
+ results.push(...result.map(result => ({ collectionId, result })));
232
+ }
233
+
234
+ // Sort results by descending similarity, apply threshold, and take top K
235
+ const sortedResults = results
236
+ .sort((a, b) => b.result.score - a.result.score)
237
+ .filter(x => x.result.score >= threshold)
238
+ .slice(0, topK);
239
+
240
+ /**
241
+ * Group the results by collection ID
242
+ * @type {Record<string, { hashes: number[], metadata: object[] }>}
243
+ */
244
+ const groupedResults = {};
245
+ for (const result of sortedResults) {
246
+ if (!groupedResults[result.collectionId]) {
247
+ groupedResults[result.collectionId] = { hashes: [], metadata: [] };
248
+ }
249
+
250
+ groupedResults[result.collectionId].hashes.push(Number(result.result.item.metadata.hash));
251
+ groupedResults[result.collectionId].metadata.push(result.result.item.metadata);
252
+ }
253
+
254
+ return groupedResults;
255
+ }
256
+
257
+ /**
258
+ * Extracts settings for the vectorization sources from the HTTP request headers.
259
+ * @param {string} source - Which source to extract settings for.
260
+ * @param {object} request - The HTTP request object.
261
+ * @returns {object} - An object that can be used as `sourceSettings` in functions that take that parameter.
262
+ */
263
+ function getSourceSettings(source, request) {
264
+ if (source === 'togetherai') {
265
+ const model = String(request.headers['x-togetherai-model']);
266
+
267
+ return {
268
+ model: model,
269
+ };
270
+ } else if (source === 'openai') {
271
+ const model = String(request.headers['x-openai-model']);
272
+
273
+ return {
274
+ model: model,
275
+ };
276
+ } else if (source === 'cohere') {
277
+ const model = String(request.headers['x-cohere-model']);
278
+
279
+ return {
280
+ model: model,
281
+ };
282
+ } else if (source === 'llamacpp') {
283
+ const apiUrl = String(request.headers['x-llamacpp-url']);
284
+
285
+ return {
286
+ apiUrl: apiUrl,
287
+ };
288
+ } else if (source === 'vllm') {
289
+ const apiUrl = String(request.headers['x-vllm-url']);
290
+ const model = String(request.headers['x-vllm-model']);
291
+
292
+ return {
293
+ apiUrl: apiUrl,
294
+ model: model,
295
+ };
296
+ } else if (source === 'ollama') {
297
+ const apiUrl = String(request.headers['x-ollama-url']);
298
+ const model = String(request.headers['x-ollama-model']);
299
+ const keep = Boolean(request.headers['x-ollama-keep']);
300
+
301
+ return {
302
+ apiUrl: apiUrl,
303
+ model: model,
304
+ keep: keep,
305
+ };
306
+ } else {
307
+ // Extras API settings to connect to the Extras embeddings provider
308
+ let extrasUrl = '';
309
+ let extrasKey = '';
310
+ if (source === 'extras') {
311
+ extrasUrl = String(request.headers['x-extras-url']);
312
+ extrasKey = String(request.headers['x-extras-key']);
313
+ }
314
+
315
+ return {
316
+ extrasUrl: extrasUrl,
317
+ extrasKey: extrasKey,
318
+ };
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Performs a request to regenerate the index if it is corrupted.
324
+ * @param {import('express').Request} req Express request object
325
+ * @param {import('express').Response} res Express response object
326
+ * @param {Error} error Error object
327
+ * @returns {Promise<any>} Promise
328
+ */
329
+ async function regenerateCorruptedIndexErrorHandler(req, res, error) {
330
+ if (error instanceof SyntaxError && !req.query.regenerated) {
331
+ const collectionId = String(req.body.collectionId);
332
+ const source = String(req.body.source) || 'transformers';
333
+
334
+ if (collectionId && source) {
335
+ const index = await getIndex(req.user.directories, collectionId, source, false);
336
+ const exists = await index.isIndexCreated();
337
+
338
+ if (exists) {
339
+ const path = index.folderPath;
340
+ console.error(`Corrupted index detected at ${path}, regenerating...`);
341
+ await index.deleteIndex();
342
+ return res.redirect(307, req.originalUrl + '?regenerated=true');
343
+ }
344
+ }
345
+ }
346
+
347
+ console.error(error);
348
+ return res.sendStatus(500);
349
+ }
350
+
351
+ const router = express.Router();
352
+
353
+ router.post('/query', jsonParser, async (req, res) => {
354
+ try {
355
+ if (!req.body.collectionId || !req.body.searchText) {
356
+ return res.sendStatus(400);
357
+ }
358
+
359
+ const collectionId = String(req.body.collectionId);
360
+ const searchText = String(req.body.searchText);
361
+ const topK = Number(req.body.topK) || 10;
362
+ const threshold = Number(req.body.threshold) || 0.0;
363
+ const source = String(req.body.source) || 'transformers';
364
+ const sourceSettings = getSourceSettings(source, req);
365
+
366
+ const results = await queryCollection(req.user.directories, collectionId, source, sourceSettings, searchText, topK, threshold);
367
+ return res.json(results);
368
+ } catch (error) {
369
+ return regenerateCorruptedIndexErrorHandler(req, res, error);
370
+ }
371
+ });
372
+
373
+ router.post('/query-multi', jsonParser, async (req, res) => {
374
+ try {
375
+ if (!Array.isArray(req.body.collectionIds) || !req.body.searchText) {
376
+ return res.sendStatus(400);
377
+ }
378
+
379
+ const collectionIds = req.body.collectionIds.map(x => String(x));
380
+ const searchText = String(req.body.searchText);
381
+ const topK = Number(req.body.topK) || 10;
382
+ const threshold = Number(req.body.threshold) || 0.0;
383
+ const source = String(req.body.source) || 'transformers';
384
+ const sourceSettings = getSourceSettings(source, req);
385
+
386
+ const results = await multiQueryCollection(req.user.directories, collectionIds, source, sourceSettings, searchText, topK, threshold);
387
+ return res.json(results);
388
+ } catch (error) {
389
+ return regenerateCorruptedIndexErrorHandler(req, res, error);
390
+ }
391
+ });
392
+
393
+ router.post('/insert', jsonParser, async (req, res) => {
394
+ try {
395
+ if (!Array.isArray(req.body.items) || !req.body.collectionId) {
396
+ return res.sendStatus(400);
397
+ }
398
+
399
+ const collectionId = String(req.body.collectionId);
400
+ const items = req.body.items.map(x => ({ hash: x.hash, text: x.text, index: x.index }));
401
+ const source = String(req.body.source) || 'transformers';
402
+ const sourceSettings = getSourceSettings(source, req);
403
+
404
+ await insertVectorItems(req.user.directories, collectionId, source, sourceSettings, items);
405
+ return res.sendStatus(200);
406
+ } catch (error) {
407
+ return regenerateCorruptedIndexErrorHandler(req, res, error);
408
+ }
409
+ });
410
+
411
+ router.post('/list', jsonParser, async (req, res) => {
412
+ try {
413
+ if (!req.body.collectionId) {
414
+ return res.sendStatus(400);
415
+ }
416
+
417
+ const collectionId = String(req.body.collectionId);
418
+ const source = String(req.body.source) || 'transformers';
419
+
420
+ const hashes = await getSavedHashes(req.user.directories, collectionId, source);
421
+ return res.json(hashes);
422
+ } catch (error) {
423
+ return regenerateCorruptedIndexErrorHandler(req, res, error);
424
+ }
425
+ });
426
+
427
+ router.post('/delete', jsonParser, async (req, res) => {
428
+ try {
429
+ if (!Array.isArray(req.body.hashes) || !req.body.collectionId) {
430
+ return res.sendStatus(400);
431
+ }
432
+
433
+ const collectionId = String(req.body.collectionId);
434
+ const hashes = req.body.hashes.map(x => Number(x));
435
+ const source = String(req.body.source) || 'transformers';
436
+
437
+ await deleteVectorItems(req.user.directories, collectionId, source, hashes);
438
+ return res.sendStatus(200);
439
+ } catch (error) {
440
+ return regenerateCorruptedIndexErrorHandler(req, res, error);
441
+ }
442
+ });
443
+
444
+ router.post('/purge-all', jsonParser, async (req, res) => {
445
+ try {
446
+ for (const source of SOURCES) {
447
+ const sourcePath = path.join(req.user.directories.vectors, sanitize(source));
448
+ if (!fs.existsSync(sourcePath)) {
449
+ continue;
450
+ }
451
+ await fs.promises.rm(sourcePath, { recursive: true });
452
+ console.log(`Deleted vector source store at ${sourcePath}`);
453
+ }
454
+
455
+ return res.sendStatus(200);
456
+ } catch (error) {
457
+ console.error(error);
458
+ return res.sendStatus(500);
459
+ }
460
+ });
461
+
462
+ router.post('/purge', jsonParser, async (req, res) => {
463
+ try {
464
+ if (!req.body.collectionId) {
465
+ return res.sendStatus(400);
466
+ }
467
+
468
+ const collectionId = String(req.body.collectionId);
469
+
470
+ for (const source of SOURCES) {
471
+ const index = await getIndex(req.user.directories, collectionId, source, false);
472
+
473
+ const exists = await index.isIndexCreated();
474
+
475
+ if (!exists) {
476
+ continue;
477
+ }
478
+
479
+ const path = index.folderPath;
480
+ await index.deleteIndex();
481
+ console.log(`Deleted vector index at ${path}`);
482
+ }
483
+
484
+ return res.sendStatus(200);
485
+ } catch (error) {
486
+ console.error(error);
487
+ return res.sendStatus(500);
488
+ }
489
+ });
490
+
491
+ module.exports = { router };
src/endpoints/worldinfo.js ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const express = require('express');
4
+ const sanitize = require('sanitize-filename');
5
+ const writeFileAtomicSync = require('write-file-atomic').sync;
6
+
7
+ const { jsonParser, urlencodedParser } = require('../express-common');
8
+
9
+ /**
10
+ * Reads a World Info file and returns its contents
11
+ * @param {import('../users').UserDirectoryList} directories User directories
12
+ * @param {string} worldInfoName Name of the World Info file
13
+ * @param {boolean} allowDummy If true, returns an empty object if the file doesn't exist
14
+ * @returns {object} World Info file contents
15
+ */
16
+ function readWorldInfoFile(directories, worldInfoName, allowDummy) {
17
+ const dummyObject = allowDummy ? { entries: {} } : null;
18
+
19
+ if (!worldInfoName) {
20
+ return dummyObject;
21
+ }
22
+
23
+ const filename = `${worldInfoName}.json`;
24
+ const pathToWorldInfo = path.join(directories.worlds, filename);
25
+
26
+ if (!fs.existsSync(pathToWorldInfo)) {
27
+ console.log(`World info file ${filename} doesn't exist.`);
28
+ return dummyObject;
29
+ }
30
+
31
+ const worldInfoText = fs.readFileSync(pathToWorldInfo, 'utf8');
32
+ const worldInfo = JSON.parse(worldInfoText);
33
+ return worldInfo;
34
+ }
35
+
36
+ const router = express.Router();
37
+
38
+ router.post('/get', jsonParser, (request, response) => {
39
+ if (!request.body?.name) {
40
+ return response.sendStatus(400);
41
+ }
42
+
43
+ const file = readWorldInfoFile(request.user.directories, request.body.name, true);
44
+
45
+ return response.send(file);
46
+ });
47
+
48
+ router.post('/delete', jsonParser, (request, response) => {
49
+ if (!request.body?.name) {
50
+ return response.sendStatus(400);
51
+ }
52
+
53
+ const worldInfoName = request.body.name;
54
+ const filename = sanitize(`${worldInfoName}.json`);
55
+ const pathToWorldInfo = path.join(request.user.directories.worlds, filename);
56
+
57
+ if (!fs.existsSync(pathToWorldInfo)) {
58
+ throw new Error(`World info file ${filename} doesn't exist.`);
59
+ }
60
+
61
+ fs.rmSync(pathToWorldInfo);
62
+
63
+ return response.sendStatus(200);
64
+ });
65
+
66
+ router.post('/import', urlencodedParser, (request, response) => {
67
+ if (!request.file) return response.sendStatus(400);
68
+
69
+ const filename = `${path.parse(sanitize(request.file.originalname)).name}.json`;
70
+
71
+ let fileContents = null;
72
+
73
+ if (request.body.convertedData) {
74
+ fileContents = request.body.convertedData;
75
+ } else {
76
+ const pathToUpload = path.join(request.file.destination, request.file.filename);
77
+ fileContents = fs.readFileSync(pathToUpload, 'utf8');
78
+ fs.unlinkSync(pathToUpload);
79
+ }
80
+
81
+ try {
82
+ const worldContent = JSON.parse(fileContents);
83
+ if (!('entries' in worldContent)) {
84
+ throw new Error('File must contain a world info entries list');
85
+ }
86
+ } catch (err) {
87
+ return response.status(400).send('Is not a valid world info file');
88
+ }
89
+
90
+ const pathToNewFile = path.join(request.user.directories.worlds, filename);
91
+ const worldName = path.parse(pathToNewFile).name;
92
+
93
+ if (!worldName) {
94
+ return response.status(400).send('World file must have a name');
95
+ }
96
+
97
+ writeFileAtomicSync(pathToNewFile, fileContents);
98
+ return response.send({ name: worldName });
99
+ });
100
+
101
+ router.post('/edit', jsonParser, (request, response) => {
102
+ if (!request.body) {
103
+ return response.sendStatus(400);
104
+ }
105
+
106
+ if (!request.body.name) {
107
+ return response.status(400).send('World file must have a name');
108
+ }
109
+
110
+ try {
111
+ if (!('entries' in request.body.data)) {
112
+ throw new Error('World info must contain an entries list');
113
+ }
114
+ } catch (err) {
115
+ return response.status(400).send('Is not a valid world info file');
116
+ }
117
+
118
+ const filename = `${sanitize(request.body.name)}.json`;
119
+ const pathToFile = path.join(request.user.directories.worlds, filename);
120
+
121
+ writeFileAtomicSync(pathToFile, JSON.stringify(request.body.data, null, 4));
122
+
123
+ return response.send({ ok: true });
124
+ });
125
+
126
+ module.exports = { router, readWorldInfoFile };
src/express-common.js ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const ipaddr = require('ipaddr.js');
3
+
4
+ // Instantiate parser middleware here with application-level size limits
5
+ const jsonParser = express.json({ limit: '200mb' });
6
+ const urlencodedParser = express.urlencoded({ extended: true, limit: '200mb' });
7
+
8
+ /**
9
+ * Gets the IP address of the client from the request object.
10
+ * @param {import('express'.Request)} req Request object
11
+ * @returns {string} IP address of the client
12
+ */
13
+ function getIpFromRequest(req) {
14
+ let clientIp = req.connection.remoteAddress;
15
+ let ip = ipaddr.parse(clientIp);
16
+ // Check if the IP address is IPv4-mapped IPv6 address
17
+ if (ip.kind() === 'ipv6' && ip instanceof ipaddr.IPv6 && ip.isIPv4MappedAddress()) {
18
+ const ipv4 = ip.toIPv4Address().toString();
19
+ clientIp = ipv4;
20
+ } else {
21
+ clientIp = ip;
22
+ clientIp = clientIp.toString();
23
+ }
24
+ return clientIp;
25
+ }
26
+
27
+
28
+ module.exports = { jsonParser, urlencodedParser, getIpFromRequest };
src/middleware/basicAuth.js ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * When applied, this middleware will ensure the request contains the required header for basic authentication and only
3
+ * allow access to the endpoint after successful authentication.
4
+ */
5
+ const { getConfig } = require('../util.js');
6
+
7
+ const unauthorizedResponse = (res) => {
8
+ res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"');
9
+ return res.status(401).send('Authentication required');
10
+ };
11
+
12
+ const basicAuthMiddleware = function (request, response, callback) {
13
+ const config = getConfig();
14
+ const authHeader = request.headers.authorization;
15
+
16
+ if (!authHeader) {
17
+ return unauthorizedResponse(response);
18
+ }
19
+
20
+ const [scheme, credentials] = authHeader.split(' ');
21
+
22
+ if (scheme !== 'Basic' || !credentials) {
23
+ return unauthorizedResponse(response);
24
+ }
25
+
26
+ const [username, password] = Buffer.from(credentials, 'base64')
27
+ .toString('utf8')
28
+ .split(':');
29
+
30
+ if (username === config.basicAuthUser.username && password === config.basicAuthUser.password) {
31
+ return callback();
32
+ } else {
33
+ return unauthorizedResponse(response);
34
+ }
35
+ };
36
+
37
+ module.exports = basicAuthMiddleware;
src/middleware/multerMonkeyPatch.js ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Decodes a file name from Latin1 to UTF-8.
3
+ * @param {string} str Input string
4
+ * @returns {string} Decoded file name
5
+ */
6
+ function decodeFileName(str) {
7
+ return Buffer.from(str, 'latin1').toString('utf-8');
8
+ }
9
+
10
+ /**
11
+ * Middleware to decode file names from Latin1 to UTF-8.
12
+ * See: https://github.com/expressjs/multer/issues/1104
13
+ * @param {import('express').Request} req Request
14
+ * @param {import('express').Response} _res Response
15
+ * @param {import('express').NextFunction} next Next middleware
16
+ */
17
+ function multerMonkeyPatch(req, _res, next) {
18
+ try {
19
+ if (req.file) {
20
+ req.file.originalname = decodeFileName(req.file.originalname);
21
+ }
22
+
23
+ next();
24
+ } catch (error) {
25
+ console.error('Error in multerMonkeyPatch:', error);
26
+ next();
27
+ }
28
+ }
29
+
30
+ module.exports = multerMonkeyPatch;
src/middleware/whitelist.js ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const ipMatching = require('ip-matching');
4
+
5
+ const { getIpFromRequest } = require('../express-common');
6
+ const { color, getConfigValue } = require('../util');
7
+
8
+ const whitelistPath = path.join(process.cwd(), './whitelist.txt');
9
+ const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false);
10
+ let whitelist = getConfigValue('whitelist', []);
11
+ let knownIPs = new Set();
12
+
13
+ if (fs.existsSync(whitelistPath)) {
14
+ try {
15
+ let whitelistTxt = fs.readFileSync(whitelistPath, 'utf-8');
16
+ whitelist = whitelistTxt.split('\n').filter(ip => ip).map(ip => ip.trim());
17
+ } catch (e) {
18
+ // Ignore errors that may occur when reading the whitelist (e.g. permissions)
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Get the client IP address from the request headers.
24
+ * @param {import('express').Request} req Express request object
25
+ * @returns {string|undefined} The client IP address
26
+ */
27
+ function getForwardedIp(req) {
28
+ if (!enableForwardedWhitelist) {
29
+ return undefined;
30
+ }
31
+
32
+ // Check if X-Real-IP is available
33
+ if (req.headers['x-real-ip']) {
34
+ return req.headers['x-real-ip'].toString();
35
+ }
36
+
37
+ // Check for X-Forwarded-For and parse if available
38
+ if (req.headers['x-forwarded-for']) {
39
+ const ipList = req.headers['x-forwarded-for'].toString().split(',').map(ip => ip.trim());
40
+ return ipList[0];
41
+ }
42
+
43
+ // If none of the headers are available, return undefined
44
+ return undefined;
45
+ }
46
+
47
+ /**
48
+ * Returns a middleware function that checks if the client IP is in the whitelist.
49
+ * @param {boolean} whitelistMode If whitelist mode is enabled via config or command line
50
+ * @param {boolean} listen If listen mode is enabled via config or command line
51
+ * @returns {import('express').RequestHandler} The middleware function
52
+ */
53
+ function whitelistMiddleware(whitelistMode, listen) {
54
+ return function (req, res, next) {
55
+ const clientIp = getIpFromRequest(req);
56
+ const forwardedIp = getForwardedIp(req);
57
+
58
+ if (listen && !knownIPs.has(clientIp)) {
59
+ const userAgent = req.headers['user-agent'];
60
+ console.log(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`));
61
+ knownIPs.add(clientIp);
62
+
63
+ // Write access log
64
+ const timestamp = new Date().toISOString();
65
+ const log = `${timestamp} ${clientIp} ${userAgent}\n`;
66
+ fs.appendFile('access.log', log, (err) => {
67
+ if (err) {
68
+ console.error('Failed to write access log:', err);
69
+ }
70
+ });
71
+ }
72
+
73
+ //clientIp = req.connection.remoteAddress.split(':').pop();
74
+ if (whitelistMode === true && !whitelist.some(x => ipMatching.matches(clientIp, ipMatching.getMatch(x)))
75
+ || forwardedIp && whitelistMode === true && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x)))
76
+ ) {
77
+ // Log the connection attempt with real IP address
78
+ const ipDetails = forwardedIp ? `${clientIp} (forwarded from ${forwardedIp})` : clientIp;
79
+ console.log(color.red('Forbidden: Connection attempt from ' + ipDetails + '. If you are attempting to connect, please add your IP address in whitelist or disable whitelist mode in config.yaml in root of SillyTavern folder.\n'));
80
+ return res.status(403).send('<b>Forbidden</b>: Connection attempt from <b>' + ipDetails + '</b>. If you are attempting to connect, please add your IP address in whitelist or disable whitelist mode in config.yaml in root of SillyTavern folder.');
81
+ }
82
+ next();
83
+ };
84
+ }
85
+
86
+ module.exports = whitelistMiddleware;
src/plugin-loader.js ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const url = require('url');
4
+ const express = require('express');
5
+ const { getConfigValue } = require('./util');
6
+ const enableServerPlugins = getConfigValue('enableServerPlugins', false);
7
+
8
+ /**
9
+ * Map of loaded plugins.
10
+ * @type {Map<string, any>}
11
+ */
12
+ const loadedPlugins = new Map();
13
+
14
+ /**
15
+ * Determine if a file is a CommonJS module.
16
+ * @param {string} file Path to file
17
+ * @returns {boolean} True if file is a CommonJS module
18
+ */
19
+ const isCommonJS = (file) => path.extname(file) === '.js';
20
+
21
+ /**
22
+ * Determine if a file is an ECMAScript module.
23
+ * @param {string} file Path to file
24
+ * @returns {boolean} True if file is an ECMAScript module
25
+ */
26
+ const isESModule = (file) => path.extname(file) === '.mjs';
27
+
28
+ /**
29
+ * Load and initialize server plugins from a directory if they are enabled.
30
+ * @param {import('express').Express} app Express app
31
+ * @param {string} pluginsPath Path to plugins directory
32
+ * @returns {Promise<Function>} Promise that resolves when all plugins are loaded. Resolves to a "cleanup" function to
33
+ * be called before the server shuts down.
34
+ */
35
+ async function loadPlugins(app, pluginsPath) {
36
+ const exitHooks = [];
37
+ const emptyFn = () => {};
38
+
39
+ // Server plugins are disabled.
40
+ if (!enableServerPlugins) {
41
+ return emptyFn;
42
+ }
43
+
44
+ // Plugins directory does not exist.
45
+ if (!fs.existsSync(pluginsPath)) {
46
+ return emptyFn;
47
+ }
48
+
49
+ const files = fs.readdirSync(pluginsPath);
50
+
51
+ // No plugins to load.
52
+ if (files.length === 0) {
53
+ return emptyFn;
54
+ }
55
+
56
+ for (const file of files) {
57
+ const pluginFilePath = path.join(pluginsPath, file);
58
+
59
+ if (fs.statSync(pluginFilePath).isDirectory()) {
60
+ await loadFromDirectory(app, pluginFilePath, exitHooks);
61
+ continue;
62
+ }
63
+
64
+ // Not a JavaScript file.
65
+ if (!isCommonJS(file) && !isESModule(file)) {
66
+ continue;
67
+ }
68
+
69
+ await loadFromFile(app, pluginFilePath, exitHooks);
70
+ }
71
+
72
+ // Call all plugin "exit" functions at once and wait for them to finish
73
+ return () => Promise.all(exitHooks.map(exitFn => exitFn()));
74
+ }
75
+
76
+ async function loadFromDirectory(app, pluginDirectoryPath, exitHooks) {
77
+ const files = fs.readdirSync(pluginDirectoryPath);
78
+
79
+ // No plugins to load.
80
+ if (files.length === 0) {
81
+ return;
82
+ }
83
+
84
+ // Plugin is an npm package.
85
+ const packageJsonFilePath = path.join(pluginDirectoryPath, 'package.json');
86
+ if (fs.existsSync(packageJsonFilePath)) {
87
+ if (await loadFromPackage(app, packageJsonFilePath, exitHooks)) {
88
+ return;
89
+ }
90
+ }
91
+
92
+ // Plugin is a CommonJS module.
93
+ const cjsFilePath = path.join(pluginDirectoryPath, 'index.js');
94
+ if (fs.existsSync(cjsFilePath)) {
95
+ if (await loadFromFile(app, cjsFilePath, exitHooks)) {
96
+ return;
97
+ }
98
+ }
99
+
100
+ // Plugin is an ECMAScript module.
101
+ const esmFilePath = path.join(pluginDirectoryPath, 'index.mjs');
102
+ if (fs.existsSync(esmFilePath)) {
103
+ if (await loadFromFile(app, esmFilePath, exitHooks)) {
104
+ return;
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Loads and initializes a plugin from an npm package.
111
+ * @param {import('express').Express} app Express app
112
+ * @param {string} packageJsonPath Path to package.json file
113
+ * @param {Array<Function>} exitHooks Array of functions to be run on plugin exit. Will be pushed to if the plugin has
114
+ * an "exit" function.
115
+ * @returns {Promise<boolean>} Promise that resolves to true if plugin was loaded successfully
116
+ */
117
+ async function loadFromPackage(app, packageJsonPath, exitHooks) {
118
+ try {
119
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
120
+ if (packageJson.main) {
121
+ const pluginFilePath = path.join(path.dirname(packageJsonPath), packageJson.main);
122
+ return await loadFromFile(app, pluginFilePath, exitHooks);
123
+ }
124
+ } catch (error) {
125
+ console.error(`Failed to load plugin from ${packageJsonPath}: ${error}`);
126
+ }
127
+ return false;
128
+ }
129
+
130
+ /**
131
+ * Loads and initializes a plugin from a file.
132
+ * @param {import('express').Express} app Express app
133
+ * @param {string} pluginFilePath Path to plugin directory
134
+ * @param {Array.<Function>} exitHooks Array of functions to be run on plugin exit. Will be pushed to if the plugin has
135
+ * an "exit" function.
136
+ * @returns {Promise<boolean>} Promise that resolves to true if plugin was loaded successfully
137
+ */
138
+ async function loadFromFile(app, pluginFilePath, exitHooks) {
139
+ try {
140
+ const fileUrl = url.pathToFileURL(pluginFilePath).toString();
141
+ const plugin = await import(fileUrl);
142
+ console.log(`Initializing plugin from ${pluginFilePath}`);
143
+ return await initPlugin(app, plugin, exitHooks);
144
+ } catch (error) {
145
+ console.error(`Failed to load plugin from ${pluginFilePath}: ${error}`);
146
+ return false;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Check whether a plugin ID is valid (only lowercase alphanumeric, hyphens, and underscores).
152
+ * @param {string} id The plugin ID to check
153
+ * @returns {boolean} True if the plugin ID is valid.
154
+ */
155
+ function isValidPluginID(id) {
156
+ return /^[a-z0-9_-]+$/.test(id);
157
+ }
158
+
159
+ /**
160
+ * Initializes a plugin module.
161
+ * @param {import('express').Express} app Express app
162
+ * @param {any} plugin Plugin module
163
+ * @param {Array.<Function>} exitHooks Array of functions to be run on plugin exit. Will be pushed to if the plugin has
164
+ * an "exit" function.
165
+ * @returns {Promise<boolean>} Promise that resolves to true if plugin was initialized successfully
166
+ */
167
+ async function initPlugin(app, plugin, exitHooks) {
168
+ const info = plugin.info || plugin.default?.info;
169
+ if (typeof info !== 'object') {
170
+ console.error('Failed to load plugin module; plugin info not found');
171
+ return false;
172
+ }
173
+
174
+ // We don't currently use "name" or "description" but it would be nice to have a UI for listing server plugins, so
175
+ // require them now just to be safe
176
+ for (const field of ['id', 'name', 'description']) {
177
+ if (typeof info[field] !== 'string') {
178
+ console.error(`Failed to load plugin module; plugin info missing field '${field}'`);
179
+ return false;
180
+ }
181
+ }
182
+
183
+ const init = plugin.init || plugin.default?.init;
184
+ if (typeof init !== 'function') {
185
+ console.error('Failed to load plugin module; no init function');
186
+ return false;
187
+ }
188
+
189
+ const { id } = info;
190
+
191
+ if (!isValidPluginID(id)) {
192
+ console.error(`Failed to load plugin module; invalid plugin ID '${id}'`);
193
+ return false;
194
+ }
195
+
196
+ if (loadedPlugins.has(id)) {
197
+ console.error(`Failed to load plugin module; plugin ID '${id}' is already in use`);
198
+ return false;
199
+ }
200
+
201
+ // Allow the plugin to register API routes under /api/plugins/[plugin ID] via a router
202
+ const router = express.Router();
203
+
204
+ await init(router);
205
+
206
+ loadedPlugins.set(id, plugin);
207
+
208
+ // Add API routes to the app if the plugin registered any
209
+ if (router.stack.length > 0) {
210
+ app.use(`/api/plugins/${id}`, router);
211
+ }
212
+
213
+ const exit = plugin.exit || plugin.default?.exit;
214
+ if (typeof exit === 'function') {
215
+ exitHooks.push(exit);
216
+ }
217
+
218
+ return true;
219
+ }
220
+
221
+ module.exports = {
222
+ loadPlugins,
223
+ };
src/polyfill.js ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ if (!Array.prototype.findLastIndex) {
2
+ Array.prototype.findLastIndex = function (callback, thisArg) {
3
+ for (let i = this.length - 1; i >= 0; i--) {
4
+ if (callback.call(thisArg, this[i], i, this)) return i;
5
+ }
6
+ return -1;
7
+ };
8
+ }
9
+
10
+ module.exports = {};