duzhong commited on
Commit
87fd35f
·
verified ·
1 Parent(s): 00ccf3f

Upload folder using huggingface_hub

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 +250 -0
  2. src/byaf.js +449 -0
  3. src/character-card-parser.js +98 -0
  4. src/charx.js +399 -0
  5. src/command-line.js +363 -0
  6. src/config-init.js +253 -0
  7. src/constants.js +535 -0
  8. src/electron/Start.bat +6 -0
  9. src/electron/index.js +62 -0
  10. src/electron/package-lock.json +802 -0
  11. src/electron/package.json +16 -0
  12. src/electron/start.sh +11 -0
  13. src/endpoints/anthropic.js +66 -0
  14. src/endpoints/assets.js +370 -0
  15. src/endpoints/avatars.js +65 -0
  16. src/endpoints/azure.js +88 -0
  17. src/endpoints/backends/chat-completions.js +0 -0
  18. src/endpoints/backends/kobold.js +281 -0
  19. src/endpoints/backends/text-completions.js +643 -0
  20. src/endpoints/backgrounds.js +76 -0
  21. src/endpoints/backups.js +75 -0
  22. src/endpoints/caption.js +29 -0
  23. src/endpoints/characters.js +1547 -0
  24. src/endpoints/chats.js +1020 -0
  25. src/endpoints/classify.js +55 -0
  26. src/endpoints/data-maid.js +816 -0
  27. src/endpoints/extensions.js +455 -0
  28. src/endpoints/files.js +101 -0
  29. src/endpoints/google.js +641 -0
  30. src/endpoints/groups.js +235 -0
  31. src/endpoints/horde.js +411 -0
  32. src/endpoints/images.js +155 -0
  33. src/endpoints/minimax.js +230 -0
  34. src/endpoints/moving-ui.js +17 -0
  35. src/endpoints/novelai.js +484 -0
  36. src/endpoints/openai.js +799 -0
  37. src/endpoints/openrouter.js +172 -0
  38. src/endpoints/presets.js +103 -0
  39. src/endpoints/quick-replies.js +32 -0
  40. src/endpoints/search.js +455 -0
  41. src/endpoints/secrets.js +635 -0
  42. src/endpoints/secure-generate.js +68 -0
  43. src/endpoints/settings.js +371 -0
  44. src/endpoints/speech.js +401 -0
  45. src/endpoints/sprites.js +290 -0
  46. src/endpoints/stable-diffusion.js +1822 -0
  47. src/endpoints/stats.js +469 -0
  48. src/endpoints/themes.js +38 -0
  49. src/endpoints/thumbnails.js +252 -0
  50. src/endpoints/tokenizers.js +1128 -0
src/additional-headers.js ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { TEXTGEN_TYPES, OPENROUTER_HEADERS, FEATHERLESS_HEADERS } from './constants.js';
2
+ import { SECRET_KEYS, readSecret } from './endpoints/secrets.js';
3
+ import { getConfigValue } from './util.js';
4
+
5
+ /**
6
+ * Gets the headers for the Mancer API.
7
+ * @param {import('./users.js').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.js').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.js').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.js').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.js').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.js').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.js').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.js').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.js').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.js').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.js').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.js').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.js').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
+ /**
176
+ * Gets the headers for the Generic text completion API.
177
+ * @param {import('./users.js').UserDirectoryList} directories
178
+ * @returns {object} Headers for the request
179
+ */
180
+ function getGenericHeaders(directories) {
181
+ const apiKey = readSecret(directories, SECRET_KEYS.GENERIC);
182
+
183
+ return apiKey ? ({
184
+ 'Authorization': `Bearer ${apiKey}`,
185
+ }) : {};
186
+ }
187
+
188
+ export function getOverrideHeaders(urlHost) {
189
+ const requestOverrides = getConfigValue('requestOverrides', []);
190
+ const overrideHeaders = requestOverrides?.find((e) => e.hosts?.includes(urlHost))?.headers;
191
+ if (overrideHeaders && urlHost) {
192
+ return overrideHeaders;
193
+ } else {
194
+ return {};
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Sets additional headers for the request.
200
+ * @param {import('express').Request} request Original request body
201
+ * @param {object} args New request arguments
202
+ * @param {string|null} server API server for new request
203
+ */
204
+ export function setAdditionalHeaders(request, args, server) {
205
+ setAdditionalHeadersByType(args.headers, request.body.api_type, server, request.user.directories);
206
+ }
207
+
208
+ /**
209
+ *
210
+ * @param {object} requestHeaders Request headers
211
+ * @param {string} type API type
212
+ * @param {string|null} server API server for new request
213
+ * @param {import('./users.js').UserDirectoryList} directories User directories
214
+ */
215
+ export function setAdditionalHeadersByType(requestHeaders, type, server, directories) {
216
+ const headerGetters = {
217
+ [TEXTGEN_TYPES.MANCER]: getMancerHeaders,
218
+ [TEXTGEN_TYPES.VLLM]: getVllmHeaders,
219
+ [TEXTGEN_TYPES.APHRODITE]: getAphroditeHeaders,
220
+ [TEXTGEN_TYPES.TABBY]: getTabbyHeaders,
221
+ [TEXTGEN_TYPES.TOGETHERAI]: getTogetherAIHeaders,
222
+ [TEXTGEN_TYPES.OOBA]: getOobaHeaders,
223
+ [TEXTGEN_TYPES.INFERMATICAI]: getInfermaticAIHeaders,
224
+ [TEXTGEN_TYPES.DREAMGEN]: getDreamGenHeaders,
225
+ [TEXTGEN_TYPES.OPENROUTER]: getOpenRouterHeaders,
226
+ [TEXTGEN_TYPES.KOBOLDCPP]: getKoboldCppHeaders,
227
+ [TEXTGEN_TYPES.LLAMACPP]: getLlamaCppHeaders,
228
+ [TEXTGEN_TYPES.FEATHERLESS]: getFeatherlessHeaders,
229
+ [TEXTGEN_TYPES.HUGGINGFACE]: getHuggingFaceHeaders,
230
+ [TEXTGEN_TYPES.GENERIC]: getGenericHeaders,
231
+ };
232
+
233
+ const getHeaders = headerGetters[type];
234
+ const headers = getHeaders ? getHeaders(directories) : {};
235
+
236
+ if (typeof server === 'string' && server.length > 0) {
237
+ try {
238
+ const url = new URL(server);
239
+ const overrideHeaders = getOverrideHeaders(url.host);
240
+
241
+ if (overrideHeaders && Object.keys(overrideHeaders).length > 0) {
242
+ Object.assign(headers, overrideHeaders);
243
+ }
244
+ } catch {
245
+ // Do nothing
246
+ }
247
+ }
248
+
249
+ Object.assign(requestHeaders, headers);
250
+ }
src/byaf.js ADDED
@@ -0,0 +1,449 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { promises as fsPromises } from 'node:fs';
2
+ import path from 'node:path';
3
+ import urlJoin from 'url-join';
4
+ import { DEFAULT_AVATAR_PATH } from './constants.js';
5
+ import { extractFileFromZipBuffer } from './util.js';
6
+
7
+ /**
8
+ * A parser for BYAF (Backyard Archive Format) files.
9
+ */
10
+ export class ByafParser {
11
+ /**
12
+ * @param {ArrayBufferLike} data BYAF ZIP buffer
13
+ */
14
+ #data;
15
+
16
+ /**
17
+ * Creates an instance of ByafParser.
18
+ * @param {ArrayBufferLike} data BYAF ZIP buffer
19
+ */
20
+ constructor(data) {
21
+ this.#data = data;
22
+ }
23
+
24
+ /**
25
+ * Replaces known macros in a string.
26
+ * @param {string} [str] String to process
27
+ * @returns {string} String with macros replaced
28
+ * @private
29
+ */
30
+ static replaceMacros(str) {
31
+ return String(str || '')
32
+ .replace(/#{user}:/gi, '{{user}}:')
33
+ .replace(/#{character}:/gi, '{{char}}:')
34
+ .replace(/{character}(?!})/gi, '{{char}}')
35
+ .replace(/{user}(?!})/gi, '{{user}}');
36
+ }
37
+
38
+ /**
39
+ * Formats example messages for a character.
40
+ * @param {ByafExampleMessage[]} [examples] Array of example objects
41
+ * @returns {string} Formatted example messages
42
+ * @private
43
+ */
44
+ static formatExampleMessages(examples) {
45
+ if (!Array.isArray(examples)) {
46
+ return '';
47
+ }
48
+
49
+ let formattedExamples = '';
50
+
51
+ examples.forEach((example) => {
52
+ if (!example?.text) {
53
+ return;
54
+ }
55
+ formattedExamples += `<START>\n${ByafParser.replaceMacros(example.text)}\n`;
56
+ });
57
+
58
+ return formattedExamples.trimEnd();
59
+ }
60
+
61
+ /**
62
+ * Formats alternate greetings for a character.
63
+ * @param {Partial<ByafScenario>[]} [scenarios] Array of scenario objects
64
+ * @returns {string[]} Formatted alternate greetings
65
+ * @private
66
+ */
67
+ formatAlternateGreetings(scenarios) {
68
+ if (!Array.isArray(scenarios)) {
69
+ return [];
70
+ }
71
+
72
+ // Skip one because it goes into 'first_mes'
73
+ if (scenarios.length <= 1) {
74
+ return [];
75
+ }
76
+ const greetings = new Set();
77
+ const firstScenarioFirstMessage = scenarios?.[0]?.firstMessages?.[0]?.text;
78
+ for (const scenario of scenarios.slice(1).filter(s => Array.isArray(s.firstMessages) && s.firstMessages.length > 0)) {
79
+ // As per the BYAF spec, "firstMessages" array MUST contain AT MOST one message.
80
+ // So we only consider the first one if it exists.
81
+ const firstMessage = scenario?.firstMessages?.[0];
82
+ if (firstMessage?.text && firstMessage.text !== firstScenarioFirstMessage) {
83
+ greetings.add(ByafParser.replaceMacros(firstMessage.text));
84
+ }
85
+ }
86
+ return Array.from(greetings);
87
+ }
88
+
89
+ /**
90
+ * Converts character book items to a structured format.
91
+ * @param {ByafLoreItem[]} items Array of key-value pairs
92
+ * @returns {CharacterBook|undefined} Converted character book or undefined if invalid
93
+ * @private
94
+ */
95
+ convertCharacterBook(items) {
96
+ if (!Array.isArray(items) || items.length === 0) {
97
+ return undefined;
98
+ }
99
+
100
+ /** @type {CharacterBook} */
101
+ const book = {
102
+ entries: [],
103
+ extensions: {},
104
+ };
105
+
106
+ items.forEach((item, index) => {
107
+ if (!item) {
108
+ return;
109
+ }
110
+ book.entries.push({
111
+ keys: ByafParser.replaceMacros(item?.key).split(',').map(key => key.trim()).filter(Boolean),
112
+ content: ByafParser.replaceMacros(item?.value),
113
+ extensions: {},
114
+ enabled: true,
115
+ insertion_order: index,
116
+ });
117
+ });
118
+
119
+ return book;
120
+ }
121
+
122
+ /**
123
+ * Extracts a character object from BYAF buffer.
124
+ * @param {ByafManifest} manifest BYAF manifest
125
+ * @returns {Promise<{character:ByafCharacter,characterPath:string}>} Character object
126
+ * @private
127
+ */
128
+ async getCharacterFromManifest(manifest) {
129
+ const charactersArray = manifest?.characters;
130
+
131
+ if (!Array.isArray(charactersArray)) {
132
+ throw new Error('Invalid BYAF file: missing characters array');
133
+ }
134
+
135
+ if (charactersArray.length === 0) {
136
+ throw new Error('Invalid BYAF file: characters array is empty');
137
+ }
138
+
139
+ if (charactersArray.length > 1) {
140
+ console.warn('Warning: BYAF manifest contains more than one character, only the first one will be imported');
141
+ }
142
+
143
+ const characterPath = charactersArray[0];
144
+ if (!characterPath) {
145
+ throw new Error('Invalid BYAF file: missing character path');
146
+ }
147
+
148
+ const characterBuffer = await extractFileFromZipBuffer(this.#data, characterPath);
149
+ if (!characterBuffer) {
150
+ throw new Error('Invalid BYAF file: failed to extract character JSON');
151
+ }
152
+
153
+ try {
154
+ const character = JSON.parse(characterBuffer.toString());
155
+ return { character, characterPath };
156
+ } catch (error) {
157
+ console.error('Failed to parse character JSON from BYAF:', error);
158
+ throw new Error('Invalid BYAF file: character is not a valid JSON');
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Extracts all scenario objects from BYAF buffer.
164
+ * @param {ByafManifest} manifest BYAF manifest
165
+ * @returns {Promise<Partial<ByafScenario>[]>} Scenarios array
166
+ * @private
167
+ */
168
+ async getScenariosFromManifest(manifest) {
169
+ const scenariosArray = manifest?.scenarios;
170
+
171
+ if (!Array.isArray(scenariosArray) || scenariosArray.length === 0) {
172
+ console.warn('Warning: BYAF manifest contains no scenarios');
173
+ return [{}];
174
+ }
175
+
176
+ const scenarios = [];
177
+
178
+ for (const scenarioPath of scenariosArray) {
179
+ const scenarioBuffer = await extractFileFromZipBuffer(this.#data, scenarioPath);
180
+ if (!scenarioBuffer) {
181
+ console.warn('Warning: failed to extract BYAF scenario JSON');
182
+ }
183
+ if (scenarioBuffer) {
184
+ try {
185
+ scenarios.push(JSON.parse(scenarioBuffer.toString()));
186
+ } catch (error) {
187
+ console.warn('Warning: BYAF scenario is not a valid JSON', error);
188
+ }
189
+ }
190
+ }
191
+
192
+ if (scenarios.length === 0) {
193
+ console.warn('Warning: BYAF manifest contains no valid scenarios');
194
+ return [{}];
195
+ }
196
+
197
+ return scenarios;
198
+ }
199
+
200
+ /**
201
+ * Extracts all character icon images from BYAF buffer.
202
+ * @param {ByafCharacter} character Character object
203
+ * @param {string} characterPath Path to the character in the BYAF manifest
204
+ * @return {Promise<{filename: string, image: Buffer, label: string}[]>} Image buffer
205
+ * @private
206
+ */
207
+ async getCharacterImages(character, characterPath) {
208
+ const defaultAvatarBuffer = await fsPromises.readFile(DEFAULT_AVATAR_PATH);
209
+ const characterImages = character?.images;
210
+
211
+ if (!Array.isArray(characterImages) || characterImages.length === 0) {
212
+ console.warn('Warning: BYAF character has no images');
213
+ return [{ filename: '', image: defaultAvatarBuffer, label: '' }];
214
+ }
215
+
216
+ const imageBuffers = [];
217
+ for (const image of characterImages) {
218
+ const imagePath = image?.path;
219
+ if (!imagePath) {
220
+ console.warn('Warning: BYAF character image path is empty');
221
+ continue;
222
+ }
223
+
224
+ const fullImagePath = urlJoin(path.dirname(characterPath), imagePath);
225
+ const imageBuffer = await extractFileFromZipBuffer(this.#data, fullImagePath);
226
+ if (!imageBuffer) {
227
+ console.warn('Warning: failed to extract BYAF character image');
228
+ continue;
229
+ }
230
+
231
+ imageBuffers.push({ filename: path.basename(imagePath), image: imageBuffer, label: image?.label || '' });
232
+ }
233
+ if (imageBuffers.length === 0) {
234
+ console.warn('Warning: BYAF character has no valid images');
235
+ return [{ filename: '', image: defaultAvatarBuffer, label: '' }];
236
+ }
237
+ return imageBuffers;
238
+ }
239
+
240
+ /**
241
+ * Formats BYAF data as a character card.
242
+ * @param {ByafManifest} manifest BYAF manifest
243
+ * @param {ByafCharacter} character Character object
244
+ * @param {Partial<ByafScenario>[]} scenarios Scenarios array
245
+ * @return {TavernCardV2} Character card object
246
+ * @private
247
+ */
248
+ getCharacterCard(manifest, character, scenarios) {
249
+ return {
250
+ spec: 'chara_card_v2',
251
+ spec_version: '2.0',
252
+ data: {
253
+ name: character?.name || character?.displayName || '',
254
+ description: ByafParser.replaceMacros(character?.persona),
255
+ personality: '',
256
+ scenario: ByafParser.replaceMacros(scenarios[0]?.narrative),
257
+ first_mes: ByafParser.replaceMacros(scenarios[0]?.firstMessages?.[0]?.text),
258
+ mes_example: ByafParser.formatExampleMessages(scenarios[0]?.exampleMessages),
259
+ creator_notes: manifest?.author?.backyardURL || '', // To preserve the link to the author from BYAF manifest, this is a good place.
260
+ system_prompt: ByafParser.replaceMacros(scenarios[0]?.formattingInstructions),
261
+ post_history_instructions: '',
262
+ alternate_greetings: this.formatAlternateGreetings(scenarios),
263
+ character_book: this.convertCharacterBook(character?.loreItems),
264
+ tags: character?.isNSFW ? ['nsfw'] : [], // Since there are no tags in BYAF spec, we can use this to preserve the isNSFW flag.
265
+ creator: manifest?.author?.name || '',
266
+ character_version: '',
267
+ extensions: { ...(character?.displayName && { 'display_name': character?.displayName }) }, // Preserve display name unmodified using extensions. "display_name" is not used by TavernIntern currently.
268
+ },
269
+ // @ts-ignore Non-standard spec extension
270
+ create_date: new Date().toISOString(),
271
+ };
272
+ }
273
+ /**
274
+ * Gets chat backgrounds from BYAF data mapped to their respective scenarios.
275
+ * @param {ByafCharacter} character Character object
276
+ * @param {Partial<ByafScenario>[]} scenarios Scenarios array
277
+ * @returns {Promise<Array<ByafChatBackground>>} Chat backgrounds
278
+ * @private
279
+ */
280
+ async getChatBackgrounds(character, scenarios) {
281
+ // Implementation for extracting chat backgrounds from BYAF data
282
+ const backgrounds = [];
283
+ let i = 1;
284
+ for (const scenario of scenarios) {
285
+ const bgImagePath = scenario?.backgroundImage;
286
+ if (bgImagePath) {
287
+ const data = await extractFileFromZipBuffer(this.#data, bgImagePath);
288
+ if (data) {
289
+ const existingIndex = backgrounds.findIndex(bg => bg.data.compare(data) === 0);
290
+ if (existingIndex !== -1) {
291
+ backgrounds[existingIndex].paths.push(bgImagePath);
292
+ continue; // Skip adding a new background since it already exists
293
+ }
294
+ backgrounds.push({
295
+ name: `${character?.name} bg ${i++}` || '',
296
+ data: data,
297
+ paths: [bgImagePath],
298
+ });
299
+ }
300
+ }
301
+ }
302
+ return backgrounds;
303
+ }
304
+
305
+ /**
306
+ * Gets the manifest from the BYAF data.
307
+ * @returns {Promise<ByafManifest>} Parsed manifest
308
+ * @private
309
+ */
310
+ async getManifest() {
311
+ const manifestBuffer = await extractFileFromZipBuffer(this.#data, 'manifest.json');
312
+ if (!manifestBuffer) {
313
+ throw new Error('Failed to extract manifest.json from BYAF file');
314
+ }
315
+
316
+ const manifest = JSON.parse(manifestBuffer.toString());
317
+ if (!manifest || typeof manifest !== 'object') {
318
+ throw new Error('Invalid BYAF manifest');
319
+ }
320
+
321
+ return manifest;
322
+ }
323
+
324
+ /**
325
+ * Imports a chat from BYAF format.
326
+ * @param {Partial<ByafScenario>} scenario Scenario object
327
+ * @param {string} userName User name
328
+ * @param {string} characterName Character name
329
+ * @param {Array<ByafChatBackground>} chatBackgrounds Chat backgrounds
330
+ * @returns {string} Chat data
331
+ */
332
+ static getChatFromScenario(scenario, userName, characterName, chatBackgrounds) {
333
+ const chatStartDate = scenario?.messages?.length == 0 ? new Date().toISOString() : scenario?.messages?.filter(m => 'createdAt' in m)[0].createdAt;
334
+ const chatBackground = chatBackgrounds.find(bg => bg.paths.includes(scenario?.backgroundImage || ''))?.name || '';
335
+ /** @type {object[]} */
336
+ const chat = [{
337
+ user_name: 'unused',
338
+ character_name: 'unused',
339
+ chat_metadata: {
340
+ scenario: scenario?.narrative ?? '',
341
+ mes_example: ByafParser.formatExampleMessages(scenario?.exampleMessages),
342
+ system_prompt: ByafParser.replaceMacros(scenario?.formattingInstructions),
343
+ mes_examples_optional: scenario?.canDeleteExampleMessages ?? false,
344
+ byaf_model_settings: {
345
+ model: scenario?.model ?? '',
346
+ temperature: scenario?.temperature ?? 1.2,
347
+ top_k: scenario?.topK ?? 40,
348
+ top_p: scenario?.topP ?? 0.9,
349
+ min_p: scenario?.minP ?? 0.1,
350
+ min_p_enabled: scenario?.minPEnabled ?? true,
351
+ repeat_penalty: scenario?.repeatPenalty ?? 1.05,
352
+ repeat_penalty_tokens: scenario?.repeatLastN ?? 256,
353
+ by_prompt_template: scenario?.promptTemplate ?? 'general',
354
+ grammar: scenario?.grammar ?? null,
355
+ },
356
+ chat_backgrounds: chatBackground ? [chatBackground] : [],
357
+ custom_background: chatBackground ? `url("${encodeURI(chatBackground)}")` : '',
358
+ },
359
+ }];
360
+ // Add the first message IF it exists.
361
+ if (scenario?.firstMessages?.length && scenario?.firstMessages?.length > 0 && scenario?.firstMessages?.[0]?.text) {
362
+ chat.push({
363
+ name: characterName,
364
+ is_user: false,
365
+ send_date: chatStartDate,
366
+ mes: scenario?.firstMessages?.[0]?.text || '',
367
+ });
368
+ }
369
+
370
+ const sortByTimestamp = (newest, curr) => {
371
+ const aTime = new Date(newest.activeTimestamp);
372
+ const bTime = new Date(curr.activeTimestamp);
373
+ return aTime >= bTime ? newest : curr;
374
+ };
375
+
376
+ const getNewestAiMessage = (message) => {
377
+ return message.outputs.reduce(sortByTimestamp);
378
+ };
379
+ const getSwipesForAiMessage = (aiMessage) => {
380
+ return aiMessage.outputs.map(output => output.text);
381
+ };
382
+
383
+ const userMessages = scenario?.messages?.filter(msg => msg.type === 'human');
384
+ const characterMessages = scenario?.messages?.filter(msg => msg.type === 'ai');
385
+ /**
386
+ * Reorders messages by interleaving user and character messages so that they are in correct chronological order.
387
+ * This is only needed to import old chats from Backyard AI that were incorrectly imported by an earlier version
388
+ * that completely messed up the order of messages. Backyard AI Windows frontend never supported creation of chats
389
+ * with which were ordered like this in the first place, so for most users this is desired functionality.
390
+ */
391
+ if (userMessages && characterMessages && userMessages.length === characterMessages.length) { // Only do the reordering if there are equal numbers of user and character messages, otherwise just import in existing order, because it's probably correct already.
392
+ for (let i = 0; i < userMessages.length; i++) {
393
+ chat.push({
394
+ name: userName,
395
+ is_user: true,
396
+ send_date: Number(userMessages[i]?.createdAt),
397
+ mes: userMessages[i]?.text,
398
+ });
399
+ const aiMessage = getNewestAiMessage(characterMessages[i]);
400
+ const aiSwipes = getSwipesForAiMessage(characterMessages[i]);
401
+ chat.push({
402
+ name: characterName,
403
+ is_user: false,
404
+ send_date: Number(aiMessage.createdAt),
405
+ mes: aiMessage.text,
406
+ swipes: aiSwipes,
407
+ swipe_id: aiSwipes.findIndex(s => s === aiMessage.text),
408
+ });
409
+ }
410
+ } else if (scenario?.messages) {
411
+ for (const message of scenario.messages) {
412
+ const isUser = message.type === 'human';
413
+ const aiMessage = !isUser ? getNewestAiMessage(message) : null;
414
+ const chatMessage = {
415
+ name: isUser ? userName : characterName,
416
+ is_user: isUser,
417
+ send_date: Number(isUser ? message.createdAt : aiMessage.createdAt),
418
+ mes: isUser ? message.text : aiMessage.text,
419
+ };
420
+ if (!isUser) {
421
+ const aiSwipes = getSwipesForAiMessage(message);
422
+ chatMessage.swipes = aiSwipes;
423
+ chatMessage.swipe_id = aiSwipes.findIndex(s => s === aiMessage.text);
424
+ }
425
+ chat.push(chatMessage);
426
+ }
427
+ } else {
428
+ console.warn('Warning: BYAF scenario contained no messages property.');
429
+ }
430
+
431
+ return chat.map(obj => JSON.stringify(obj)).join('\n');
432
+ }
433
+
434
+ /**
435
+ * Parses the BYAF data.
436
+ * @return {Promise<ByafParseResult>} Parsed character card and image buffer
437
+ */
438
+ async parse() {
439
+ const manifest = await this.getManifest();
440
+ const { character, characterPath } = await this.getCharacterFromManifest(manifest);
441
+ const scenarios = await this.getScenariosFromManifest(manifest);
442
+ const images = await this.getCharacterImages(character, characterPath);
443
+ const card = this.getCharacterCard(manifest, character, scenarios);
444
+ const chatBackgrounds = await this.getChatBackgrounds(character, scenarios);
445
+ return { card, images, scenarios, chatBackgrounds, character };
446
+ }
447
+ }
448
+
449
+ export default ByafParser;
src/character-card-parser.js ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs';
2
+ import { Buffer } from 'node:buffer';
3
+
4
+ import encode from './png/encode.js';
5
+ import extract from 'png-chunks-extract';
6
+ import PNGtext from 'png-chunk-text';
7
+
8
+ /**
9
+ * Writes Character metadata to a PNG image buffer.
10
+ * Writes only 'chara', 'ccv3' is not supported and removed not to create a mismatch.
11
+ * @param {Buffer} image PNG image buffer
12
+ * @param {string} data Character data to write
13
+ * @returns {Buffer} PNG image buffer with metadata
14
+ */
15
+ export const write = (image, data) => {
16
+ const chunks = extract(new Uint8Array(image));
17
+ const tEXtChunks = chunks.filter(chunk => chunk.name === 'tEXt');
18
+
19
+ // Remove existing tEXt chunks
20
+ for (const tEXtChunk of tEXtChunks) {
21
+ const data = PNGtext.decode(tEXtChunk.data);
22
+ if (data.keyword.toLowerCase() === 'chara' || data.keyword.toLowerCase() === 'ccv3') {
23
+ chunks.splice(chunks.indexOf(tEXtChunk), 1);
24
+ }
25
+ }
26
+
27
+ // Add new v2 chunk before the IEND chunk
28
+ const base64EncodedData = Buffer.from(data, 'utf8').toString('base64');
29
+ chunks.splice(-1, 0, PNGtext.encode('chara', base64EncodedData));
30
+
31
+ // Try adding v3 chunk before the IEND chunk
32
+ try {
33
+ //change v2 format to v3
34
+ const v3Data = JSON.parse(data);
35
+ v3Data.spec = 'chara_card_v3';
36
+ v3Data.spec_version = '3.0';
37
+
38
+ const base64EncodedData = Buffer.from(JSON.stringify(v3Data), 'utf8').toString('base64');
39
+ chunks.splice(-1, 0, PNGtext.encode('ccv3', base64EncodedData));
40
+ } catch (error) {
41
+ // Ignore errors when adding v3 chunk
42
+ }
43
+
44
+ const newBuffer = Buffer.from(encode(chunks));
45
+ return newBuffer;
46
+ };
47
+
48
+ /**
49
+ * Reads Character metadata from a PNG image buffer.
50
+ * Supports both V2 (chara) and V3 (ccv3). V3 (ccv3) takes precedence.
51
+ * @param {Buffer} image PNG image buffer
52
+ * @returns {string} Character data
53
+ */
54
+ export const read = (image) => {
55
+ const chunks = extract(new Uint8Array(image));
56
+
57
+ const textChunks = chunks.filter((chunk) => chunk.name === 'tEXt').map((chunk) => PNGtext.decode(chunk.data));
58
+
59
+ if (textChunks.length === 0) {
60
+ console.error('PNG metadata does not contain any text chunks.');
61
+ throw new Error('No PNG metadata.');
62
+ }
63
+
64
+ const ccv3Index = textChunks.findIndex((chunk) => chunk.keyword.toLowerCase() === 'ccv3');
65
+
66
+ if (ccv3Index > -1) {
67
+ return Buffer.from(textChunks[ccv3Index].text, 'base64').toString('utf8');
68
+ }
69
+
70
+ const charaIndex = textChunks.findIndex((chunk) => chunk.keyword.toLowerCase() === 'chara');
71
+
72
+ if (charaIndex > -1) {
73
+ return Buffer.from(textChunks[charaIndex].text, 'base64').toString('utf8');
74
+ }
75
+
76
+ console.error('PNG metadata does not contain any character data.');
77
+ throw new Error('No PNG metadata.');
78
+ };
79
+
80
+ /**
81
+ * Parses a card image and returns the character metadata.
82
+ * @param {string} cardUrl Path to the card image
83
+ * @param {string} format File format
84
+ * @returns {Promise<string>} Character data
85
+ */
86
+ export const parse = async (cardUrl, format) => {
87
+ let fileFormat = format === undefined ? 'png' : format;
88
+
89
+ switch (fileFormat) {
90
+ case 'png': {
91
+ const buffer = fs.readFileSync(cardUrl);
92
+ return read(buffer);
93
+ }
94
+ }
95
+
96
+ throw new Error('Unsupported format');
97
+ };
98
+
src/charx.js ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import _ from 'lodash';
4
+ import sanitize from 'sanitize-filename';
5
+ import { sync as writeFileAtomicSync } from 'write-file-atomic';
6
+ import { extractFileFromZipBuffer, extractFilesFromZipBuffer, normalizeZipEntryPath, ensureDirectory } from './util.js';
7
+ import { DEFAULT_AVATAR_PATH } from './constants.js';
8
+
9
+ // 'embeded://' is intentional - RisuAI exports use this misspelling
10
+ const CHARX_EMBEDDED_URI_PREFIXES = ['embeded://', 'embedded://', '__asset:'];
11
+ const CHARX_IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'webp', 'gif', 'apng', 'avif', 'bmp', 'jfif']);
12
+ const CHARX_SPRITE_TYPES = new Set(['emotion', 'expression']);
13
+ const CHARX_BACKGROUND_TYPES = new Set(['background']);
14
+
15
+ // ZIP local file header signature: PK\x03\x04
16
+ const ZIP_SIGNATURE = Buffer.from([0x50, 0x4B, 0x03, 0x04]);
17
+
18
+ /**
19
+ * Find ZIP data start in buffer (handles SFX/self-extracting archives).
20
+ * @param {Buffer} buffer
21
+ * @returns {Buffer} Buffer starting at ZIP signature, or original if not found
22
+ */
23
+ function findZipStart(buffer) {
24
+ const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
25
+ const index = buf.indexOf(ZIP_SIGNATURE);
26
+ if (index > 0) {
27
+ return buf.slice(index);
28
+ }
29
+ return buf;
30
+ }
31
+
32
+ /**
33
+ * @typedef {Object} CharXAsset
34
+ * @property {string} type - Asset type (emotion, expression, background, etc.)
35
+ * @property {string} name - Asset name from metadata
36
+ * @property {string} ext - File extension (lowercase, no dot)
37
+ * @property {string} zipPath - Normalized path within the ZIP archive
38
+ * @property {number} order - Original index in assets array
39
+ * @property {string} [storageCategory] - 'sprite' | 'background' | 'misc' (set by mapCharXAssetsForStorage)
40
+ * @property {string} [baseName] - Normalized filename base (set by mapCharXAssetsForStorage)
41
+ */
42
+
43
+ /**
44
+ * @typedef {Object} CharXParseResult
45
+ * @property {Object} card - Parsed card.json (CCv2 or CCv3 spec)
46
+ * @property {string|Buffer} avatar - Avatar image buffer or DEFAULT_AVATAR_PATH
47
+ * @property {CharXAsset[]} auxiliaryAssets - Assets mapped for storage
48
+ * @property {Map<string, Buffer>} extractedBuffers - Map of zipPath to extracted buffer
49
+ */
50
+
51
+ export class CharXParser {
52
+ #data;
53
+
54
+ /**
55
+ * @param {ArrayBuffer|Buffer} data
56
+ */
57
+ constructor(data) {
58
+ // Handle SFX (self-extracting) ZIP archives by finding the actual ZIP start
59
+ this.#data = findZipStart(Buffer.isBuffer(data) ? data : Buffer.from(data));
60
+ }
61
+
62
+ /**
63
+ * Parse the CharX archive and extract card data and assets.
64
+ * @returns {Promise<CharXParseResult>}
65
+ */
66
+ async parse() {
67
+ console.info('Importing from CharX');
68
+ const cardBuffer = await extractFileFromZipBuffer(this.#data, 'card.json');
69
+
70
+ if (!cardBuffer) {
71
+ throw new Error('Failed to extract card.json from CharX file');
72
+ }
73
+
74
+ const card = JSON.parse(cardBuffer.toString());
75
+
76
+ if (card.spec === undefined) {
77
+ throw new Error('Invalid CharX card file: missing spec field');
78
+ }
79
+
80
+ const embeddedAssets = this.collectCharXAssets(card);
81
+ const iconAsset = this.pickCharXIconAsset(embeddedAssets);
82
+ const auxiliaryAssets = this.mapCharXAssetsForStorage(embeddedAssets);
83
+
84
+ const archivePaths = new Set();
85
+
86
+ if (iconAsset?.zipPath) {
87
+ archivePaths.add(iconAsset.zipPath);
88
+ }
89
+ for (const asset of auxiliaryAssets) {
90
+ if (asset?.zipPath) {
91
+ archivePaths.add(asset.zipPath);
92
+ }
93
+ }
94
+
95
+ let extractedBuffers = new Map();
96
+ if (archivePaths.size > 0) {
97
+ extractedBuffers = await extractFilesFromZipBuffer(this.#data, [...archivePaths]);
98
+ }
99
+
100
+ /** @type {string|Buffer} */
101
+ let avatar = DEFAULT_AVATAR_PATH;
102
+ if (iconAsset?.zipPath) {
103
+ const iconBuffer = extractedBuffers.get(iconAsset.zipPath);
104
+ if (iconBuffer) {
105
+ avatar = iconBuffer;
106
+ }
107
+ }
108
+
109
+ return { card, avatar, auxiliaryAssets, extractedBuffers };
110
+ }
111
+
112
+ getEmbeddedZipPathFromUri(uri) {
113
+ if (typeof uri !== 'string') {
114
+ return null;
115
+ }
116
+
117
+ const trimmed = uri.trim();
118
+ if (!trimmed) {
119
+ return null;
120
+ }
121
+
122
+ const lower = trimmed.toLowerCase();
123
+ for (const prefix of CHARX_EMBEDDED_URI_PREFIXES) {
124
+ if (lower.startsWith(prefix)) {
125
+ const rawPath = trimmed.slice(prefix.length);
126
+ return normalizeZipEntryPath(rawPath);
127
+ }
128
+ }
129
+
130
+ return null;
131
+ }
132
+
133
+ /**
134
+ * Normalize extension string: lowercase, strip leading dot.
135
+ * @param {string} ext
136
+ * @returns {string}
137
+ */
138
+ normalizeExtString(ext) {
139
+ if (typeof ext !== 'string') return '';
140
+ return ext.trim().toLowerCase().replace(/^\./, '');
141
+ }
142
+
143
+ /**
144
+ * Strip trailing image extension from asset name if present.
145
+ * Handles cases like "image.png" with ext "png" → "image" (avoids "image.png.png")
146
+ * @param {string} name - Asset name that may contain extension
147
+ * @param {string} expectedExt - The expected extension (lowercase, no dot)
148
+ * @returns {string} Name with trailing extension stripped if it matched
149
+ */
150
+ stripTrailingImageExtension(name, expectedExt) {
151
+ if (!name || !expectedExt) return name;
152
+ const lower = name.toLowerCase();
153
+ // Check if name ends with the expected extension
154
+ if (lower.endsWith(`.${expectedExt}`)) {
155
+ return name.slice(0, -(expectedExt.length + 1));
156
+ }
157
+ // Also check for any known image extension at the end
158
+ for (const ext of CHARX_IMAGE_EXTENSIONS) {
159
+ if (lower.endsWith(`.${ext}`)) {
160
+ return name.slice(0, -(ext.length + 1));
161
+ }
162
+ }
163
+ return name;
164
+ }
165
+
166
+ deriveCharXAssetExtension(assetExt, zipPath) {
167
+ const metaExt = this.normalizeExtString(assetExt);
168
+ const pathExt = this.normalizeExtString(path.extname(zipPath || ''));
169
+ return metaExt || pathExt;
170
+ }
171
+
172
+ collectCharXAssets(card) {
173
+ const assets = _.get(card, 'data.assets');
174
+ if (!Array.isArray(assets)) {
175
+ return [];
176
+ }
177
+
178
+ return assets.map((asset, index) => {
179
+ if (!asset) {
180
+ return null;
181
+ }
182
+
183
+ const zipPath = this.getEmbeddedZipPathFromUri(asset.uri);
184
+ if (!zipPath) {
185
+ return null;
186
+ }
187
+
188
+ const ext = this.deriveCharXAssetExtension(asset.ext, zipPath);
189
+ const type = typeof asset.type === 'string' ? asset.type.toLowerCase() : '';
190
+ const name = typeof asset.name === 'string' ? asset.name : '';
191
+
192
+ return {
193
+ type,
194
+ name,
195
+ ext,
196
+ zipPath,
197
+ order: index,
198
+ };
199
+ }).filter(Boolean);
200
+ }
201
+
202
+ pickCharXIconAsset(assets) {
203
+ const iconAssets = assets.filter(asset => asset.type === 'icon' && CHARX_IMAGE_EXTENSIONS.has(asset.ext) && asset.zipPath);
204
+ if (iconAssets.length === 0) {
205
+ return null;
206
+ }
207
+
208
+ const mainIcon = iconAssets.find(asset => asset.name?.toLowerCase() === 'main');
209
+ return mainIcon || iconAssets[0];
210
+ }
211
+
212
+ /**
213
+ * Normalize asset name for filesystem storage.
214
+ * @param {string} name - Original asset name
215
+ * @param {string} fallback - Fallback name if normalization fails
216
+ * @param {boolean} useHyphens - Use hyphens instead of underscores (for sprites)
217
+ * @returns {string} Normalized filename base (without extension)
218
+ */
219
+ getCharXAssetBaseName(name, fallback, useHyphens = false) {
220
+ const cleaned = (String(name ?? '').trim() || '');
221
+ if (!cleaned) {
222
+ return fallback.toLowerCase();
223
+ }
224
+
225
+ const separator = useHyphens ? '-' : '_';
226
+ // Convert to lowercase, collapse non-alphanumeric runs to separator, trim edges
227
+ const base = cleaned
228
+ .toLowerCase()
229
+ .replace(/[^a-z0-9]+/g, separator)
230
+ .replace(new RegExp(`^${separator}|${separator}$`, 'g'), '');
231
+
232
+ if (!base) {
233
+ return fallback.toLowerCase();
234
+ }
235
+
236
+ const sanitized = sanitize(base);
237
+ return (sanitized || fallback).toLowerCase();
238
+ }
239
+
240
+ mapCharXAssetsForStorage(assets) {
241
+ return assets.reduce((acc, asset) => {
242
+ if (!asset?.zipPath) {
243
+ return acc;
244
+ }
245
+
246
+ const ext = (asset.ext || '').toLowerCase();
247
+ if (!CHARX_IMAGE_EXTENSIONS.has(ext)) {
248
+ return acc;
249
+ }
250
+
251
+ if (asset.type === 'icon' || asset.type === 'user_icon') {
252
+ return acc;
253
+ }
254
+
255
+ let storageCategory;
256
+ if (CHARX_SPRITE_TYPES.has(asset.type)) {
257
+ storageCategory = 'sprite';
258
+ } else if (CHARX_BACKGROUND_TYPES.has(asset.type)) {
259
+ storageCategory = 'background';
260
+ } else {
261
+ storageCategory = 'misc';
262
+ }
263
+
264
+ // Use hyphens for sprites so ST's expression label extraction works correctly
265
+ // (sprites.js extracts label via regex that splits on dash or dot)
266
+ const useHyphens = storageCategory === 'sprite';
267
+ // Strip trailing extension from name if present (e.g., "image.png" with ext "png")
268
+ const nameWithoutExt = this.stripTrailingImageExtension(asset.name, ext);
269
+ acc.push({
270
+ ...asset,
271
+ ext,
272
+ storageCategory,
273
+ baseName: this.getCharXAssetBaseName(nameWithoutExt, `${storageCategory}-${asset.order ?? 0}`, useHyphens),
274
+ });
275
+
276
+ return acc;
277
+ }, []);
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Delete existing file with same base name (any extension) before overwriting.
283
+ * Matches ST's sprite upload behavior in sprites.js.
284
+ * @param {string} dirPath - Directory path
285
+ * @param {string} baseName - Base filename without extension
286
+ */
287
+ function deleteExistingByBaseName(dirPath, baseName) {
288
+ try {
289
+ const files = fs.readdirSync(dirPath, { withFileTypes: true }).filter(f => f.isFile()).map(f => f.name);
290
+ for (const file of files) {
291
+ if (path.parse(file).name === baseName) {
292
+ fs.unlinkSync(path.join(dirPath, file));
293
+ }
294
+ }
295
+ } catch {
296
+ // Directory doesn't exist yet or other error, that's fine
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Persist extracted CharX assets to appropriate ST directories.
302
+ * Note: Uses sync writes consistent with ST's existing file handling.
303
+ * @param {Array} assets - Mapped assets from CharXParser
304
+ * @param {Map<string, Buffer>} bufferMap - Extracted file buffers
305
+ * @param {Object} directories - User directories object
306
+ * @param {string} characterFolder - Character folder name (sanitized)
307
+ * @returns {{sprites: number, backgrounds: number, misc: number}}
308
+ */
309
+ export function persistCharXAssets(assets, bufferMap, directories, characterFolder) {
310
+ /** @type {{sprites: number, backgrounds: number, misc: number}} */
311
+ const summary = { sprites: 0, backgrounds: 0, misc: 0 };
312
+ if (!Array.isArray(assets) || assets.length === 0) {
313
+ return summary;
314
+ }
315
+
316
+ let spritesPath = null;
317
+ let miscPath = null;
318
+
319
+ const ensureSpritesPath = () => {
320
+ if (spritesPath) {
321
+ return spritesPath;
322
+ }
323
+ const candidate = path.join(directories.characters, characterFolder);
324
+ if (!ensureDirectory(candidate)) {
325
+ return null;
326
+ }
327
+ spritesPath = candidate;
328
+ return spritesPath;
329
+ };
330
+
331
+ const ensureMiscPath = () => {
332
+ if (miscPath) {
333
+ return miscPath;
334
+ }
335
+ // Use the image gallery path: user/images/{characterName}/
336
+ const candidate = path.join(directories.userImages, characterFolder);
337
+ if (!ensureDirectory(candidate)) {
338
+ return null;
339
+ }
340
+ miscPath = candidate;
341
+ return miscPath;
342
+ };
343
+
344
+ for (const asset of assets) {
345
+ if (!asset?.zipPath) {
346
+ continue;
347
+ }
348
+ const buffer = bufferMap.get(asset.zipPath);
349
+ if (!buffer) {
350
+ console.warn(`CharX: Asset ${asset.zipPath} missing or unsupported, skipping.`);
351
+ continue;
352
+ }
353
+
354
+ try {
355
+ if (asset.storageCategory === 'sprite') {
356
+ const targetDir = ensureSpritesPath();
357
+ if (!targetDir) {
358
+ continue;
359
+ }
360
+ // Delete existing sprite with same base name (any extension) - matches sprites.js behavior
361
+ deleteExistingByBaseName(targetDir, asset.baseName);
362
+ const filePath = path.join(targetDir, `${asset.baseName}.${asset.ext || 'png'}`);
363
+ writeFileAtomicSync(filePath, buffer);
364
+ summary.sprites += 1;
365
+ continue;
366
+ }
367
+
368
+ if (asset.storageCategory === 'background') {
369
+ // Store in character-specific backgrounds folder: characters/{charName}/backgrounds/
370
+ const backgroundDir = path.join(directories.characters, characterFolder, 'backgrounds');
371
+ if (!ensureDirectory(backgroundDir)) {
372
+ continue;
373
+ }
374
+ // Delete existing background with same base name
375
+ deleteExistingByBaseName(backgroundDir, asset.baseName);
376
+ const fileName = `${asset.baseName}.${asset.ext || 'png'}`;
377
+ const filePath = path.join(backgroundDir, fileName);
378
+ writeFileAtomicSync(filePath, buffer);
379
+ summary.backgrounds += 1;
380
+ continue;
381
+ }
382
+
383
+ if (asset.storageCategory === 'misc') {
384
+ const miscDir = ensureMiscPath();
385
+ if (!miscDir) {
386
+ continue;
387
+ }
388
+ // Overwrite existing misc asset with same name
389
+ const filePath = path.join(miscDir, `${asset.baseName}.${asset.ext || 'png'}`);
390
+ writeFileAtomicSync(filePath, buffer);
391
+ summary.misc += 1;
392
+ }
393
+ } catch (error) {
394
+ console.warn(`CharX: Failed to save asset "${asset.name}": ${error.message}`);
395
+ }
396
+ }
397
+
398
+ return summary;
399
+ }
src/command-line.js ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import yargs from 'yargs/yargs';
4
+ import { hideBin } from 'yargs/helpers';
5
+ import ipRegex from 'ip-regex';
6
+ import envPaths from 'env-paths';
7
+ import { color, getConfigValue, stringToBool } from './util.js';
8
+ import { initConfig } from './config-init.js';
9
+
10
+ /**
11
+ * @typedef {object} CommandLineArguments Parsed command line arguments
12
+ * @property {string} configPath Path to the config file
13
+ * @property {string} dataRoot Data root directory
14
+ * @property {number} port Port number
15
+ * @property {boolean} listen If TavernIntern is listening on all network interfaces
16
+ * @property {string} listenAddressIPv6 IPv6 address to listen to
17
+ * @property {string} listenAddressIPv4 IPv4 address to listen to
18
+ * @property {boolean|string} enableIPv4 If enable IPv4 protocol ("auto" is also allowed)
19
+ * @property {boolean|string} enableIPv6 If enable IPv6 protocol ("auto" is also allowed)
20
+ * @property {boolean} dnsPreferIPv6 If prefer IPv6 for DNS
21
+ * @property {boolean} browserLaunchEnabled If automatically launch TavernIntern in the browser
22
+ * @property {string} browserLaunchHostname Browser launch hostname
23
+ * @property {number} browserLaunchPort Browser launch port override (-1 is use server port)
24
+ * @property {boolean} browserLaunchAvoidLocalhost If avoid using 'localhost' for browser launch in auto mode
25
+ * @property {boolean} enableCorsProxy If enable CORS proxy
26
+ * @property {boolean} disableCsrf If disable CSRF protection
27
+ * @property {boolean} ssl If enable SSL
28
+ * @property {string} certPath Path to certificate
29
+ * @property {string} keyPath Path to private key
30
+ * @property {string} keyPassphrase SSL private key passphrase
31
+ * @property {boolean} whitelistMode If enable whitelist mode
32
+ * @property {boolean} basicAuthMode If enable basic authentication
33
+ * @property {boolean} requestProxyEnabled If enable outgoing request proxy
34
+ * @property {string} requestProxyUrl Request proxy URL
35
+ * @property {string[]} requestProxyBypass Request proxy bypass list
36
+ * @property {function(): URL} getIPv4ListenUrl Get IPv4 listen URL
37
+ * @property {function(): URL} getIPv6ListenUrl Get IPv6 listen URL
38
+ * @property {function(import('./server-startup.js').ServerStartupResult): Promise<string>} getBrowserLaunchHostname Get browser launch hostname
39
+ * @property {function(string): URL} getBrowserLaunchUrl Get browser launch URL
40
+ */
41
+
42
+ /**
43
+ * Provides a command line arguments parser.
44
+ */
45
+ export class CommandLineParser {
46
+ /**
47
+ * Gets the default configuration values.
48
+ * @param {boolean} isGlobal If the configuration is global or not
49
+ * @returns {CommandLineArguments} Default configuration values
50
+ */
51
+ getDefaultConfig(isGlobal) {
52
+ const appPaths = envPaths('TavernIntern', { suffix: '' });
53
+ const configPath = isGlobal ? path.join(appPaths.data, 'config.yaml') : './config.yaml';
54
+ const dataPath = isGlobal ? path.join(appPaths.data, 'data') : './data';
55
+ return Object.freeze({
56
+ configPath: configPath,
57
+ dataRoot: dataPath,
58
+ port: 8000,
59
+ listen: false,
60
+ listenAddressIPv6: '[::]',
61
+ listenAddressIPv4: '0.0.0.0',
62
+ enableIPv4: true,
63
+ enableIPv6: false,
64
+ dnsPreferIPv6: false,
65
+ browserLaunchEnabled: false,
66
+ browserLaunchHostname: 'auto',
67
+ browserLaunchPort: -1,
68
+ browserLaunchAvoidLocalhost: false,
69
+ enableCorsProxy: false,
70
+ disableCsrf: false,
71
+ ssl: false,
72
+ certPath: 'certs/cert.pem',
73
+ keyPath: 'certs/privkey.pem',
74
+ keyPassphrase: '',
75
+ whitelistMode: true,
76
+ basicAuthMode: false,
77
+ requestProxyEnabled: false,
78
+ requestProxyUrl: '',
79
+ requestProxyBypass: [],
80
+ getIPv4ListenUrl: function () {
81
+ throw new Error('getIPv4ListenUrl is not implemented');
82
+ },
83
+ getIPv6ListenUrl: function () {
84
+ throw new Error('getIPv6ListenUrl is not implemented');
85
+ },
86
+ getBrowserLaunchHostname: async function () {
87
+ throw new Error('getBrowserLaunchHostname is not implemented');
88
+ },
89
+ getBrowserLaunchUrl: function () {
90
+ throw new Error('getBrowserLaunchUrl is not implemented');
91
+ },
92
+ });
93
+ }
94
+
95
+ constructor() {
96
+ this.booleanAutoOptions = [true, false, 'auto'];
97
+ }
98
+
99
+ /**
100
+ * Parses command line arguments.
101
+ * Arguments that are not provided will be filled with config values.
102
+ * @param {string[]} args Process startup arguments.
103
+ * @returns {CommandLineArguments} Parsed command line arguments.
104
+ */
105
+ parse(args) {
106
+ const cliArguments = yargs(hideBin(args))
107
+ .usage('Usage: <your-start-script> [options]\nOptions that are not provided will be filled with config values.')
108
+ .option('global', {
109
+ type: 'boolean',
110
+ default: null,
111
+ describe: 'Use global data and config paths instead of the server directory',
112
+ })
113
+ .option('configPath', {
114
+ type: 'string',
115
+ default: null,
116
+ describe: 'Path to the config file (only for standalone mode)',
117
+ })
118
+ .option('enableIPv6', {
119
+ type: 'string',
120
+ default: null,
121
+ describe: 'Enables IPv6 protocol',
122
+ })
123
+ .option('enableIPv4', {
124
+ type: 'string',
125
+ default: null,
126
+ describe: 'Enables IPv4 protocol',
127
+ })
128
+ .option('port', {
129
+ type: 'number',
130
+ default: null,
131
+ describe: 'Sets the server listening port',
132
+ })
133
+ .option('dnsPreferIPv6', {
134
+ type: 'boolean',
135
+ default: null,
136
+ describe: 'Prefers IPv6 for DNS\nYou should probably have the enabled if you\'re on an IPv6 only network',
137
+ })
138
+ .option('browserLaunchEnabled', {
139
+ type: 'boolean',
140
+ default: null,
141
+ describe: 'Automatically launch TavernIntern in the browser',
142
+ })
143
+ .option('browserLaunchHostname', {
144
+ type: 'string',
145
+ default: null,
146
+ describe: 'Sets the browser launch hostname, best left on \'auto\'.\nUse values like \'localhost\', \'st.example.com\'',
147
+ })
148
+ .option('browserLaunchPort', {
149
+ type: 'number',
150
+ default: null,
151
+ describe: 'Overrides the port for browser launch with open your browser with this port and ignore what port the server is running on. -1 is use server port',
152
+ })
153
+ .option('browserLaunchAvoidLocalhost', {
154
+ type: 'boolean',
155
+ default: null,
156
+ describe: 'Avoids using \'localhost\' for browser launch in auto mode.\nUse if you don\'t have \'localhost\' in your hosts file',
157
+ })
158
+ .option('listen', {
159
+ type: 'boolean',
160
+ default: null,
161
+ describe: 'Whether to listen on all network interfaces',
162
+ })
163
+ .option('listenAddressIPv6', {
164
+ type: 'string',
165
+ default: null,
166
+ describe: 'Specific IPv6 address to listen to',
167
+ })
168
+ .option('listenAddressIPv4', {
169
+ type: 'string',
170
+ default: null,
171
+ describe: 'Specific IPv4 address to listen to',
172
+ })
173
+ .option('corsProxy', {
174
+ type: 'boolean',
175
+ default: null,
176
+ describe: 'Enables CORS proxy',
177
+ })
178
+ .option('disableCsrf', {
179
+ type: 'boolean',
180
+ default: null,
181
+ describe: 'Disables CSRF protection - NOT RECOMMENDED',
182
+ })
183
+ .option('ssl', {
184
+ type: 'boolean',
185
+ default: null,
186
+ describe: 'Enables SSL',
187
+ })
188
+ .option('certPath', {
189
+ type: 'string',
190
+ default: null,
191
+ describe: 'Path to SSL certificate file',
192
+ })
193
+ .option('keyPath', {
194
+ type: 'string',
195
+ default: null,
196
+ describe: 'Path to SSL private key file',
197
+ })
198
+ .option('keyPassphrase', {
199
+ type: 'string',
200
+ default: null,
201
+ describe: 'Passphrase for the SSL private key',
202
+ })
203
+ .option('whitelist', {
204
+ type: 'boolean',
205
+ default: null,
206
+ describe: 'Enables whitelist mode',
207
+ })
208
+ .option('dataRoot', {
209
+ type: 'string',
210
+ default: null,
211
+ describe: 'Root directory for data storage (only for standalone mode)',
212
+ })
213
+ .option('basicAuthMode', {
214
+ type: 'boolean',
215
+ default: null,
216
+ describe: 'Enables basic authentication',
217
+ })
218
+ .option('requestProxyEnabled', {
219
+ type: 'boolean',
220
+ default: null,
221
+ describe: 'Enables a use of proxy for outgoing requests',
222
+ })
223
+ .option('requestProxyUrl', {
224
+ type: 'string',
225
+ default: null,
226
+ describe: 'Request proxy URL (HTTP or SOCKS protocols)',
227
+ })
228
+ .option('requestProxyBypass', {
229
+ type: 'array',
230
+ describe: 'Request proxy bypass list (space separated list of hosts)',
231
+ })
232
+ /* DEPRECATED options */
233
+ .option('autorun', {
234
+ type: 'boolean',
235
+ default: null,
236
+ describe: 'DEPRECATED: Use "browserLaunchEnabled" instead.',
237
+ })
238
+ .option('autorunHostname', {
239
+ type: 'string',
240
+ default: null,
241
+ describe: 'DEPRECATED: Use "browserLaunchHostname" instead.',
242
+ })
243
+ .option('autorunPortOverride', {
244
+ type: 'number',
245
+ default: null,
246
+ describe: 'DEPRECATED: Use "browserLaunchPort" instead.',
247
+ })
248
+ .option('avoidLocalhost', {
249
+ type: 'boolean',
250
+ default: null,
251
+ describe: 'DEPRECATED: Use "browserLaunchAvoidLocalhost" instead.',
252
+ })
253
+ .parseSync();
254
+
255
+ const isGlobal = globalThis.FORCE_GLOBAL_MODE ?? cliArguments.global ?? false;
256
+ const defaultConfig = this.getDefaultConfig(isGlobal);
257
+
258
+ if (isGlobal && cliArguments.configPath) {
259
+ console.warn(color.yellow('Warning: "--configPath" argument is ignored in global mode'));
260
+ }
261
+
262
+ if (isGlobal && cliArguments.dataRoot) {
263
+ console.warn(color.yellow('Warning: "--dataRoot" argument is ignored in global mode'));
264
+ }
265
+
266
+ const configPath = isGlobal
267
+ ? defaultConfig.configPath
268
+ : (cliArguments.configPath ?? defaultConfig.configPath);
269
+ if (isGlobal && !fs.existsSync(path.dirname(configPath))) {
270
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
271
+ }
272
+ initConfig(configPath);
273
+
274
+ const dataRoot = isGlobal
275
+ ? defaultConfig.dataRoot
276
+ : (cliArguments.dataRoot ?? getConfigValue('dataRoot', defaultConfig.dataRoot));
277
+ if (isGlobal && !fs.existsSync(dataRoot)) {
278
+ fs.mkdirSync(dataRoot, { recursive: true });
279
+ }
280
+
281
+ /** @type {CommandLineArguments} */
282
+ const result = {
283
+ configPath: configPath,
284
+ dataRoot: dataRoot,
285
+ port: cliArguments.port ?? getConfigValue('port', defaultConfig.port, 'number'),
286
+ listen: cliArguments.listen ?? getConfigValue('listen', defaultConfig.listen, 'boolean'),
287
+ listenAddressIPv6: cliArguments.listenAddressIPv6 ?? getConfigValue('listenAddress.ipv6', defaultConfig.listenAddressIPv6),
288
+ listenAddressIPv4: cliArguments.listenAddressIPv4 ?? getConfigValue('listenAddress.ipv4', defaultConfig.listenAddressIPv4),
289
+ enableIPv4: stringToBool(cliArguments.enableIPv4) ?? stringToBool(getConfigValue('protocol.ipv4', defaultConfig.enableIPv4)) ?? defaultConfig.enableIPv4,
290
+ enableIPv6: stringToBool(cliArguments.enableIPv6) ?? stringToBool(getConfigValue('protocol.ipv6', defaultConfig.enableIPv6)) ?? defaultConfig.enableIPv6,
291
+ dnsPreferIPv6: cliArguments.dnsPreferIPv6 ?? getConfigValue('dnsPreferIPv6', defaultConfig.dnsPreferIPv6, 'boolean'),
292
+ browserLaunchEnabled: cliArguments.browserLaunchEnabled ?? cliArguments.autorun ?? getConfigValue('browserLaunch.enabled', defaultConfig.browserLaunchEnabled, 'boolean'),
293
+ browserLaunchHostname: cliArguments.browserLaunchHostname ?? cliArguments.autorunHostname ?? getConfigValue('browserLaunch.hostname', defaultConfig.browserLaunchHostname),
294
+ browserLaunchPort: cliArguments.browserLaunchPort ?? cliArguments.autorunPortOverride ?? getConfigValue('browserLaunch.port', defaultConfig.browserLaunchPort, 'number'),
295
+ browserLaunchAvoidLocalhost: cliArguments.browserLaunchAvoidLocalhost ?? cliArguments.avoidLocalhost ?? getConfigValue('browserLaunch.avoidLocalhost', defaultConfig.browserLaunchAvoidLocalhost, 'boolean'),
296
+ enableCorsProxy: cliArguments.corsProxy ?? getConfigValue('enableCorsProxy', defaultConfig.enableCorsProxy, 'boolean'),
297
+ disableCsrf: cliArguments.disableCsrf ?? getConfigValue('disableCsrfProtection', defaultConfig.disableCsrf, 'boolean'),
298
+ ssl: cliArguments.ssl ?? getConfigValue('ssl.enabled', defaultConfig.ssl, 'boolean'),
299
+ certPath: cliArguments.certPath ?? getConfigValue('ssl.certPath', defaultConfig.certPath),
300
+ keyPath: cliArguments.keyPath ?? getConfigValue('ssl.keyPath', defaultConfig.keyPath),
301
+ keyPassphrase: cliArguments.keyPassphrase ?? getConfigValue('ssl.keyPassphrase', defaultConfig.keyPassphrase),
302
+ whitelistMode: cliArguments.whitelist ?? getConfigValue('whitelistMode', defaultConfig.whitelistMode, 'boolean'),
303
+ basicAuthMode: cliArguments.basicAuthMode ?? getConfigValue('basicAuthMode', defaultConfig.basicAuthMode, 'boolean'),
304
+ requestProxyEnabled: cliArguments.requestProxyEnabled ?? getConfigValue('requestProxy.enabled', defaultConfig.requestProxyEnabled, 'boolean'),
305
+ requestProxyUrl: cliArguments.requestProxyUrl ?? getConfigValue('requestProxy.url', defaultConfig.requestProxyUrl),
306
+ requestProxyBypass: cliArguments.requestProxyBypass ?? getConfigValue('requestProxy.bypass', defaultConfig.requestProxyBypass),
307
+ getIPv4ListenUrl: function () {
308
+ const isValid = ipRegex.v4({ exact: true }).test(this.listenAddressIPv4);
309
+ return new URL(
310
+ (this.ssl ? 'https://' : 'http://') +
311
+ (this.listen ? (isValid ? this.listenAddressIPv4 : '0.0.0.0') : '127.0.0.1') +
312
+ (':' + this.port),
313
+ );
314
+ },
315
+ getIPv6ListenUrl: function () {
316
+ const isValid = ipRegex.v6({ exact: true }).test(this.listenAddressIPv6);
317
+ return new URL(
318
+ (this.ssl ? 'https://' : 'http://') +
319
+ (this.listen ? (isValid ? this.listenAddressIPv6 : '[::]') : '[::1]') +
320
+ (':' + this.port),
321
+ );
322
+ },
323
+ getBrowserLaunchHostname: async function ({ useIPv6, useIPv4 }) {
324
+ if (this.browserLaunchHostname === 'auto') {
325
+ if (useIPv6 && useIPv4) {
326
+ return this.browserLaunchAvoidLocalhost ? '[::1]' : 'localhost';
327
+ }
328
+
329
+ if (useIPv6) {
330
+ return '[::1]';
331
+ }
332
+
333
+ if (useIPv4) {
334
+ return '127.0.0.1';
335
+ }
336
+ }
337
+
338
+ return this.browserLaunchHostname;
339
+ },
340
+ getBrowserLaunchUrl: function (hostname) {
341
+ const browserLaunchPort = (this.browserLaunchPort >= 0) ? this.browserLaunchPort : this.port;
342
+ return new URL(
343
+ (this.ssl ? 'https://' : 'http://') +
344
+ (hostname) +
345
+ (':') +
346
+ (browserLaunchPort),
347
+ );
348
+ },
349
+ };
350
+
351
+ if (!this.booleanAutoOptions.includes(result.enableIPv6)) {
352
+ console.warn(color.red('`protocol: ipv6` option invalid'), '\n use:', this.booleanAutoOptions, '\n setting to:', defaultConfig.enableIPv6);
353
+ result.enableIPv6 = defaultConfig.enableIPv6;
354
+ }
355
+
356
+ if (!this.booleanAutoOptions.includes(result.enableIPv4)) {
357
+ console.warn(color.red('`protocol: ipv4` option invalid'), '\n use:', this.booleanAutoOptions, '\n setting to:', defaultConfig.enableIPv4);
358
+ result.enableIPv4 = defaultConfig.enableIPv4;
359
+ }
360
+
361
+ return result;
362
+ }
363
+ }
src/config-init.js ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import yaml from 'yaml';
4
+ import color from 'chalk';
5
+ import _ from 'lodash';
6
+ import { serverDirectory } from './server-directory.js';
7
+ import { keyToEnv, setConfigFilePath } from './util.js';
8
+
9
+ const keyMigrationMap = [
10
+ {
11
+ oldKey: 'disableThumbnails',
12
+ newKey: 'thumbnails.enabled',
13
+ migrate: (value) => !value,
14
+ },
15
+ {
16
+ oldKey: 'thumbnailsQuality',
17
+ newKey: 'thumbnails.quality',
18
+ migrate: (value) => value,
19
+ },
20
+ {
21
+ oldKey: 'avatarThumbnailsPng',
22
+ newKey: 'thumbnails.format',
23
+ migrate: (value) => (value ? 'png' : 'jpg'),
24
+ },
25
+ {
26
+ oldKey: 'disableChatBackup',
27
+ newKey: 'backups.chat.enabled',
28
+ migrate: (value) => !value,
29
+ },
30
+ {
31
+ oldKey: 'numberOfBackups',
32
+ newKey: 'backups.common.numberOfBackups',
33
+ migrate: (value) => value,
34
+ },
35
+ {
36
+ oldKey: 'maxTotalChatBackups',
37
+ newKey: 'backups.chat.maxTotalBackups',
38
+ migrate: (value) => value,
39
+ },
40
+ {
41
+ oldKey: 'chatBackupThrottleInterval',
42
+ newKey: 'backups.chat.throttleInterval',
43
+ migrate: (value) => value,
44
+ },
45
+ {
46
+ oldKey: 'enableExtensions',
47
+ newKey: 'extensions.enabled',
48
+ migrate: (value) => value,
49
+ },
50
+ {
51
+ oldKey: 'enableExtensionsAutoUpdate',
52
+ newKey: 'extensions.autoUpdate',
53
+ migrate: (value) => value,
54
+ },
55
+ {
56
+ oldKey: 'extras.disableAutoDownload',
57
+ newKey: 'extensions.models.autoDownload',
58
+ migrate: (value) => !value,
59
+ },
60
+ {
61
+ oldKey: 'extras.classificationModel',
62
+ newKey: 'extensions.models.classification',
63
+ migrate: (value) => value,
64
+ },
65
+ {
66
+ oldKey: 'extras.captioningModel',
67
+ newKey: 'extensions.models.captioning',
68
+ migrate: (value) => value,
69
+ },
70
+ {
71
+ oldKey: 'extras.embeddingModel',
72
+ newKey: 'extensions.models.embedding',
73
+ migrate: (value) => value,
74
+ },
75
+ {
76
+ oldKey: 'extras.speechToTextModel',
77
+ newKey: 'extensions.models.speechToText',
78
+ migrate: (value) => value,
79
+ },
80
+ {
81
+ oldKey: 'extras.textToSpeechModel',
82
+ newKey: 'extensions.models.textToSpeech',
83
+ migrate: (value) => value,
84
+ },
85
+ {
86
+ oldKey: 'minLogLevel',
87
+ newKey: 'logging.minLogLevel',
88
+ migrate: (value) => value,
89
+ },
90
+ {
91
+ oldKey: 'cardsCacheCapacity',
92
+ newKey: 'performance.memoryCacheCapacity',
93
+ migrate: (value) => `${value}mb`,
94
+ },
95
+ {
96
+ oldKey: 'cookieSecret',
97
+ newKey: 'cookieSecret',
98
+ migrate: () => void 0,
99
+ remove: true,
100
+ },
101
+ {
102
+ oldKey: 'autorun',
103
+ newKey: 'browserLaunch.enabled',
104
+ migrate: (value) => value,
105
+ },
106
+ {
107
+ oldKey: 'autorunHostname',
108
+ newKey: 'browserLaunch.hostname',
109
+ migrate: (value) => value,
110
+ },
111
+ {
112
+ oldKey: 'autorunPortOverride',
113
+ newKey: 'browserLaunch.port',
114
+ migrate: (value) => value,
115
+ },
116
+ {
117
+ oldKey: 'avoidLocalhost',
118
+ newKey: 'browserLaunch.avoidLocalhost',
119
+ migrate: (value) => value,
120
+ },
121
+ {
122
+ oldKey: 'extras.promptExpansionModel',
123
+ newKey: 'extras.promptExpansionModel',
124
+ migrate: () => void 0,
125
+ remove: true,
126
+ },
127
+ {
128
+ oldKey: 'autheliaAuth',
129
+ newKey: 'sso.autheliaAuth',
130
+ migrate: (value) => value,
131
+ },
132
+ {
133
+ oldKey: 'authentikAuth',
134
+ newKey: 'sso.authentikAuth',
135
+ migrate: (value) => value,
136
+ },
137
+ ];
138
+
139
+ /**
140
+ * Gets all keys from an object recursively.
141
+ * @param {object} obj Object to get all keys from
142
+ * @param {string} prefix Prefix to prepend to all keys
143
+ * @returns {string[]} Array of all keys in the object
144
+ */
145
+ function getAllKeys(obj, prefix = '') {
146
+ if (typeof obj !== 'object' || Array.isArray(obj) || obj === null) {
147
+ return [];
148
+ }
149
+
150
+ return _.flatMap(Object.keys(obj), key => {
151
+ const newPrefix = prefix ? `${prefix}.${key}` : key;
152
+ if (typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
153
+ return getAllKeys(obj[key], newPrefix);
154
+ } else {
155
+ return [newPrefix];
156
+ }
157
+ });
158
+ }
159
+
160
+ /**
161
+ * Compares the current config.yaml with the default config.yaml and adds any missing values.
162
+ * @param {string} configPath Path to config.yaml
163
+ */
164
+ export function addMissingConfigValues(configPath) {
165
+ try {
166
+ const defaultConfig = yaml.parse(fs.readFileSync(path.join(serverDirectory, './default/config.yaml'), 'utf8'));
167
+
168
+ if (!fs.existsSync(configPath)) {
169
+ console.warn(color.yellow(`Warning: config.yaml not found at ${configPath}. Creating a new one with default values.`));
170
+ fs.writeFileSync(configPath, yaml.stringify(defaultConfig));
171
+ return;
172
+ }
173
+
174
+ let config = yaml.parse(fs.readFileSync(configPath, 'utf8'));
175
+
176
+ // Migrate old keys to new keys
177
+ const migratedKeys = [];
178
+ for (const { oldKey, newKey, migrate, remove } of keyMigrationMap) {
179
+ // Migrate environment variables
180
+ const oldEnvKey = keyToEnv(oldKey);
181
+ const newEnvKey = keyToEnv(newKey);
182
+ if (process.env[oldEnvKey] && !process.env[newEnvKey]) {
183
+ const oldValue = process.env[oldEnvKey];
184
+ const newValue = migrate(oldValue);
185
+ process.env[newEnvKey] = newValue;
186
+ delete process.env[oldEnvKey];
187
+ console.warn(color.yellow(`Warning: Using a deprecated environment variable: ${oldEnvKey}. Please use ${newEnvKey} instead.`));
188
+ console.log(`Redirecting ${color.blue(oldEnvKey)}=${oldValue} -> ${color.blue(newEnvKey)}=${newValue}`);
189
+ }
190
+
191
+ if (_.has(config, oldKey)) {
192
+ if (remove) {
193
+ _.unset(config, oldKey);
194
+ migratedKeys.push({
195
+ oldKey,
196
+ newValue: void 0,
197
+ });
198
+ continue;
199
+ }
200
+
201
+ const oldValue = _.get(config, oldKey);
202
+ const newValue = migrate(oldValue);
203
+ _.set(config, newKey, newValue);
204
+ _.unset(config, oldKey);
205
+
206
+ migratedKeys.push({
207
+ oldKey,
208
+ newKey,
209
+ oldValue,
210
+ newValue,
211
+ });
212
+ }
213
+ }
214
+
215
+ // Get all keys from the original config
216
+ const originalKeys = getAllKeys(config);
217
+
218
+ // Use lodash's defaultsDeep function to recursively apply default properties
219
+ config = _.defaultsDeep(config, defaultConfig);
220
+
221
+ // Get all keys from the updated config
222
+ const updatedKeys = getAllKeys(config);
223
+
224
+ // Find the keys that were added
225
+ const addedKeys = _.difference(updatedKeys, originalKeys);
226
+
227
+ if (addedKeys.length === 0 && migratedKeys.length === 0) {
228
+ return;
229
+ }
230
+
231
+ if (addedKeys.length > 0) {
232
+ console.log('Adding missing config values to config.yaml:', addedKeys);
233
+ }
234
+
235
+ if (migratedKeys.length > 0) {
236
+ console.log('Migrating config values in config.yaml:', migratedKeys);
237
+ }
238
+
239
+ fs.writeFileSync(configPath, yaml.stringify(config));
240
+ } catch (error) {
241
+ console.error(color.red('FATAL: Could not add missing config values to config.yaml'), error);
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Performs early initialization tasks before the server starts.
247
+ * @param {string} configPath Path to config.yaml
248
+ */
249
+ export function initConfig(configPath) {
250
+ console.log('Using config path:', color.green(configPath));
251
+ setConfigFilePath(configPath);
252
+ addMissingConfigValues(configPath);
253
+ }
src/constants.js ADDED
@@ -0,0 +1,535 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const PUBLIC_DIRECTORIES = {
2
+ images: 'public/img/',
3
+ backups: 'backups/',
4
+ sounds: 'public/sounds',
5
+ extensions: 'public/scripts/extensions',
6
+ globalExtensions: 'public/scripts/extensions/third-party',
7
+ };
8
+
9
+ export const SETTINGS_FILE = 'settings.json';
10
+
11
+ /**
12
+ * @type {import('./users.js').UserDirectoryList}
13
+ * @readonly
14
+ * @enum {string}
15
+ */
16
+ export const USER_DIRECTORY_TEMPLATE = Object.freeze({
17
+ root: '',
18
+ thumbnails: 'thumbnails',
19
+ thumbnailsBg: 'thumbnails/bg',
20
+ thumbnailsAvatar: 'thumbnails/avatar',
21
+ thumbnailsPersona: 'thumbnails/persona',
22
+ worlds: 'worlds',
23
+ user: 'user',
24
+ avatars: 'User Avatars',
25
+ userImages: 'user/images',
26
+ groups: 'groups',
27
+ groupChats: 'group chats',
28
+ chats: 'chats',
29
+ characters: 'characters',
30
+ backgrounds: 'backgrounds',
31
+ novelAI_Settings: 'NovelAI Settings',
32
+ koboldAI_Settings: 'KoboldAI Settings',
33
+ openAI_Settings: 'OpenAI Settings',
34
+ textGen_Settings: 'TextGen Settings',
35
+ themes: 'themes',
36
+ movingUI: 'movingUI',
37
+ extensions: 'extensions',
38
+ instruct: 'instruct',
39
+ context: 'context',
40
+ quickreplies: 'QuickReplies',
41
+ assets: 'assets',
42
+ comfyWorkflows: 'user/workflows',
43
+ files: 'user/files',
44
+ vectors: 'vectors',
45
+ backups: 'backups',
46
+ sysprompt: 'sysprompt',
47
+ reasoning: 'reasoning',
48
+ });
49
+
50
+ /**
51
+ * @type {import('./users.js').User}
52
+ * @readonly
53
+ */
54
+ export const DEFAULT_USER = Object.freeze({
55
+ handle: 'default-user',
56
+ name: 'User',
57
+ created: Date.now(),
58
+ password: '',
59
+ admin: true,
60
+ enabled: true,
61
+ salt: '',
62
+ });
63
+
64
+ export const UNSAFE_EXTENSIONS = [
65
+ '.php',
66
+ '.exe',
67
+ '.com',
68
+ '.dll',
69
+ '.pif',
70
+ '.application',
71
+ '.gadget',
72
+ '.msi',
73
+ '.jar',
74
+ '.cmd',
75
+ '.bat',
76
+ '.reg',
77
+ '.sh',
78
+ '.py',
79
+ '.js',
80
+ '.jse',
81
+ '.jsp',
82
+ '.pdf',
83
+ '.html',
84
+ '.htm',
85
+ '.hta',
86
+ '.vb',
87
+ '.vbs',
88
+ '.vbe',
89
+ '.cpl',
90
+ '.msc',
91
+ '.scr',
92
+ '.sql',
93
+ '.iso',
94
+ '.img',
95
+ '.dmg',
96
+ '.ps1',
97
+ '.ps1xml',
98
+ '.ps2',
99
+ '.ps2xml',
100
+ '.psc1',
101
+ '.psc2',
102
+ '.msh',
103
+ '.msh1',
104
+ '.msh2',
105
+ '.mshxml',
106
+ '.msh1xml',
107
+ '.msh2xml',
108
+ '.scf',
109
+ '.lnk',
110
+ '.inf',
111
+ '.reg',
112
+ '.doc',
113
+ '.docm',
114
+ '.docx',
115
+ '.dot',
116
+ '.dotm',
117
+ '.dotx',
118
+ '.xls',
119
+ '.xlsm',
120
+ '.xlsx',
121
+ '.xlt',
122
+ '.xltm',
123
+ '.xltx',
124
+ '.xlam',
125
+ '.ppt',
126
+ '.pptm',
127
+ '.pptx',
128
+ '.pot',
129
+ '.potm',
130
+ '.potx',
131
+ '.ppam',
132
+ '.ppsx',
133
+ '.ppsm',
134
+ '.pps',
135
+ '.ppam',
136
+ '.sldx',
137
+ '.sldm',
138
+ '.ws',
139
+ ];
140
+
141
+ export const GEMINI_SAFETY = [
142
+ {
143
+ category: 'HARM_CATEGORY_HARASSMENT',
144
+ threshold: 'OFF',
145
+ },
146
+ {
147
+ category: 'HARM_CATEGORY_HATE_SPEECH',
148
+ threshold: 'OFF',
149
+ },
150
+ {
151
+ category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
152
+ threshold: 'OFF',
153
+ },
154
+ {
155
+ category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
156
+ threshold: 'OFF',
157
+ },
158
+ {
159
+ category: 'HARM_CATEGORY_CIVIC_INTEGRITY',
160
+ threshold: 'OFF',
161
+ },
162
+ ];
163
+
164
+ export const VERTEX_SAFETY = [
165
+ {
166
+ category: 'HARM_CATEGORY_IMAGE_HATE',
167
+ threshold: 'OFF',
168
+ },
169
+ {
170
+ category: 'HARM_CATEGORY_IMAGE_DANGEROUS_CONTENT',
171
+ threshold: 'OFF',
172
+ },
173
+ {
174
+ category: 'HARM_CATEGORY_IMAGE_HARASSMENT',
175
+ threshold: 'OFF',
176
+ },
177
+ {
178
+ category: 'HARM_CATEGORY_IMAGE_SEXUALLY_EXPLICIT',
179
+ threshold: 'OFF',
180
+ },
181
+ {
182
+ category: 'HARM_CATEGORY_JAILBREAK',
183
+ threshold: 'OFF',
184
+ },
185
+ ];
186
+
187
+ export const CHAT_COMPLETION_SOURCES = {
188
+ OPENAI: 'openai',
189
+ CLAUDE: 'claude',
190
+ OPENROUTER: 'openrouter',
191
+ AI21: 'ai21',
192
+ MAKERSUITE: 'makersuite',
193
+ VERTEXAI: 'vertexai',
194
+ MISTRALAI: 'mistralai',
195
+ CUSTOM: 'custom',
196
+ COHERE: 'cohere',
197
+ PERPLEXITY: 'perplexity',
198
+ GROQ: 'groq',
199
+ CHUTES: 'chutes',
200
+ ELECTRONHUB: 'electronhub',
201
+ NANOGPT: 'nanogpt',
202
+ DEEPSEEK: 'deepseek',
203
+ AIMLAPI: 'aimlapi',
204
+ XAI: 'xai',
205
+ POLLINATIONS: 'pollinations',
206
+ MOONSHOT: 'moonshot',
207
+ FIREWORKS: 'fireworks',
208
+ COMETAPI: 'cometapi',
209
+ AZURE_OPENAI: 'azure_openai',
210
+ ZAI: 'zai',
211
+ SILICONFLOW: 'siliconflow',
212
+ };
213
+
214
+ /**
215
+ * Path to multer file uploads under the data root.
216
+ */
217
+ export const UPLOADS_DIRECTORY = '_uploads';
218
+
219
+ // TODO: this is copied from the client code; there should be a way to de-duplicate it eventually
220
+ export const TEXTGEN_TYPES = {
221
+ OOBA: 'ooba',
222
+ MANCER: 'mancer',
223
+ VLLM: 'vllm',
224
+ APHRODITE: 'aphrodite',
225
+ TABBY: 'tabby',
226
+ KOBOLDCPP: 'koboldcpp',
227
+ TOGETHERAI: 'togetherai',
228
+ LLAMACPP: 'llamacpp',
229
+ OLLAMA: 'ollama',
230
+ INFERMATICAI: 'infermaticai',
231
+ DREAMGEN: 'dreamgen',
232
+ OPENROUTER: 'openrouter',
233
+ FEATHERLESS: 'featherless',
234
+ HUGGINGFACE: 'huggingface',
235
+ GENERIC: 'generic',
236
+ };
237
+
238
+ export const INFERMATICAI_KEYS = [
239
+ 'model',
240
+ 'prompt',
241
+ 'max_tokens',
242
+ 'temperature',
243
+ 'top_p',
244
+ 'top_k',
245
+ 'repetition_penalty',
246
+ 'stream',
247
+ 'stop',
248
+ 'presence_penalty',
249
+ 'frequency_penalty',
250
+ 'min_p',
251
+ 'seed',
252
+ 'ignore_eos',
253
+ 'n',
254
+ 'best_of',
255
+ 'min_tokens',
256
+ 'spaces_between_special_tokens',
257
+ 'skip_special_tokens',
258
+ 'logprobs',
259
+ ];
260
+
261
+ export const FEATHERLESS_KEYS = [
262
+ 'model',
263
+ 'prompt',
264
+ 'best_of',
265
+ 'echo',
266
+ 'frequency_penalty',
267
+ 'logit_bias',
268
+ 'logprobs',
269
+ 'max_tokens',
270
+ 'n',
271
+ 'presence_penalty',
272
+ 'seed',
273
+ 'stop',
274
+ 'stream',
275
+ 'suffix',
276
+ 'temperature',
277
+ 'top_p',
278
+ 'user',
279
+
280
+ 'use_beam_search',
281
+ 'top_k',
282
+ 'min_p',
283
+ 'repetition_penalty',
284
+ 'length_penalty',
285
+ 'early_stopping',
286
+ 'stop_token_ids',
287
+ 'ignore_eos',
288
+ 'min_tokens',
289
+ 'skip_special_tokens',
290
+ 'spaces_between_special_tokens',
291
+ 'truncate_prompt_tokens',
292
+
293
+ 'include_stop_str_in_output',
294
+ 'response_format',
295
+ 'guided_json',
296
+ 'guided_regex',
297
+ 'guided_choice',
298
+ 'guided_grammar',
299
+ 'guided_decoding_backend',
300
+ 'guided_whitespace_pattern',
301
+ ];
302
+
303
+ // https://docs.together.ai/reference/completions
304
+ export const TOGETHERAI_KEYS = [
305
+ 'model',
306
+ 'prompt',
307
+ 'max_tokens',
308
+ 'temperature',
309
+ 'top_p',
310
+ 'top_k',
311
+ 'repetition_penalty',
312
+ 'min_p',
313
+ 'presence_penalty',
314
+ 'frequency_penalty',
315
+ 'stream',
316
+ 'stop',
317
+ ];
318
+
319
+ // https://github.com/ollama/ollama/blob/main/docs/api.md#request-8
320
+ export const OLLAMA_KEYS = [
321
+ 'num_predict',
322
+ 'num_ctx',
323
+ 'num_batch',
324
+ 'stop',
325
+ 'temperature',
326
+ 'repeat_penalty',
327
+ 'presence_penalty',
328
+ 'frequency_penalty',
329
+ 'top_k',
330
+ 'top_p',
331
+ 'tfs_z',
332
+ 'typical_p',
333
+ 'seed',
334
+ 'repeat_last_n',
335
+ 'min_p',
336
+ ];
337
+
338
+ // https://platform.openai.com/docs/api-reference/completions
339
+ export const OPENAI_KEYS = [
340
+ 'model',
341
+ 'prompt',
342
+ 'stream',
343
+ 'temperature',
344
+ 'top_p',
345
+ 'frequency_penalty',
346
+ 'presence_penalty',
347
+ 'stop',
348
+ 'seed',
349
+ 'logit_bias',
350
+ 'logprobs',
351
+ 'max_tokens',
352
+ 'n',
353
+ 'best_of',
354
+ ];
355
+
356
+ export const AVATAR_WIDTH = 512;
357
+ export const AVATAR_HEIGHT = 768;
358
+ export const DEFAULT_AVATAR_PATH = './public/img/ai4.png';
359
+
360
+ export const OPENROUTER_HEADERS = {
361
+ 'HTTP-Referer': 'https://tavernintern.app',
362
+ 'X-Title': 'TavernIntern',
363
+ };
364
+
365
+ export const AIMLAPI_HEADERS = {
366
+ 'HTTP-Referer': 'https://tavernintern.app',
367
+ 'X-Title': 'TavernIntern',
368
+ };
369
+
370
+ export const FEATHERLESS_HEADERS = {
371
+ 'HTTP-Referer': 'https://tavernintern.app',
372
+ 'X-Title': 'TavernIntern',
373
+ };
374
+
375
+ export const OPENROUTER_KEYS = [
376
+ 'max_tokens',
377
+ 'temperature',
378
+ 'top_k',
379
+ 'top_p',
380
+ 'presence_penalty',
381
+ 'frequency_penalty',
382
+ 'repetition_penalty',
383
+ 'min_p',
384
+ 'top_a',
385
+ 'seed',
386
+ 'logit_bias',
387
+ 'model',
388
+ 'stream',
389
+ 'prompt',
390
+ 'stop',
391
+ 'provider',
392
+ 'include_reasoning',
393
+ ];
394
+
395
+ // https://github.com/vllm-project/vllm/blob/0f8a91401c89ac0a8018def3756829611b57727f/vllm/entrypoints/openai/protocol.py#L220
396
+ export const VLLM_KEYS = [
397
+ 'model',
398
+ 'prompt',
399
+ 'best_of',
400
+ 'echo',
401
+ 'frequency_penalty',
402
+ 'logit_bias',
403
+ 'logprobs',
404
+ 'max_tokens',
405
+ 'n',
406
+ 'presence_penalty',
407
+ 'seed',
408
+ 'stop',
409
+ 'stream',
410
+ 'suffix',
411
+ 'temperature',
412
+ 'top_p',
413
+ 'user',
414
+
415
+ 'use_beam_search',
416
+ 'top_k',
417
+ 'min_p',
418
+ 'repetition_penalty',
419
+ 'length_penalty',
420
+ 'early_stopping',
421
+ 'stop_token_ids',
422
+ 'ignore_eos',
423
+ 'min_tokens',
424
+ 'skip_special_tokens',
425
+ 'spaces_between_special_tokens',
426
+ 'truncate_prompt_tokens',
427
+
428
+ 'include_stop_str_in_output',
429
+ 'response_format',
430
+ 'guided_json',
431
+ 'guided_regex',
432
+ 'guided_choice',
433
+ 'guided_grammar',
434
+ 'guided_decoding_backend',
435
+ 'guided_whitespace_pattern',
436
+ ];
437
+
438
+ export const AZURE_OPENAI_KEYS = [
439
+ 'messages',
440
+ 'temperature',
441
+ 'frequency_penalty',
442
+ 'presence_penalty',
443
+ 'top_p',
444
+ 'max_tokens',
445
+ 'max_completion_tokens',
446
+ 'stream',
447
+ 'logit_bias',
448
+ 'stop',
449
+ 'n',
450
+ 'logprobs',
451
+ 'seed',
452
+ 'tools',
453
+ 'tool_choice',
454
+ 'reasoning_effort',
455
+ ];
456
+
457
+ export const OPENAI_VERBOSITY_MODELS = /^gpt-5/;
458
+
459
+ export const OPENAI_REASONING_EFFORT_MODELS = [
460
+ 'o1',
461
+ 'o3-mini',
462
+ 'o3-mini-2025-01-31',
463
+ 'o4-mini',
464
+ 'o4-mini-2025-04-16',
465
+ 'o3',
466
+ 'o3-2025-04-16',
467
+ 'gpt-5',
468
+ 'gpt-5-2025-08-07',
469
+ 'gpt-5-mini',
470
+ 'gpt-5-mini-2025-08-07',
471
+ 'gpt-5-nano',
472
+ 'gpt-5-nano-2025-08-07',
473
+ 'gpt-5.1',
474
+ 'gpt-5.1-2025-11-13',
475
+ 'gpt-5.1-chat-latest',
476
+ 'gpt-5.2',
477
+ 'gpt-5.2-2025-12-11',
478
+ 'gpt-5.2-chat-latest',
479
+ ];
480
+
481
+ export const OPENAI_REASONING_EFFORT_MAP = {
482
+ min: 'minimal',
483
+ };
484
+
485
+ export const LOG_LEVELS = {
486
+ DEBUG: 0,
487
+ INFO: 1,
488
+ WARN: 2,
489
+ ERROR: 3,
490
+ };
491
+
492
+ /**
493
+ * An array of supported media file extensions.
494
+ * This is used to validate file uploads and ensure that only supported media types are processed.
495
+ */
496
+ export const MEDIA_EXTENSIONS = [
497
+ 'bmp',
498
+ 'png',
499
+ 'jpg',
500
+ 'webp',
501
+ 'jpeg',
502
+ 'jfif',
503
+ 'gif',
504
+ 'mp4',
505
+ 'avi',
506
+ 'mov',
507
+ 'wmv',
508
+ 'flv',
509
+ 'webm',
510
+ '3gp',
511
+ 'mkv',
512
+ 'mpg',
513
+ 'mp3',
514
+ 'wav',
515
+ 'ogg',
516
+ 'flac',
517
+ 'aac',
518
+ 'm4a',
519
+ 'aiff',
520
+ ];
521
+
522
+ /**
523
+ * Bitwise flag-style media request types.
524
+ */
525
+ export const MEDIA_REQUEST_TYPE = {
526
+ IMAGE: 0b001,
527
+ VIDEO: 0b010,
528
+ AUDIO: 0b100,
529
+ };
530
+
531
+
532
+ export const ZAI_ENDPOINT = {
533
+ COMMON: 'common',
534
+ CODING: 'coding',
535
+ };
src/electron/Start.bat ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ @echo off
2
+ pushd %~dp0
3
+ call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev
4
+ npm run start server.js %*
5
+ pause
6
+ popd
src/electron/index.js ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app, BrowserWindow } from 'electron';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import yargs from 'yargs';
5
+ import { serverEvents, EVENT_NAMES } from '../server-events.js';
6
+
7
+ const cliArguments = yargs(process.argv)
8
+ .usage('Usage: <your-start-script> [options]')
9
+ .option('width', {
10
+ type: 'number',
11
+ default: 800,
12
+ describe: 'The width of the window',
13
+ })
14
+ .option('height', {
15
+ type: 'number',
16
+ default: 600,
17
+ describe: 'The height of the window',
18
+ })
19
+ .parseSync();
20
+
21
+ /** @type {string} The URL to load in the window. */
22
+ let appUrl;
23
+
24
+ function createTavernInternWindow() {
25
+ if (!appUrl) {
26
+ console.error('The server has not started yet.');
27
+ return;
28
+ }
29
+ new BrowserWindow({
30
+ height: cliArguments.height,
31
+ width: cliArguments.width,
32
+ }).loadURL(appUrl);
33
+ }
34
+
35
+ function startServer() {
36
+ return new Promise((_resolve, _reject) => {
37
+ serverEvents.addListener(EVENT_NAMES.SERVER_STARTED, ({ url }) => {
38
+ appUrl = url.toString();
39
+ createTavernInternWindow();
40
+ });
41
+ const sillyTavernRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
42
+ process.chdir(sillyTavernRoot);
43
+
44
+ import('../server-global.js');
45
+ });
46
+ }
47
+
48
+ app.whenReady().then(() => {
49
+ app.on('activate', () => {
50
+ if (BrowserWindow.getAllWindows().length === 0) {
51
+ createTavernInternWindow();
52
+ }
53
+ });
54
+
55
+ startServer();
56
+ });
57
+
58
+ app.on('window-all-closed', () => {
59
+ if (process.platform !== 'darwin') {
60
+ app.quit();
61
+ }
62
+ });
src/electron/package-lock.json ADDED
@@ -0,0 +1,802 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "sillytavern-electron",
3
+ "version": "1.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "sillytavern-electron",
9
+ "version": "1.0.0",
10
+ "license": "AGPL-3.0",
11
+ "dependencies": {
12
+ "electron": "^35.0.0"
13
+ }
14
+ },
15
+ "node_modules/@electron/get": {
16
+ "version": "2.0.3",
17
+ "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
18
+ "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
19
+ "license": "MIT",
20
+ "dependencies": {
21
+ "debug": "^4.1.1",
22
+ "env-paths": "^2.2.0",
23
+ "fs-extra": "^8.1.0",
24
+ "got": "^11.8.5",
25
+ "progress": "^2.0.3",
26
+ "semver": "^6.2.0",
27
+ "sumchecker": "^3.0.1"
28
+ },
29
+ "engines": {
30
+ "node": ">=12"
31
+ },
32
+ "optionalDependencies": {
33
+ "global-agent": "^3.0.0"
34
+ }
35
+ },
36
+ "node_modules/@sindresorhus/is": {
37
+ "version": "4.6.0",
38
+ "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
39
+ "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
40
+ "license": "MIT",
41
+ "engines": {
42
+ "node": ">=10"
43
+ },
44
+ "funding": {
45
+ "url": "https://github.com/sindresorhus/is?sponsor=1"
46
+ }
47
+ },
48
+ "node_modules/@szmarczak/http-timer": {
49
+ "version": "4.0.6",
50
+ "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
51
+ "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==",
52
+ "license": "MIT",
53
+ "dependencies": {
54
+ "defer-to-connect": "^2.0.0"
55
+ },
56
+ "engines": {
57
+ "node": ">=10"
58
+ }
59
+ },
60
+ "node_modules/@types/cacheable-request": {
61
+ "version": "6.0.3",
62
+ "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
63
+ "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==",
64
+ "license": "MIT",
65
+ "dependencies": {
66
+ "@types/http-cache-semantics": "*",
67
+ "@types/keyv": "^3.1.4",
68
+ "@types/node": "*",
69
+ "@types/responselike": "^1.0.0"
70
+ }
71
+ },
72
+ "node_modules/@types/http-cache-semantics": {
73
+ "version": "4.0.4",
74
+ "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
75
+ "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==",
76
+ "license": "MIT"
77
+ },
78
+ "node_modules/@types/keyv": {
79
+ "version": "3.1.4",
80
+ "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
81
+ "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==",
82
+ "license": "MIT",
83
+ "dependencies": {
84
+ "@types/node": "*"
85
+ }
86
+ },
87
+ "node_modules/@types/node": {
88
+ "version": "22.13.9",
89
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz",
90
+ "integrity": "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==",
91
+ "license": "MIT",
92
+ "dependencies": {
93
+ "undici-types": "~6.20.0"
94
+ }
95
+ },
96
+ "node_modules/@types/responselike": {
97
+ "version": "1.0.3",
98
+ "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
99
+ "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==",
100
+ "license": "MIT",
101
+ "dependencies": {
102
+ "@types/node": "*"
103
+ }
104
+ },
105
+ "node_modules/@types/yauzl": {
106
+ "version": "2.10.3",
107
+ "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
108
+ "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
109
+ "license": "MIT",
110
+ "optional": true,
111
+ "dependencies": {
112
+ "@types/node": "*"
113
+ }
114
+ },
115
+ "node_modules/boolean": {
116
+ "version": "3.2.0",
117
+ "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
118
+ "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
119
+ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
120
+ "license": "MIT",
121
+ "optional": true
122
+ },
123
+ "node_modules/buffer-crc32": {
124
+ "version": "0.2.13",
125
+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
126
+ "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
127
+ "license": "MIT",
128
+ "engines": {
129
+ "node": "*"
130
+ }
131
+ },
132
+ "node_modules/cacheable-lookup": {
133
+ "version": "5.0.4",
134
+ "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
135
+ "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==",
136
+ "license": "MIT",
137
+ "engines": {
138
+ "node": ">=10.6.0"
139
+ }
140
+ },
141
+ "node_modules/cacheable-request": {
142
+ "version": "7.0.4",
143
+ "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz",
144
+ "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==",
145
+ "license": "MIT",
146
+ "dependencies": {
147
+ "clone-response": "^1.0.2",
148
+ "get-stream": "^5.1.0",
149
+ "http-cache-semantics": "^4.0.0",
150
+ "keyv": "^4.0.0",
151
+ "lowercase-keys": "^2.0.0",
152
+ "normalize-url": "^6.0.1",
153
+ "responselike": "^2.0.0"
154
+ },
155
+ "engines": {
156
+ "node": ">=8"
157
+ }
158
+ },
159
+ "node_modules/clone-response": {
160
+ "version": "1.0.3",
161
+ "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
162
+ "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==",
163
+ "license": "MIT",
164
+ "dependencies": {
165
+ "mimic-response": "^1.0.0"
166
+ },
167
+ "funding": {
168
+ "url": "https://github.com/sponsors/sindresorhus"
169
+ }
170
+ },
171
+ "node_modules/debug": {
172
+ "version": "4.4.0",
173
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
174
+ "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
175
+ "license": "MIT",
176
+ "dependencies": {
177
+ "ms": "^2.1.3"
178
+ },
179
+ "engines": {
180
+ "node": ">=6.0"
181
+ },
182
+ "peerDependenciesMeta": {
183
+ "supports-color": {
184
+ "optional": true
185
+ }
186
+ }
187
+ },
188
+ "node_modules/decompress-response": {
189
+ "version": "6.0.0",
190
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
191
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
192
+ "license": "MIT",
193
+ "dependencies": {
194
+ "mimic-response": "^3.1.0"
195
+ },
196
+ "engines": {
197
+ "node": ">=10"
198
+ },
199
+ "funding": {
200
+ "url": "https://github.com/sponsors/sindresorhus"
201
+ }
202
+ },
203
+ "node_modules/decompress-response/node_modules/mimic-response": {
204
+ "version": "3.1.0",
205
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
206
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
207
+ "license": "MIT",
208
+ "engines": {
209
+ "node": ">=10"
210
+ },
211
+ "funding": {
212
+ "url": "https://github.com/sponsors/sindresorhus"
213
+ }
214
+ },
215
+ "node_modules/defer-to-connect": {
216
+ "version": "2.0.1",
217
+ "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
218
+ "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
219
+ "license": "MIT",
220
+ "engines": {
221
+ "node": ">=10"
222
+ }
223
+ },
224
+ "node_modules/define-data-property": {
225
+ "version": "1.1.4",
226
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
227
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
228
+ "license": "MIT",
229
+ "optional": true,
230
+ "dependencies": {
231
+ "es-define-property": "^1.0.0",
232
+ "es-errors": "^1.3.0",
233
+ "gopd": "^1.0.1"
234
+ },
235
+ "engines": {
236
+ "node": ">= 0.4"
237
+ },
238
+ "funding": {
239
+ "url": "https://github.com/sponsors/ljharb"
240
+ }
241
+ },
242
+ "node_modules/define-properties": {
243
+ "version": "1.2.1",
244
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
245
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
246
+ "license": "MIT",
247
+ "optional": true,
248
+ "dependencies": {
249
+ "define-data-property": "^1.0.1",
250
+ "has-property-descriptors": "^1.0.0",
251
+ "object-keys": "^1.1.1"
252
+ },
253
+ "engines": {
254
+ "node": ">= 0.4"
255
+ },
256
+ "funding": {
257
+ "url": "https://github.com/sponsors/ljharb"
258
+ }
259
+ },
260
+ "node_modules/detect-node": {
261
+ "version": "2.1.0",
262
+ "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
263
+ "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
264
+ "license": "MIT",
265
+ "optional": true
266
+ },
267
+ "node_modules/electron": {
268
+ "version": "35.7.5",
269
+ "resolved": "https://registry.npmjs.org/electron/-/electron-35.7.5.tgz",
270
+ "integrity": "sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw==",
271
+ "hasInstallScript": true,
272
+ "license": "MIT",
273
+ "dependencies": {
274
+ "@electron/get": "^2.0.0",
275
+ "@types/node": "^22.7.7",
276
+ "extract-zip": "^2.0.1"
277
+ },
278
+ "bin": {
279
+ "electron": "cli.js"
280
+ },
281
+ "engines": {
282
+ "node": ">= 12.20.55"
283
+ }
284
+ },
285
+ "node_modules/end-of-stream": {
286
+ "version": "1.4.4",
287
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
288
+ "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
289
+ "license": "MIT",
290
+ "dependencies": {
291
+ "once": "^1.4.0"
292
+ }
293
+ },
294
+ "node_modules/env-paths": {
295
+ "version": "2.2.1",
296
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
297
+ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
298
+ "license": "MIT",
299
+ "engines": {
300
+ "node": ">=6"
301
+ }
302
+ },
303
+ "node_modules/es-define-property": {
304
+ "version": "1.0.1",
305
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
306
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
307
+ "license": "MIT",
308
+ "optional": true,
309
+ "engines": {
310
+ "node": ">= 0.4"
311
+ }
312
+ },
313
+ "node_modules/es-errors": {
314
+ "version": "1.3.0",
315
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
316
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
317
+ "license": "MIT",
318
+ "optional": true,
319
+ "engines": {
320
+ "node": ">= 0.4"
321
+ }
322
+ },
323
+ "node_modules/es6-error": {
324
+ "version": "4.1.1",
325
+ "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
326
+ "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
327
+ "license": "MIT",
328
+ "optional": true
329
+ },
330
+ "node_modules/escape-string-regexp": {
331
+ "version": "4.0.0",
332
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
333
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
334
+ "license": "MIT",
335
+ "optional": true,
336
+ "engines": {
337
+ "node": ">=10"
338
+ },
339
+ "funding": {
340
+ "url": "https://github.com/sponsors/sindresorhus"
341
+ }
342
+ },
343
+ "node_modules/extract-zip": {
344
+ "version": "2.0.1",
345
+ "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
346
+ "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
347
+ "license": "BSD-2-Clause",
348
+ "dependencies": {
349
+ "debug": "^4.1.1",
350
+ "get-stream": "^5.1.0",
351
+ "yauzl": "^2.10.0"
352
+ },
353
+ "bin": {
354
+ "extract-zip": "cli.js"
355
+ },
356
+ "engines": {
357
+ "node": ">= 10.17.0"
358
+ },
359
+ "optionalDependencies": {
360
+ "@types/yauzl": "^2.9.1"
361
+ }
362
+ },
363
+ "node_modules/fd-slicer": {
364
+ "version": "1.1.0",
365
+ "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
366
+ "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
367
+ "license": "MIT",
368
+ "dependencies": {
369
+ "pend": "~1.2.0"
370
+ }
371
+ },
372
+ "node_modules/fs-extra": {
373
+ "version": "8.1.0",
374
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
375
+ "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
376
+ "license": "MIT",
377
+ "dependencies": {
378
+ "graceful-fs": "^4.2.0",
379
+ "jsonfile": "^4.0.0",
380
+ "universalify": "^0.1.0"
381
+ },
382
+ "engines": {
383
+ "node": ">=6 <7 || >=8"
384
+ }
385
+ },
386
+ "node_modules/get-stream": {
387
+ "version": "5.2.0",
388
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
389
+ "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
390
+ "license": "MIT",
391
+ "dependencies": {
392
+ "pump": "^3.0.0"
393
+ },
394
+ "engines": {
395
+ "node": ">=8"
396
+ },
397
+ "funding": {
398
+ "url": "https://github.com/sponsors/sindresorhus"
399
+ }
400
+ },
401
+ "node_modules/global-agent": {
402
+ "version": "3.0.0",
403
+ "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
404
+ "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
405
+ "license": "BSD-3-Clause",
406
+ "optional": true,
407
+ "dependencies": {
408
+ "boolean": "^3.0.1",
409
+ "es6-error": "^4.1.1",
410
+ "matcher": "^3.0.0",
411
+ "roarr": "^2.15.3",
412
+ "semver": "^7.3.2",
413
+ "serialize-error": "^7.0.1"
414
+ },
415
+ "engines": {
416
+ "node": ">=10.0"
417
+ }
418
+ },
419
+ "node_modules/global-agent/node_modules/semver": {
420
+ "version": "7.7.1",
421
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
422
+ "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
423
+ "license": "ISC",
424
+ "optional": true,
425
+ "bin": {
426
+ "semver": "bin/semver.js"
427
+ },
428
+ "engines": {
429
+ "node": ">=10"
430
+ }
431
+ },
432
+ "node_modules/globalthis": {
433
+ "version": "1.0.4",
434
+ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
435
+ "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
436
+ "license": "MIT",
437
+ "optional": true,
438
+ "dependencies": {
439
+ "define-properties": "^1.2.1",
440
+ "gopd": "^1.0.1"
441
+ },
442
+ "engines": {
443
+ "node": ">= 0.4"
444
+ },
445
+ "funding": {
446
+ "url": "https://github.com/sponsors/ljharb"
447
+ }
448
+ },
449
+ "node_modules/gopd": {
450
+ "version": "1.2.0",
451
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
452
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
453
+ "license": "MIT",
454
+ "optional": true,
455
+ "engines": {
456
+ "node": ">= 0.4"
457
+ },
458
+ "funding": {
459
+ "url": "https://github.com/sponsors/ljharb"
460
+ }
461
+ },
462
+ "node_modules/got": {
463
+ "version": "11.8.6",
464
+ "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz",
465
+ "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==",
466
+ "license": "MIT",
467
+ "dependencies": {
468
+ "@sindresorhus/is": "^4.0.0",
469
+ "@szmarczak/http-timer": "^4.0.5",
470
+ "@types/cacheable-request": "^6.0.1",
471
+ "@types/responselike": "^1.0.0",
472
+ "cacheable-lookup": "^5.0.3",
473
+ "cacheable-request": "^7.0.2",
474
+ "decompress-response": "^6.0.0",
475
+ "http2-wrapper": "^1.0.0-beta.5.2",
476
+ "lowercase-keys": "^2.0.0",
477
+ "p-cancelable": "^2.0.0",
478
+ "responselike": "^2.0.0"
479
+ },
480
+ "engines": {
481
+ "node": ">=10.19.0"
482
+ },
483
+ "funding": {
484
+ "url": "https://github.com/sindresorhus/got?sponsor=1"
485
+ }
486
+ },
487
+ "node_modules/graceful-fs": {
488
+ "version": "4.2.11",
489
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
490
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
491
+ "license": "ISC"
492
+ },
493
+ "node_modules/has-property-descriptors": {
494
+ "version": "1.0.2",
495
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
496
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
497
+ "license": "MIT",
498
+ "optional": true,
499
+ "dependencies": {
500
+ "es-define-property": "^1.0.0"
501
+ },
502
+ "funding": {
503
+ "url": "https://github.com/sponsors/ljharb"
504
+ }
505
+ },
506
+ "node_modules/http-cache-semantics": {
507
+ "version": "4.1.1",
508
+ "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
509
+ "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==",
510
+ "license": "BSD-2-Clause"
511
+ },
512
+ "node_modules/http2-wrapper": {
513
+ "version": "1.0.3",
514
+ "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
515
+ "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==",
516
+ "license": "MIT",
517
+ "dependencies": {
518
+ "quick-lru": "^5.1.1",
519
+ "resolve-alpn": "^1.0.0"
520
+ },
521
+ "engines": {
522
+ "node": ">=10.19.0"
523
+ }
524
+ },
525
+ "node_modules/json-buffer": {
526
+ "version": "3.0.1",
527
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
528
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
529
+ "license": "MIT"
530
+ },
531
+ "node_modules/json-stringify-safe": {
532
+ "version": "5.0.1",
533
+ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
534
+ "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
535
+ "license": "ISC",
536
+ "optional": true
537
+ },
538
+ "node_modules/jsonfile": {
539
+ "version": "4.0.0",
540
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
541
+ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
542
+ "license": "MIT",
543
+ "optionalDependencies": {
544
+ "graceful-fs": "^4.1.6"
545
+ }
546
+ },
547
+ "node_modules/keyv": {
548
+ "version": "4.5.4",
549
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
550
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
551
+ "license": "MIT",
552
+ "dependencies": {
553
+ "json-buffer": "3.0.1"
554
+ }
555
+ },
556
+ "node_modules/lowercase-keys": {
557
+ "version": "2.0.0",
558
+ "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
559
+ "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
560
+ "license": "MIT",
561
+ "engines": {
562
+ "node": ">=8"
563
+ }
564
+ },
565
+ "node_modules/matcher": {
566
+ "version": "3.0.0",
567
+ "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
568
+ "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
569
+ "license": "MIT",
570
+ "optional": true,
571
+ "dependencies": {
572
+ "escape-string-regexp": "^4.0.0"
573
+ },
574
+ "engines": {
575
+ "node": ">=10"
576
+ }
577
+ },
578
+ "node_modules/mimic-response": {
579
+ "version": "1.0.1",
580
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
581
+ "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
582
+ "license": "MIT",
583
+ "engines": {
584
+ "node": ">=4"
585
+ }
586
+ },
587
+ "node_modules/ms": {
588
+ "version": "2.1.3",
589
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
590
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
591
+ "license": "MIT"
592
+ },
593
+ "node_modules/normalize-url": {
594
+ "version": "6.1.0",
595
+ "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
596
+ "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
597
+ "license": "MIT",
598
+ "engines": {
599
+ "node": ">=10"
600
+ },
601
+ "funding": {
602
+ "url": "https://github.com/sponsors/sindresorhus"
603
+ }
604
+ },
605
+ "node_modules/object-keys": {
606
+ "version": "1.1.1",
607
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
608
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
609
+ "license": "MIT",
610
+ "optional": true,
611
+ "engines": {
612
+ "node": ">= 0.4"
613
+ }
614
+ },
615
+ "node_modules/once": {
616
+ "version": "1.4.0",
617
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
618
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
619
+ "license": "ISC",
620
+ "dependencies": {
621
+ "wrappy": "1"
622
+ }
623
+ },
624
+ "node_modules/p-cancelable": {
625
+ "version": "2.1.1",
626
+ "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
627
+ "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
628
+ "license": "MIT",
629
+ "engines": {
630
+ "node": ">=8"
631
+ }
632
+ },
633
+ "node_modules/pend": {
634
+ "version": "1.2.0",
635
+ "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
636
+ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
637
+ "license": "MIT"
638
+ },
639
+ "node_modules/progress": {
640
+ "version": "2.0.3",
641
+ "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
642
+ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
643
+ "license": "MIT",
644
+ "engines": {
645
+ "node": ">=0.4.0"
646
+ }
647
+ },
648
+ "node_modules/pump": {
649
+ "version": "3.0.2",
650
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz",
651
+ "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==",
652
+ "license": "MIT",
653
+ "dependencies": {
654
+ "end-of-stream": "^1.1.0",
655
+ "once": "^1.3.1"
656
+ }
657
+ },
658
+ "node_modules/quick-lru": {
659
+ "version": "5.1.1",
660
+ "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
661
+ "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
662
+ "license": "MIT",
663
+ "engines": {
664
+ "node": ">=10"
665
+ },
666
+ "funding": {
667
+ "url": "https://github.com/sponsors/sindresorhus"
668
+ }
669
+ },
670
+ "node_modules/resolve-alpn": {
671
+ "version": "1.2.1",
672
+ "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
673
+ "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
674
+ "license": "MIT"
675
+ },
676
+ "node_modules/responselike": {
677
+ "version": "2.0.1",
678
+ "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz",
679
+ "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==",
680
+ "license": "MIT",
681
+ "dependencies": {
682
+ "lowercase-keys": "^2.0.0"
683
+ },
684
+ "funding": {
685
+ "url": "https://github.com/sponsors/sindresorhus"
686
+ }
687
+ },
688
+ "node_modules/roarr": {
689
+ "version": "2.15.4",
690
+ "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
691
+ "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
692
+ "license": "BSD-3-Clause",
693
+ "optional": true,
694
+ "dependencies": {
695
+ "boolean": "^3.0.1",
696
+ "detect-node": "^2.0.4",
697
+ "globalthis": "^1.0.1",
698
+ "json-stringify-safe": "^5.0.1",
699
+ "semver-compare": "^1.0.0",
700
+ "sprintf-js": "^1.1.2"
701
+ },
702
+ "engines": {
703
+ "node": ">=8.0"
704
+ }
705
+ },
706
+ "node_modules/semver": {
707
+ "version": "6.3.1",
708
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
709
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
710
+ "license": "ISC",
711
+ "bin": {
712
+ "semver": "bin/semver.js"
713
+ }
714
+ },
715
+ "node_modules/semver-compare": {
716
+ "version": "1.0.0",
717
+ "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
718
+ "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
719
+ "license": "MIT",
720
+ "optional": true
721
+ },
722
+ "node_modules/serialize-error": {
723
+ "version": "7.0.1",
724
+ "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
725
+ "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
726
+ "license": "MIT",
727
+ "optional": true,
728
+ "dependencies": {
729
+ "type-fest": "^0.13.1"
730
+ },
731
+ "engines": {
732
+ "node": ">=10"
733
+ },
734
+ "funding": {
735
+ "url": "https://github.com/sponsors/sindresorhus"
736
+ }
737
+ },
738
+ "node_modules/sprintf-js": {
739
+ "version": "1.1.3",
740
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
741
+ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
742
+ "license": "BSD-3-Clause",
743
+ "optional": true
744
+ },
745
+ "node_modules/sumchecker": {
746
+ "version": "3.0.1",
747
+ "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
748
+ "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
749
+ "license": "Apache-2.0",
750
+ "dependencies": {
751
+ "debug": "^4.1.0"
752
+ },
753
+ "engines": {
754
+ "node": ">= 8.0"
755
+ }
756
+ },
757
+ "node_modules/type-fest": {
758
+ "version": "0.13.1",
759
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
760
+ "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
761
+ "license": "(MIT OR CC0-1.0)",
762
+ "optional": true,
763
+ "engines": {
764
+ "node": ">=10"
765
+ },
766
+ "funding": {
767
+ "url": "https://github.com/sponsors/sindresorhus"
768
+ }
769
+ },
770
+ "node_modules/undici-types": {
771
+ "version": "6.20.0",
772
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
773
+ "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
774
+ "license": "MIT"
775
+ },
776
+ "node_modules/universalify": {
777
+ "version": "0.1.2",
778
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
779
+ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
780
+ "license": "MIT",
781
+ "engines": {
782
+ "node": ">= 4.0.0"
783
+ }
784
+ },
785
+ "node_modules/wrappy": {
786
+ "version": "1.0.2",
787
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
788
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
789
+ "license": "ISC"
790
+ },
791
+ "node_modules/yauzl": {
792
+ "version": "2.10.0",
793
+ "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
794
+ "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
795
+ "license": "MIT",
796
+ "dependencies": {
797
+ "buffer-crc32": "~0.2.3",
798
+ "fd-slicer": "~1.1.0"
799
+ }
800
+ }
801
+ }
802
+ }
src/electron/package.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "sillytavern-electron",
3
+ "version": "1.0.0",
4
+ "description": "Electron server for SillyTavern",
5
+ "license": "AGPL-3.0",
6
+ "author": "",
7
+ "type": "module",
8
+ "main": "index.js",
9
+ "scripts": {
10
+ "test": "echo \"Error: no test specified\" && exit 1",
11
+ "start": "electron ."
12
+ },
13
+ "dependencies": {
14
+ "electron": "^35.0.0"
15
+ }
16
+ }
src/electron/start.sh ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+
3
+ # Make sure pwd is the directory of the script
4
+ cd "$(dirname "$0")"
5
+
6
+ echo "Assuming nodejs and npm is already installed. If you haven't installed them already, do so now"
7
+ echo "Installing Electron Wrapper's Node Modules..."
8
+ npm i --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev
9
+
10
+ echo "Starting Electron Wrapper..."
11
+ npm run start -- "$@"
src/endpoints/anthropic.js ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fetch from 'node-fetch';
2
+ import express from 'express';
3
+
4
+ import { readSecret, SECRET_KEYS } from './secrets.js';
5
+
6
+ export const router = express.Router();
7
+
8
+ router.post('/caption-image', 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.debug('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
+ });
45
+
46
+ if (!result.ok) {
47
+ const text = await result.text();
48
+ console.warn(`Claude API returned error: ${result.status} ${result.statusText}`, text);
49
+ return response.status(result.status).send({ error: true });
50
+ }
51
+
52
+ /** @type {any} */
53
+ const generateResponseJson = await result.json();
54
+ const caption = generateResponseJson.content[0].text;
55
+ console.debug('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
+ });
src/endpoints/assets.js ADDED
@@ -0,0 +1,370 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { finished } from 'node:stream/promises';
4
+
5
+ import mime from 'mime-types';
6
+ import express from 'express';
7
+ import sanitize from 'sanitize-filename';
8
+ import fetch from 'node-fetch';
9
+
10
+ import { UNSAFE_EXTENSIONS } from '../constants.js';
11
+ import { clientRelativePath } from '../util.js';
12
+
13
+ const VALID_CATEGORIES = ['bgm', 'ambient', 'blip', 'live2d', 'vrm', 'character', 'temp'];
14
+
15
+ /**
16
+ * Validates the input filename for the asset.
17
+ * @param {string} inputFilename Input filename
18
+ * @returns {{error: boolean, message?: string}} Whether validation failed, and why if so
19
+ */
20
+ export function validateAssetFileName(inputFilename) {
21
+ if (!/^[a-zA-Z0-9_\-.]+$/.test(inputFilename)) {
22
+ return {
23
+ error: true,
24
+ message: 'Illegal character in filename; only alphanumeric, \'_\', \'-\' are accepted.',
25
+ };
26
+ }
27
+
28
+ const inputExtension = path.extname(inputFilename).toLowerCase();
29
+ if (UNSAFE_EXTENSIONS.some(ext => ext === inputExtension)) {
30
+ return {
31
+ error: true,
32
+ message: 'Forbidden file extension.',
33
+ };
34
+ }
35
+
36
+ if (inputFilename.startsWith('.')) {
37
+ return {
38
+ error: true,
39
+ message: 'Filename cannot start with \'.\'',
40
+ };
41
+ }
42
+
43
+ if (sanitize(inputFilename) !== inputFilename) {
44
+ return {
45
+ error: true,
46
+ message: 'Reserved or long filename.',
47
+ };
48
+ }
49
+
50
+ return { error: false };
51
+ }
52
+
53
+ /**
54
+ * Recursive function to get files
55
+ * @param {string} dir - The directory to search for files
56
+ * @param {string[]} files - The array of files to return
57
+ * @returns {string[]} - The array of files
58
+ */
59
+ function getFiles(dir, files = []) {
60
+ if (!fs.existsSync(dir)) return files;
61
+
62
+ // Get an array of all files and directories in the passed directory using fs.readdirSync
63
+ const fileList = fs.readdirSync(dir, { withFileTypes: true });
64
+ // Create the full path of the file/directory by concatenating the passed directory and file/directory name
65
+ for (const file of fileList) {
66
+ const name = path.join(dir, file.name);
67
+ // Check if the current file/directory is a directory using fs.statSync
68
+ if (file.isDirectory()) {
69
+ // If it is a directory, recursively call the getFiles function with the directory path and the files array
70
+ getFiles(name, files);
71
+ } else {
72
+ // If it is a file, push the full path to the files array
73
+ files.push(name);
74
+ }
75
+ }
76
+ return files;
77
+ }
78
+
79
+ /**
80
+ * Ensure that the asset folders exist.
81
+ * @param {import('../users.js').UserDirectoryList} directories - The user's directories
82
+ */
83
+ function ensureFoldersExist(directories) {
84
+ const folderPath = path.join(directories.assets);
85
+
86
+ for (const category of VALID_CATEGORIES) {
87
+ const assetCategoryPath = path.join(folderPath, category);
88
+ if (fs.existsSync(assetCategoryPath) && !fs.statSync(assetCategoryPath).isDirectory()) {
89
+ fs.unlinkSync(assetCategoryPath);
90
+ }
91
+ if (!fs.existsSync(assetCategoryPath)) {
92
+ fs.mkdirSync(assetCategoryPath, { recursive: true });
93
+ }
94
+ }
95
+ }
96
+
97
+ export const router = express.Router();
98
+
99
+ /**
100
+ * HTTP POST handler function to retrieve name of all files of a given folder path.
101
+ *
102
+ * @param {Object} request - HTTP Request object. Require folder path in query
103
+ * @param {Object} response - HTTP Response object will contain a list of file path.
104
+ *
105
+ * @returns {void}
106
+ */
107
+ router.post('/get', async (request, response) => {
108
+ const folderPath = path.join(request.user.directories.assets);
109
+ let output = {};
110
+
111
+ try {
112
+ if (fs.existsSync(folderPath) && fs.statSync(folderPath).isDirectory()) {
113
+
114
+ ensureFoldersExist(request.user.directories);
115
+
116
+ const folders = fs.readdirSync(folderPath, { withFileTypes: true })
117
+ .filter(file => file.isDirectory());
118
+
119
+ for (const { name: folder } of folders) {
120
+ if (folder == 'temp')
121
+ continue;
122
+
123
+ // Live2d assets
124
+ if (folder == 'live2d') {
125
+ output[folder] = [];
126
+ const live2d_folder = path.normalize(path.join(folderPath, folder));
127
+ const files = getFiles(live2d_folder);
128
+ //console.debug("FILE FOUND:",files)
129
+ for (let file of files) {
130
+ if (file.includes('model') && file.endsWith('.json')) {
131
+ //console.debug("Asset live2d model found:",file)
132
+ output[folder].push(clientRelativePath(request.user.directories.root, file));
133
+ }
134
+ }
135
+ continue;
136
+ }
137
+
138
+ // VRM assets
139
+ if (folder == 'vrm') {
140
+ output[folder] = { 'model': [], 'animation': [] };
141
+ // Extract models
142
+ const vrm_model_folder = path.normalize(path.join(folderPath, 'vrm', 'model'));
143
+ let files = getFiles(vrm_model_folder);
144
+ //console.debug("FILE FOUND:",files)
145
+ for (let file of files) {
146
+ if (!file.endsWith('.placeholder')) {
147
+ //console.debug("Asset VRM model found:",file)
148
+ output['vrm']['model'].push(clientRelativePath(request.user.directories.root, file));
149
+ }
150
+ }
151
+
152
+ // Extract models
153
+ const vrm_animation_folder = path.normalize(path.join(folderPath, 'vrm', 'animation'));
154
+ files = getFiles(vrm_animation_folder);
155
+ //console.debug("FILE FOUND:",files)
156
+ for (let file of files) {
157
+ if (!file.endsWith('.placeholder')) {
158
+ //console.debug("Asset VRM animation found:",file)
159
+ output['vrm']['animation'].push(clientRelativePath(request.user.directories.root, file));
160
+ }
161
+ }
162
+ continue;
163
+ }
164
+
165
+ // Other assets (bgm/ambient/blip)
166
+ const files = fs.readdirSync(path.join(folderPath, folder))
167
+ .filter(filename => {
168
+ return filename != '.placeholder';
169
+ });
170
+ output[folder] = [];
171
+ for (const file of files) {
172
+ output[folder].push(`assets/${folder}/${file}`);
173
+ }
174
+ }
175
+ }
176
+ }
177
+ catch (err) {
178
+ console.error(err);
179
+ }
180
+ return response.send(output);
181
+ });
182
+
183
+ /**
184
+ * HTTP POST handler function to download the requested asset.
185
+ *
186
+ * @param {Object} request - HTTP Request object, expects a url, a category and a filename.
187
+ * @param {Object} response - HTTP Response only gives status.
188
+ *
189
+ * @returns {void}
190
+ */
191
+ router.post('/download', async (request, response) => {
192
+ const url = request.body.url;
193
+ const inputCategory = request.body.category;
194
+
195
+ // Check category
196
+ let category = null;
197
+ for (let i of VALID_CATEGORIES)
198
+ if (i == inputCategory)
199
+ category = i;
200
+
201
+ if (category === null) {
202
+ console.error('Bad request: unsupported asset category.');
203
+ return response.sendStatus(400);
204
+ }
205
+
206
+ // Validate filename
207
+ ensureFoldersExist(request.user.directories);
208
+ const validation = validateAssetFileName(request.body.filename);
209
+ if (validation.error)
210
+ return response.status(400).send(validation.message);
211
+
212
+ const temp_path = path.join(request.user.directories.assets, 'temp', request.body.filename);
213
+ const file_path = path.join(request.user.directories.assets, category, request.body.filename);
214
+ console.info('Request received to download', url, 'to', file_path);
215
+
216
+ try {
217
+ // Download to temp
218
+ const res = await fetch(url);
219
+ if (!res.ok || res.body === null) {
220
+ throw new Error(`Unexpected response ${res.statusText}`);
221
+ }
222
+ const destination = path.resolve(temp_path);
223
+ // Delete if previous download failed
224
+ if (fs.existsSync(temp_path)) {
225
+ fs.unlink(temp_path, (err) => {
226
+ if (err) throw err;
227
+ });
228
+ }
229
+ const fileStream = fs.createWriteStream(destination, { flags: 'wx' });
230
+ // @ts-ignore
231
+ await finished(res.body.pipe(fileStream));
232
+
233
+ if (category === 'character') {
234
+ const fileContent = fs.readFileSync(temp_path);
235
+ const contentType = mime.lookup(temp_path) || 'application/octet-stream';
236
+ response.setHeader('Content-Type', contentType);
237
+ response.send(fileContent);
238
+ fs.unlinkSync(temp_path);
239
+ return;
240
+ }
241
+
242
+ // Move into asset place
243
+ console.info('Download finished, moving file from', temp_path, 'to', file_path);
244
+ fs.copyFileSync(temp_path, file_path);
245
+ fs.unlinkSync(temp_path);
246
+ response.sendStatus(200);
247
+ }
248
+ catch (error) {
249
+ console.error(error);
250
+ response.sendStatus(500);
251
+ }
252
+ });
253
+
254
+ /**
255
+ * HTTP POST handler function to delete the requested asset.
256
+ *
257
+ * @param {Object} request - HTTP Request object, expects a category and a filename
258
+ * @param {Object} response - HTTP Response only gives stats.
259
+ *
260
+ * @returns {void}
261
+ */
262
+ router.post('/delete', async (request, response) => {
263
+ const inputCategory = request.body.category;
264
+
265
+ // Check category
266
+ let category = null;
267
+ for (let i of VALID_CATEGORIES)
268
+ if (i == inputCategory)
269
+ category = i;
270
+
271
+ if (category === null) {
272
+ console.error('Bad request: unsupported asset category.');
273
+ return response.sendStatus(400);
274
+ }
275
+
276
+ // Validate filename
277
+ const validation = validateAssetFileName(request.body.filename);
278
+ if (validation.error)
279
+ return response.status(400).send(validation.message);
280
+
281
+ const file_path = path.join(request.user.directories.assets, category, request.body.filename);
282
+ console.info('Request received to delete', category, file_path);
283
+
284
+ try {
285
+ // Delete if previous download failed
286
+ if (fs.existsSync(file_path)) {
287
+ fs.unlink(file_path, (err) => {
288
+ if (err) throw err;
289
+ });
290
+ console.info('Asset deleted.');
291
+ }
292
+ else {
293
+ console.error('Asset not found.');
294
+ response.sendStatus(400);
295
+ }
296
+ // Move into asset place
297
+ response.sendStatus(200);
298
+ }
299
+ catch (error) {
300
+ console.error(error);
301
+ response.sendStatus(500);
302
+ }
303
+ });
304
+
305
+ ///////////////////////////////
306
+ /**
307
+ * HTTP POST handler function to retrieve a character background music list.
308
+ *
309
+ * @param {Object} request - HTTP Request object, expects a character name in the query.
310
+ * @param {Object} response - HTTP Response object will contain a list of audio file path.
311
+ *
312
+ * @returns {void}
313
+ */
314
+ router.post('/character', async (request, response) => {
315
+ if (request.query.name === undefined) return response.sendStatus(400);
316
+
317
+ // For backwards compatibility, don't reject invalid character names, just sanitize them
318
+ const name = sanitize(request.query.name.toString());
319
+ const inputCategory = request.query.category;
320
+
321
+ // Check category
322
+ let category = null;
323
+ for (let i of VALID_CATEGORIES)
324
+ if (i == inputCategory)
325
+ category = i;
326
+
327
+ if (category === null) {
328
+ console.error('Bad request: unsupported asset category.');
329
+ return response.sendStatus(400);
330
+ }
331
+
332
+ const folderPath = path.join(request.user.directories.characters, name, category);
333
+
334
+ let output = [];
335
+ try {
336
+ if (fs.existsSync(folderPath) && fs.statSync(folderPath).isDirectory()) {
337
+
338
+ // Live2d assets
339
+ if (category == 'live2d') {
340
+ const folders = fs.readdirSync(folderPath, { withFileTypes: true });
341
+ for (const folderInfo of folders) {
342
+ if (!folderInfo.isDirectory()) continue;
343
+
344
+ const modelFolder = folderInfo.name;
345
+ const live2dModelPath = path.join(folderPath, modelFolder);
346
+ for (let file of fs.readdirSync(live2dModelPath)) {
347
+ //console.debug("Character live2d model found:", file)
348
+ if (file.includes('model') && file.endsWith('.json'))
349
+ output.push(path.join('characters', name, category, modelFolder, file));
350
+ }
351
+ }
352
+ return response.send(output);
353
+ }
354
+
355
+ // Other assets
356
+ const files = fs.readdirSync(folderPath)
357
+ .filter(filename => {
358
+ return filename != '.placeholder';
359
+ });
360
+
361
+ for (let i of files)
362
+ output.push(`/characters/${name}/${category}/${i}`);
363
+ }
364
+ return response.send(output);
365
+ }
366
+ catch (err) {
367
+ console.error(err);
368
+ return response.sendStatus(500);
369
+ }
370
+ });
src/endpoints/avatars.js ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+
4
+ import express from 'express';
5
+ import sanitize from 'sanitize-filename';
6
+ import { Jimp } from '../jimp.js';
7
+ import { sync as writeFileAtomicSync } from 'write-file-atomic';
8
+
9
+ import { getImages, tryParse } from '../util.js';
10
+ import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
11
+ import { applyAvatarCropResize } from './characters.js';
12
+ import { invalidateThumbnail } from './thumbnails.js';
13
+ import cacheBuster from '../middleware/cacheBuster.js';
14
+
15
+ export const router = express.Router();
16
+
17
+ router.post('/get', function (request, response) {
18
+ const images = getImages(request.user.directories.avatars);
19
+ response.send(images);
20
+ });
21
+
22
+ router.post('/delete', getFileNameValidationFunction('avatar'), function (request, response) {
23
+ if (!request.body) return response.sendStatus(400);
24
+
25
+ if (request.body.avatar !== sanitize(request.body.avatar)) {
26
+ console.error('Malicious avatar name prevented');
27
+ return response.sendStatus(403);
28
+ }
29
+
30
+ const fileName = path.join(request.user.directories.avatars, sanitize(request.body.avatar));
31
+
32
+ if (fs.existsSync(fileName)) {
33
+ fs.unlinkSync(fileName);
34
+ invalidateThumbnail(request.user.directories, 'persona', sanitize(request.body.avatar));
35
+ return response.send({ result: 'ok' });
36
+ }
37
+
38
+ return response.sendStatus(404);
39
+ });
40
+
41
+ router.post('/upload', getFileNameValidationFunction('overwrite_name'), async (request, response) => {
42
+ if (!request.file) return response.sendStatus(400);
43
+
44
+ try {
45
+ const pathToUpload = path.join(request.file.destination, request.file.filename);
46
+ const crop = tryParse(request.query.crop);
47
+ const rawImg = await Jimp.read(pathToUpload);
48
+ const image = await applyAvatarCropResize(rawImg, crop);
49
+
50
+ // Remove previous thumbnail and bust cache if overwriting
51
+ if (request.body.overwrite_name) {
52
+ invalidateThumbnail(request.user.directories, 'persona', sanitize(request.body.overwrite_name));
53
+ cacheBuster.bust(request, response);
54
+ }
55
+
56
+ const filename = sanitize(request.body.overwrite_name || `${Date.now()}.png`);
57
+ const pathToNewFile = path.join(request.user.directories.avatars, filename);
58
+ writeFileAtomicSync(pathToNewFile, image);
59
+ fs.unlinkSync(pathToUpload);
60
+ return response.send({ path: filename });
61
+ } catch (err) {
62
+ console.error('Error uploading user avatar:', err);
63
+ return response.status(400).send('Is not a valid image');
64
+ }
65
+ });
src/endpoints/azure.js ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fetch from 'node-fetch';
2
+ import { Router } from 'express';
3
+
4
+ import { readSecret, SECRET_KEYS } from './secrets.js';
5
+
6
+ export const router = Router();
7
+
8
+ router.post('/list', async (req, res) => {
9
+ try {
10
+ const key = readSecret(req.user.directories, SECRET_KEYS.AZURE_TTS);
11
+
12
+ if (!key) {
13
+ console.warn('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.warn('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.warn('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', async (req, res) => {
47
+ try {
48
+ const key = readSecret(req.user.directories, SECRET_KEYS.AZURE_TTS);
49
+
50
+ if (!key) {
51
+ console.warn('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.warn('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': 'webm-24khz-16bit-mono-opus',
72
+ },
73
+ body: ssml,
74
+ });
75
+
76
+ if (!response.ok) {
77
+ console.warn('Azure Request failed', response.status, response.statusText);
78
+ return res.sendStatus(500);
79
+ }
80
+
81
+ const audio = Buffer.from(await response.arrayBuffer());
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
+ });
src/endpoints/backends/chat-completions.js ADDED
The diff for this file is too large to render. See raw diff
 
src/endpoints/backends/kobold.js ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs';
2
+ import express from 'express';
3
+ import fetch from 'node-fetch';
4
+
5
+ import { forwardFetchResponse, delay } from '../../util.js';
6
+ import { getOverrideHeaders, setAdditionalHeaders, setAdditionalHeadersByType } from '../../additional-headers.js';
7
+ import { TEXTGEN_TYPES } from '../../constants.js';
8
+
9
+ export const router = express.Router();
10
+
11
+ router.post('/generate', async function (request, response_generate) {
12
+ if (!request.body) return response_generate.sendStatus(400);
13
+
14
+ if (request.body.api_server.indexOf('localhost') != -1) {
15
+ request.body.api_server = request.body.api_server.replace('localhost', '127.0.0.1');
16
+ }
17
+
18
+ const request_prompt = request.body.prompt;
19
+ const controller = new AbortController();
20
+ request.socket.removeAllListeners('close');
21
+ request.socket.on('close', async function () {
22
+ if (request.body.can_abort && !response_generate.writableEnded) {
23
+ try {
24
+ console.info('Aborting Kobold generation...');
25
+ // send abort signal to koboldcpp
26
+ const abortResponse = await fetch(`${request.body.api_server}/extra/abort`, {
27
+ method: 'POST',
28
+ });
29
+
30
+ if (!abortResponse.ok) {
31
+ console.error('Error sending abort request to Kobold:', abortResponse.status);
32
+ }
33
+ } catch (error) {
34
+ console.error(error);
35
+ }
36
+ }
37
+ controller.abort();
38
+ });
39
+
40
+ let this_settings = {
41
+ prompt: request_prompt,
42
+ use_story: false,
43
+ use_memory: false,
44
+ use_authors_note: false,
45
+ use_world_info: false,
46
+ max_context_length: request.body.max_context_length,
47
+ max_length: request.body.max_length,
48
+ };
49
+
50
+ if (!request.body.gui_settings) {
51
+ this_settings = {
52
+ prompt: request_prompt,
53
+ use_story: false,
54
+ use_memory: false,
55
+ use_authors_note: false,
56
+ use_world_info: false,
57
+ max_context_length: request.body.max_context_length,
58
+ max_length: request.body.max_length,
59
+ rep_pen: request.body.rep_pen,
60
+ rep_pen_range: request.body.rep_pen_range,
61
+ rep_pen_slope: request.body.rep_pen_slope,
62
+ temperature: request.body.temperature,
63
+ tfs: request.body.tfs,
64
+ top_a: request.body.top_a,
65
+ top_k: request.body.top_k,
66
+ top_p: request.body.top_p,
67
+ min_p: request.body.min_p,
68
+ typical: request.body.typical,
69
+ sampler_order: request.body.sampler_order,
70
+ singleline: !!request.body.singleline,
71
+ use_default_badwordsids: request.body.use_default_badwordsids,
72
+ mirostat: request.body.mirostat,
73
+ mirostat_eta: request.body.mirostat_eta,
74
+ mirostat_tau: request.body.mirostat_tau,
75
+ grammar: request.body.grammar,
76
+ sampler_seed: request.body.sampler_seed,
77
+ };
78
+ if (request.body.stop_sequence) {
79
+ this_settings['stop_sequence'] = request.body.stop_sequence;
80
+ }
81
+ }
82
+
83
+ console.debug(this_settings);
84
+ const args = {
85
+ body: JSON.stringify(this_settings),
86
+ headers: Object.assign(
87
+ { 'Content-Type': 'application/json' },
88
+ getOverrideHeaders((new URL(request.body.api_server))?.host),
89
+ ),
90
+ signal: controller.signal,
91
+ };
92
+
93
+ const MAX_RETRIES = 50;
94
+ const delayAmount = 2500;
95
+ for (let i = 0; i < MAX_RETRIES; i++) {
96
+ try {
97
+ const url = request.body.streaming ? `${request.body.api_server}/extra/generate/stream` : `${request.body.api_server}/v1/generate`;
98
+ const response = await fetch(url, { method: 'POST', ...args });
99
+
100
+ if (request.body.streaming) {
101
+ // Pipe remote SSE stream to Express response
102
+ forwardFetchResponse(response, response_generate);
103
+ return;
104
+ } else {
105
+ if (!response.ok) {
106
+ const errorText = await response.text();
107
+ console.warn(`Kobold returned error: ${response.status} ${response.statusText} ${errorText}`);
108
+
109
+ try {
110
+ const errorJson = JSON.parse(errorText);
111
+ const message = errorJson?.detail?.msg || errorText;
112
+ return response_generate.status(400).send({ error: { message } });
113
+ } catch {
114
+ return response_generate.status(400).send({ error: { message: errorText } });
115
+ }
116
+ }
117
+
118
+ const data = await response.json();
119
+ console.debug('Endpoint response:', data);
120
+ return response_generate.send(data);
121
+ }
122
+ } catch (error) {
123
+ // response
124
+ switch (error?.status) {
125
+ case 403:
126
+ case 503: // retry in case of temporary service issue, possibly caused by a queue failure?
127
+ console.warn(`KoboldAI is busy. Retry attempt ${i + 1} of ${MAX_RETRIES}...`);
128
+ await delay(delayAmount);
129
+ break;
130
+ default:
131
+ if ('status' in error) {
132
+ console.error('Status Code from Kobold:', error.status);
133
+ }
134
+ return response_generate.send({ error: true });
135
+ }
136
+ }
137
+ }
138
+
139
+ console.error('Max retries exceeded. Giving up.');
140
+ return response_generate.send({ error: true });
141
+ });
142
+
143
+ router.post('/status', async function (request, response) {
144
+ if (!request.body) return response.sendStatus(400);
145
+ let api_server = request.body.api_server;
146
+ if (api_server.indexOf('localhost') != -1) {
147
+ api_server = api_server.replace('localhost', '127.0.0.1');
148
+ }
149
+
150
+ const args = {
151
+ headers: { 'Content-Type': 'application/json' },
152
+ };
153
+
154
+ setAdditionalHeaders(request, args, api_server);
155
+
156
+ const result = {};
157
+
158
+ /** @type {any} */
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', async function (request, response) {
191
+ try {
192
+ const server = request.body.server;
193
+
194
+ if (!server) {
195
+ console.error('Server is not set');
196
+ return response.sendStatus(400);
197
+ }
198
+
199
+ if (!request.file) {
200
+ console.error('No audio file found');
201
+ return response.sendStatus(400);
202
+ }
203
+
204
+ console.debug('Transcribing audio with KoboldCpp', server);
205
+
206
+ const fileBase64 = fs.readFileSync(request.file.path).toString('base64');
207
+ fs.unlinkSync(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.error('KoboldCpp request failed', result.statusText, text);
229
+ return response.status(500).send(text);
230
+ }
231
+
232
+ const data = await result.json();
233
+ console.debug('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
+ router.post('/embed', async function (request, response) {
242
+ try {
243
+ const { server, items } = request.body;
244
+
245
+ if (!server) {
246
+ console.warn('KoboldCpp URL is not set');
247
+ return response.sendStatus(400);
248
+ }
249
+
250
+ const headers = {};
251
+ setAdditionalHeadersByType(headers, TEXTGEN_TYPES.KOBOLDCPP, server, request.user.directories);
252
+
253
+ const embeddingsUrl = new URL(server);
254
+ embeddingsUrl.pathname = '/api/extra/embeddings';
255
+
256
+ const embeddingsResult = await fetch(embeddingsUrl, {
257
+ method: 'POST',
258
+ headers: {
259
+ ...headers,
260
+ },
261
+ body: JSON.stringify({
262
+ input: items,
263
+ }),
264
+ });
265
+
266
+ /** @type {any} */
267
+ const data = await embeddingsResult.json();
268
+
269
+ if (!Array.isArray(data?.data)) {
270
+ console.warn('KoboldCpp API response was not an array');
271
+ return response.sendStatus(500);
272
+ }
273
+
274
+ const model = data.model || 'unknown';
275
+ const embeddings = data.data.map(x => Array.isArray(x) ? x[0] : x).sort((a, b) => a.index - b.index).map(x => x.embedding);
276
+ return response.json({ model, embeddings });
277
+ } catch (error) {
278
+ console.error('KoboldCpp embedding failed', error);
279
+ response.status(500).send('Internal server error');
280
+ }
281
+ });
src/endpoints/backends/text-completions.js ADDED
@@ -0,0 +1,643 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Readable } from 'node:stream';
2
+ import fetch from 'node-fetch';
3
+ import express from 'express';
4
+ import _ from 'lodash';
5
+
6
+ import {
7
+ TEXTGEN_TYPES,
8
+ TOGETHERAI_KEYS,
9
+ OLLAMA_KEYS,
10
+ INFERMATICAI_KEYS,
11
+ OPENROUTER_KEYS,
12
+ VLLM_KEYS,
13
+ FEATHERLESS_KEYS,
14
+ OPENAI_KEYS,
15
+ } from '../../constants.js';
16
+ import { forwardFetchResponse, trimV1, getConfigValue } from '../../util.js';
17
+ import { setAdditionalHeaders } from '../../additional-headers.js';
18
+ import { createHash } from 'node:crypto';
19
+
20
+ export const router = express.Router();
21
+
22
+ /**
23
+ * Special boy's steaming routine. Wrap this abomination into proper SSE stream.
24
+ * @param {import('node-fetch').Response} jsonStream JSON stream
25
+ * @param {import('express').Request} request Express request
26
+ * @param {import('express').Response} response Express response
27
+ * @returns {Promise<any>} Nothing valuable
28
+ */
29
+ async function parseOllamaStream(jsonStream, request, response) {
30
+ try {
31
+ if (!jsonStream.body) {
32
+ throw new Error('No body in the response');
33
+ }
34
+
35
+ let partialData = '';
36
+ jsonStream.body.on('data', (data) => {
37
+ const chunk = data.toString();
38
+ partialData += chunk;
39
+ while (true) {
40
+ let json;
41
+ try {
42
+ json = JSON.parse(partialData);
43
+ } catch (e) {
44
+ break;
45
+ }
46
+ const text = json.response || '';
47
+ const thinking = json.thinking || '';
48
+ const chunk = { choices: [{ text, thinking }] };
49
+ response.write(`data: ${JSON.stringify(chunk)}\n\n`);
50
+ partialData = '';
51
+ }
52
+ });
53
+
54
+ request.socket.on('close', function () {
55
+ if (jsonStream.body instanceof Readable) jsonStream.body.destroy();
56
+ response.end();
57
+ });
58
+
59
+ jsonStream.body.on('end', () => {
60
+ console.info('Streaming request finished');
61
+ response.write('data: [DONE]\n\n');
62
+ response.end();
63
+ });
64
+ } catch (error) {
65
+ console.error('Error forwarding streaming response:', error);
66
+ if (!response.headersSent) {
67
+ return response.status(500).send({ error: true });
68
+ } else {
69
+ return response.end();
70
+ }
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Abort KoboldCpp generation request.
76
+ * @param {import('express').Request} request the generation request
77
+ * @param {string} url Server base URL
78
+ * @returns {Promise<void>} Promise resolving when we are done
79
+ */
80
+ async function abortKoboldCppRequest(request, url) {
81
+ try {
82
+ console.info('Aborting Kobold generation...');
83
+ const args = {
84
+ method: 'POST',
85
+ headers: {},
86
+ };
87
+
88
+ setAdditionalHeaders(request, args, url);
89
+ const abortResponse = await fetch(`${url}/api/extra/abort`, args);
90
+
91
+ if (!abortResponse.ok) {
92
+ console.error('Error sending abort request to Kobold:', abortResponse.status, abortResponse.statusText);
93
+ }
94
+ } catch (error) {
95
+ console.error(error);
96
+ }
97
+ }
98
+
99
+ //************** Ooba/OpenAI text completions API
100
+ router.post('/status', async function (request, response) {
101
+ if (!request.body) return response.sendStatus(400);
102
+
103
+ try {
104
+ if (request.body.api_server.indexOf('localhost') !== -1) {
105
+ request.body.api_server = request.body.api_server.replace('localhost', '127.0.0.1');
106
+ }
107
+
108
+ console.debug('Trying to connect to API', request.body);
109
+ const baseUrl = trimV1(request.body.api_server);
110
+
111
+ const args = {
112
+ headers: { 'Content-Type': 'application/json' },
113
+ };
114
+
115
+ setAdditionalHeaders(request, args, baseUrl);
116
+
117
+ const apiType = request.body.api_type;
118
+ let url = baseUrl;
119
+ let result = '';
120
+
121
+ switch (apiType) {
122
+ case TEXTGEN_TYPES.GENERIC:
123
+ case TEXTGEN_TYPES.OOBA:
124
+ case TEXTGEN_TYPES.VLLM:
125
+ case TEXTGEN_TYPES.APHRODITE:
126
+ case TEXTGEN_TYPES.KOBOLDCPP:
127
+ case TEXTGEN_TYPES.LLAMACPP:
128
+ case TEXTGEN_TYPES.INFERMATICAI:
129
+ case TEXTGEN_TYPES.OPENROUTER:
130
+ case TEXTGEN_TYPES.FEATHERLESS:
131
+ url += '/v1/models';
132
+ break;
133
+ case TEXTGEN_TYPES.DREAMGEN:
134
+ url += '/api/openai/v1/models';
135
+ break;
136
+ case TEXTGEN_TYPES.MANCER:
137
+ url += '/oai/v1/models';
138
+ break;
139
+ case TEXTGEN_TYPES.TABBY:
140
+ url += '/v1/model/list';
141
+ break;
142
+ case TEXTGEN_TYPES.TOGETHERAI:
143
+ url += '/api/models?&info';
144
+ break;
145
+ case TEXTGEN_TYPES.OLLAMA:
146
+ url += '/api/tags';
147
+ break;
148
+ case TEXTGEN_TYPES.HUGGINGFACE:
149
+ url += '/info';
150
+ break;
151
+ }
152
+
153
+ const modelsReply = await fetch(url, args);
154
+ const isPossiblyLmStudio = modelsReply.headers.get('x-powered-by') === 'Express';
155
+
156
+ if (!modelsReply.ok) {
157
+ console.error('Models endpoint is offline.');
158
+ return response.sendStatus(400);
159
+ }
160
+
161
+ /** @type {any} */
162
+ let data = await modelsReply.json();
163
+
164
+ // Rewrap to OAI-like response
165
+ if (apiType === TEXTGEN_TYPES.TOGETHERAI && Array.isArray(data)) {
166
+ data = { data: data.map(x => ({ id: x.name, ...x })) };
167
+ }
168
+
169
+ if (apiType === TEXTGEN_TYPES.OLLAMA && Array.isArray(data.models)) {
170
+ data = { data: data.models.map(x => ({ id: x.name, ...x })) };
171
+ }
172
+
173
+ if (apiType === TEXTGEN_TYPES.HUGGINGFACE) {
174
+ data = { data: [] };
175
+ }
176
+
177
+ if (!Array.isArray(data.data)) {
178
+ console.error('Models response is not an array.');
179
+ return response.sendStatus(400);
180
+ }
181
+
182
+ const modelIds = data.data.map(x => x.id);
183
+ console.info('Models available:', modelIds);
184
+
185
+ // Set result to the first model ID
186
+ result = modelIds[0] || 'Valid';
187
+
188
+ if (apiType === TEXTGEN_TYPES.OOBA && !isPossiblyLmStudio) {
189
+ try {
190
+ const modelInfoUrl = baseUrl + '/v1/internal/model/info';
191
+ const modelInfoReply = await fetch(modelInfoUrl, args);
192
+
193
+ if (modelInfoReply.ok) {
194
+ /** @type {any} */
195
+ const modelInfo = await modelInfoReply.json();
196
+ console.debug('Ooba model info:', modelInfo);
197
+
198
+ const modelName = modelInfo?.model_name;
199
+ result = modelName || result;
200
+ response.setHeader('x-supports-tokenization', 'true');
201
+ }
202
+ } catch (error) {
203
+ console.error(`Failed to get Ooba model info: ${error}`);
204
+ }
205
+ } else if (apiType === TEXTGEN_TYPES.TABBY) {
206
+ try {
207
+ const modelInfoUrl = baseUrl + '/v1/model';
208
+ const modelInfoReply = await fetch(modelInfoUrl, args);
209
+
210
+ if (modelInfoReply.ok) {
211
+ /** @type {any} */
212
+ const modelInfo = await modelInfoReply.json();
213
+ console.debug('Tabby model info:', modelInfo);
214
+
215
+ const modelName = modelInfo?.id;
216
+ result = modelName || result;
217
+ } else {
218
+ // TabbyAPI returns an error 400 if a model isn't loaded
219
+
220
+ result = 'None';
221
+ }
222
+ } catch (error) {
223
+ console.error(`Failed to get TabbyAPI model info: ${error}`);
224
+ }
225
+ }
226
+
227
+ return response.send({ result, data: data.data });
228
+ } catch (error) {
229
+ console.error(error);
230
+ return response.sendStatus(500);
231
+ }
232
+ });
233
+
234
+ router.post('/props', async function (request, response) {
235
+ if (!request.body.api_server) return response.sendStatus(400);
236
+
237
+ try {
238
+ const baseUrl = trimV1(request.body.api_server);
239
+ const args = {
240
+ headers: {},
241
+ };
242
+
243
+ setAdditionalHeaders(request, args, baseUrl);
244
+
245
+ const apiType = request.body.api_type;
246
+ let propsUrl = baseUrl + '/props';
247
+ if (apiType === TEXTGEN_TYPES.LLAMACPP && request.body.model) {
248
+ propsUrl += `?model=${encodeURIComponent(request.body.model)}`;
249
+ console.debug(`Querying llama-server props with model parameter: ${request.body.model}`);
250
+ }
251
+ const propsReply = await fetch(propsUrl, args);
252
+
253
+ if (!propsReply.ok) {
254
+ return response.sendStatus(400);
255
+ }
256
+
257
+ /** @type {any} */
258
+ const props = await propsReply.json();
259
+ // TEMPORARY: llama.cpp's /props endpoint has a bug which replaces the last newline with a \0
260
+ if (apiType === TEXTGEN_TYPES.LLAMACPP && props['chat_template'] && props['chat_template'].endsWith('\u0000')) {
261
+ props['chat_template'] = props['chat_template'].slice(0, -1) + '\n';
262
+ }
263
+ props['chat_template_hash'] = createHash('sha256').update(props['chat_template']).digest('hex');
264
+ console.debug(`Model properties: ${JSON.stringify(props)}`);
265
+ return response.send(props);
266
+ } catch (error) {
267
+ console.error(error);
268
+ return response.sendStatus(500);
269
+ }
270
+ });
271
+
272
+ router.post('/generate', async function (request, response) {
273
+ if (!request.body) return response.sendStatus(400);
274
+
275
+ try {
276
+ if (request.body.api_server.indexOf('localhost') !== -1) {
277
+ request.body.api_server = request.body.api_server.replace('localhost', '127.0.0.1');
278
+ }
279
+
280
+ const apiType = request.body.api_type;
281
+ const baseUrl = request.body.api_server;
282
+ console.debug(request.body);
283
+
284
+ const controller = new AbortController();
285
+ request.socket.removeAllListeners('close');
286
+ request.socket.on('close', async function () {
287
+ if (request.body.api_type === TEXTGEN_TYPES.KOBOLDCPP && !response.writableEnded) {
288
+ await abortKoboldCppRequest(request, trimV1(baseUrl));
289
+ }
290
+
291
+ controller.abort();
292
+ });
293
+
294
+ let url = trimV1(baseUrl);
295
+
296
+ switch (request.body.api_type) {
297
+ case TEXTGEN_TYPES.GENERIC:
298
+ case TEXTGEN_TYPES.VLLM:
299
+ case TEXTGEN_TYPES.FEATHERLESS:
300
+ case TEXTGEN_TYPES.APHRODITE:
301
+ case TEXTGEN_TYPES.OOBA:
302
+ case TEXTGEN_TYPES.TABBY:
303
+ case TEXTGEN_TYPES.KOBOLDCPP:
304
+ case TEXTGEN_TYPES.TOGETHERAI:
305
+ case TEXTGEN_TYPES.INFERMATICAI:
306
+ case TEXTGEN_TYPES.HUGGINGFACE:
307
+ url += '/v1/completions';
308
+ break;
309
+ case TEXTGEN_TYPES.DREAMGEN:
310
+ url += '/api/openai/v1/completions';
311
+ break;
312
+ case TEXTGEN_TYPES.MANCER:
313
+ url += '/oai/v1/completions';
314
+ break;
315
+ case TEXTGEN_TYPES.LLAMACPP:
316
+ url += '/completion';
317
+ break;
318
+ case TEXTGEN_TYPES.OLLAMA:
319
+ url += '/api/generate';
320
+ break;
321
+ case TEXTGEN_TYPES.OPENROUTER:
322
+ url += '/v1/chat/completions';
323
+ break;
324
+ }
325
+
326
+ const args = {
327
+ method: 'POST',
328
+ body: JSON.stringify(request.body),
329
+ headers: { 'Content-Type': 'application/json' },
330
+ signal: controller.signal,
331
+ timeout: 0,
332
+ };
333
+
334
+ setAdditionalHeaders(request, args, baseUrl);
335
+
336
+ if (request.body.api_type === TEXTGEN_TYPES.TOGETHERAI) {
337
+ request.body = _.pickBy(request.body, (_, key) => TOGETHERAI_KEYS.includes(key));
338
+ args.body = JSON.stringify(request.body);
339
+ }
340
+
341
+ if (request.body.api_type === TEXTGEN_TYPES.INFERMATICAI) {
342
+ request.body = _.pickBy(request.body, (_, key) => INFERMATICAI_KEYS.includes(key));
343
+ args.body = JSON.stringify(request.body);
344
+ }
345
+
346
+ if (request.body.api_type === TEXTGEN_TYPES.FEATHERLESS) {
347
+ request.body = _.pickBy(request.body, (_, key) => FEATHERLESS_KEYS.includes(key));
348
+ args.body = JSON.stringify(request.body);
349
+ }
350
+
351
+ if (request.body.api_type === TEXTGEN_TYPES.DREAMGEN) {
352
+ args.body = JSON.stringify(request.body);
353
+ }
354
+
355
+ if (request.body.api_type === TEXTGEN_TYPES.GENERIC) {
356
+ request.body = _.pickBy(request.body, (_, key) => OPENAI_KEYS.includes(key));
357
+ if (Array.isArray(request.body.stop)) { request.body.stop = request.body.stop.slice(0, 4); }
358
+ args.body = JSON.stringify(request.body);
359
+ }
360
+
361
+ if (request.body.api_type === TEXTGEN_TYPES.OPENROUTER) {
362
+ if (Array.isArray(request.body.provider) && request.body.provider.length > 0) {
363
+ request.body.provider = {
364
+ allow_fallbacks: request.body.allow_fallbacks ?? true,
365
+ order: request.body.provider,
366
+ };
367
+ } else {
368
+ delete request.body.provider;
369
+ }
370
+ request.body = _.pickBy(request.body, (_, key) => OPENROUTER_KEYS.includes(key));
371
+ args.body = JSON.stringify(request.body);
372
+ }
373
+
374
+ if (request.body.api_type === TEXTGEN_TYPES.VLLM) {
375
+ request.body = _.pickBy(request.body, (_, key) => VLLM_KEYS.includes(key));
376
+ args.body = JSON.stringify(request.body);
377
+ }
378
+
379
+ if (request.body.api_type === TEXTGEN_TYPES.OLLAMA) {
380
+ const keepAlive = Number(getConfigValue('ollama.keepAlive', -1, 'number'));
381
+ const numBatch = Number(getConfigValue('ollama.batchSize', -1, 'number'));
382
+ if (numBatch > 0) {
383
+ request.body['num_batch'] = numBatch;
384
+ }
385
+ args.body = JSON.stringify({
386
+ model: request.body.model,
387
+ prompt: request.body.prompt,
388
+ stream: request.body.stream ?? false,
389
+ keep_alive: keepAlive,
390
+ raw: true,
391
+ options: _.pickBy(request.body, (_, key) => OLLAMA_KEYS.includes(key)),
392
+ });
393
+ }
394
+
395
+ if (request.body.api_type === TEXTGEN_TYPES.OLLAMA && request.body.stream) {
396
+ const stream = await fetch(url, args);
397
+ parseOllamaStream(stream, request, response);
398
+ } else if (request.body.stream) {
399
+ const completionsStream = await fetch(url, args);
400
+ // Pipe remote SSE stream to Express response
401
+ forwardFetchResponse(completionsStream, response);
402
+ }
403
+ else {
404
+ const completionsReply = await fetch(url, args);
405
+
406
+ if (completionsReply.ok) {
407
+ /** @type {any} */
408
+ const data = await completionsReply.json();
409
+ console.debug('Endpoint response:', data);
410
+
411
+ // Map InfermaticAI response to OAI completions format
412
+ if (apiType === TEXTGEN_TYPES.INFERMATICAI) {
413
+ data['choices'] = (data?.choices || []).map(choice => ({ text: choice?.message?.content || choice.text, logprobs: choice?.logprobs, index: choice?.index }));
414
+ }
415
+
416
+ return response.send(data);
417
+ } else {
418
+ const text = await completionsReply.text();
419
+ const errorBody = { error: true, status: completionsReply.status, response: text };
420
+
421
+ return !response.headersSent
422
+ ? response.send(errorBody)
423
+ : response.end();
424
+ }
425
+ }
426
+ } catch (error) {
427
+ const status = error?.status ?? error?.code ?? 'UNKNOWN';
428
+ const text = error?.error ?? error?.statusText ?? error?.message ?? 'Unknown error on /generate endpoint';
429
+ let value = { error: true, status: status, response: text };
430
+ console.error('Endpoint error:', error);
431
+
432
+ return !response.headersSent
433
+ ? response.send(value)
434
+ : response.end();
435
+ }
436
+ });
437
+
438
+ const ollama = express.Router();
439
+
440
+ ollama.post('/download', async function (request, response) {
441
+ try {
442
+ if (!request.body.name || !request.body.api_server) return response.sendStatus(400);
443
+
444
+ const name = request.body.name;
445
+ const url = String(request.body.api_server).replace(/\/$/, '');
446
+ console.debug('Pulling Ollama model:', name);
447
+
448
+ const fetchResponse = await fetch(`${url}/api/pull`, {
449
+ method: 'POST',
450
+ headers: { 'Content-Type': 'application/json' },
451
+ body: JSON.stringify({
452
+ name: name,
453
+ stream: false,
454
+ }),
455
+ });
456
+
457
+ if (!fetchResponse.ok) {
458
+ console.error('Download error:', fetchResponse.status, fetchResponse.statusText);
459
+ return response.status(500).send({ error: true });
460
+ }
461
+
462
+ console.debug('Ollama pull response:', await fetchResponse.json());
463
+ return response.send({ ok: true });
464
+ } catch (error) {
465
+ console.error(error);
466
+ return response.sendStatus(500);
467
+ }
468
+ });
469
+
470
+ ollama.post('/caption-image', async function (request, response) {
471
+ try {
472
+ if (!request.body.server_url || !request.body.model) {
473
+ return response.sendStatus(400);
474
+ }
475
+
476
+ console.debug('Ollama caption request:', request.body);
477
+ const baseUrl = trimV1(request.body.server_url);
478
+
479
+ const fetchResponse = await fetch(`${baseUrl}/api/generate`, {
480
+ method: 'POST',
481
+ headers: { 'Content-Type': 'application/json' },
482
+ body: JSON.stringify({
483
+ model: request.body.model,
484
+ prompt: request.body.prompt,
485
+ images: [request.body.image],
486
+ stream: false,
487
+ }),
488
+ });
489
+
490
+ if (!fetchResponse.ok) {
491
+ const errorText = await fetchResponse.text();
492
+ console.error('Ollama caption error:', fetchResponse.status, fetchResponse.statusText, errorText);
493
+ return response.status(500).send({ error: true });
494
+ }
495
+
496
+ /** @type {any} */
497
+ const data = await fetchResponse.json();
498
+ console.debug('Ollama caption response:', data);
499
+
500
+ const caption = data?.response || '';
501
+
502
+ if (!caption) {
503
+ console.error('Ollama caption is empty.');
504
+ return response.status(500).send({ error: true });
505
+ }
506
+
507
+ return response.send({ caption });
508
+ } catch (error) {
509
+ console.error(error);
510
+ return response.sendStatus(500);
511
+ }
512
+ });
513
+
514
+ const llamacpp = express.Router();
515
+
516
+ llamacpp.post('/props', async function (request, response) {
517
+ try {
518
+ if (!request.body.server_url) {
519
+ return response.sendStatus(400);
520
+ }
521
+
522
+ console.debug('LlamaCpp props request:', request.body);
523
+ const baseUrl = trimV1(request.body.server_url);
524
+
525
+ const fetchResponse = await fetch(`${baseUrl}/props`, {
526
+ method: 'GET',
527
+ });
528
+
529
+ if (!fetchResponse.ok) {
530
+ console.error('LlamaCpp props error:', fetchResponse.status, fetchResponse.statusText);
531
+ return response.status(500).send({ error: true });
532
+ }
533
+
534
+ const data = await fetchResponse.json();
535
+ console.debug('LlamaCpp props response:', data);
536
+
537
+ return response.send(data);
538
+
539
+ } catch (error) {
540
+ console.error(error);
541
+ return response.sendStatus(500);
542
+ }
543
+ });
544
+
545
+ llamacpp.post('/slots', async function (request, response) {
546
+ try {
547
+ if (!request.body.server_url) {
548
+ return response.sendStatus(400);
549
+ }
550
+ if (!/^(erase|info|restore|save)$/.test(request.body.action)) {
551
+ return response.sendStatus(400);
552
+ }
553
+
554
+ console.debug('LlamaCpp slots request:', request.body);
555
+ const baseUrl = trimV1(request.body.server_url);
556
+
557
+ let fetchResponse;
558
+ if (request.body.action === 'info') {
559
+ fetchResponse = await fetch(`${baseUrl}/slots`, {
560
+ method: 'GET',
561
+ });
562
+ } else {
563
+ if (!/^\d+$/.test(request.body.id_slot)) {
564
+ return response.sendStatus(400);
565
+ }
566
+ if (request.body.action !== 'erase' && !request.body.filename) {
567
+ return response.sendStatus(400);
568
+ }
569
+
570
+ fetchResponse = await fetch(`${baseUrl}/slots/${request.body.id_slot}?action=${request.body.action}`, {
571
+ method: 'POST',
572
+ headers: { 'Content-Type': 'application/json' },
573
+ body: JSON.stringify({
574
+ filename: request.body.action !== 'erase' ? `${request.body.filename}` : undefined,
575
+ }),
576
+ });
577
+ }
578
+
579
+ if (!fetchResponse.ok) {
580
+ console.error('LlamaCpp slots error:', fetchResponse.status, fetchResponse.statusText);
581
+ return response.status(500).send({ error: true });
582
+ }
583
+
584
+ const data = await fetchResponse.json();
585
+ console.debug('LlamaCpp slots response:', data);
586
+
587
+ return response.send(data);
588
+
589
+ } catch (error) {
590
+ console.error(error);
591
+ return response.sendStatus(500);
592
+ }
593
+ });
594
+
595
+ const tabby = express.Router();
596
+
597
+ tabby.post('/download', async function (request, response) {
598
+ try {
599
+ const baseUrl = String(request.body.api_server).replace(/\/$/, '');
600
+
601
+ const args = {
602
+ method: 'POST',
603
+ headers: { 'Content-Type': 'application/json' },
604
+ body: JSON.stringify(request.body),
605
+ timeout: 0,
606
+ };
607
+
608
+ setAdditionalHeaders(request, args, baseUrl);
609
+
610
+ // Check key permissions
611
+ const permissionResponse = await fetch(`${baseUrl}/v1/auth/permission`, {
612
+ headers: args.headers,
613
+ });
614
+
615
+ if (permissionResponse.ok) {
616
+ /** @type {any} */
617
+ const permissionJson = await permissionResponse.json();
618
+
619
+ if (permissionJson['permission'] !== 'admin') {
620
+ return response.status(403).send({ error: true });
621
+ }
622
+ } else {
623
+ console.error('API Permission error:', permissionResponse.status, permissionResponse.statusText);
624
+ return response.status(500).send({ error: true });
625
+ }
626
+
627
+ const fetchResponse = await fetch(`${baseUrl}/v1/download`, args);
628
+
629
+ if (!fetchResponse.ok) {
630
+ console.error('Download error:', fetchResponse.status, fetchResponse.statusText);
631
+ return response.status(500).send({ error: true });
632
+ }
633
+
634
+ return response.send({ ok: true });
635
+ } catch (error) {
636
+ console.error(error);
637
+ return response.sendStatus(500);
638
+ }
639
+ });
640
+
641
+ router.use('/ollama', ollama);
642
+ router.use('/llamacpp', llamacpp);
643
+ router.use('/tabby', tabby);
src/endpoints/backgrounds.js ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import express from 'express';
5
+ import sanitize from 'sanitize-filename';
6
+
7
+ import { dimensions, invalidateThumbnail } from './thumbnails.js';
8
+ import { getImages } from '../util.js';
9
+ import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
10
+
11
+ export const router = express.Router();
12
+
13
+ router.post('/all', function (request, response) {
14
+ const images = getImages(request.user.directories.backgrounds);
15
+ const config = { width: dimensions.bg[0], height: dimensions.bg[1] };
16
+ response.json({ images, config });
17
+ });
18
+
19
+ router.post('/delete', getFileNameValidationFunction('bg'), function (request, response) {
20
+ if (!request.body) return response.sendStatus(400);
21
+
22
+ if (request.body.bg !== sanitize(request.body.bg)) {
23
+ console.error('Malicious bg name prevented');
24
+ return response.sendStatus(403);
25
+ }
26
+
27
+ const fileName = path.join(request.user.directories.backgrounds, sanitize(request.body.bg));
28
+
29
+ if (!fs.existsSync(fileName)) {
30
+ console.error('BG file not found');
31
+ return response.sendStatus(400);
32
+ }
33
+
34
+ fs.unlinkSync(fileName);
35
+ invalidateThumbnail(request.user.directories, 'bg', request.body.bg);
36
+ return response.send('ok');
37
+ });
38
+
39
+ router.post('/rename', function (request, response) {
40
+ if (!request.body) return response.sendStatus(400);
41
+
42
+ const oldFileName = path.join(request.user.directories.backgrounds, sanitize(request.body.old_bg));
43
+ const newFileName = path.join(request.user.directories.backgrounds, sanitize(request.body.new_bg));
44
+
45
+ if (!fs.existsSync(oldFileName)) {
46
+ console.error('BG file not found');
47
+ return response.sendStatus(400);
48
+ }
49
+
50
+ if (fs.existsSync(newFileName)) {
51
+ console.error('New BG file already exists');
52
+ return response.sendStatus(400);
53
+ }
54
+
55
+ fs.copyFileSync(oldFileName, newFileName);
56
+ fs.unlinkSync(oldFileName);
57
+ invalidateThumbnail(request.user.directories, 'bg', request.body.old_bg);
58
+ return response.send('ok');
59
+ });
60
+
61
+ router.post('/upload', function (request, response) {
62
+ if (!request.body || !request.file) return response.sendStatus(400);
63
+
64
+ const img_path = path.join(request.file.destination, request.file.filename);
65
+ const filename = request.file.originalname;
66
+
67
+ try {
68
+ fs.copyFileSync(img_path, path.join(request.user.directories.backgrounds, filename));
69
+ fs.unlinkSync(img_path);
70
+ invalidateThumbnail(request.user.directories, 'bg', filename);
71
+ response.send(filename);
72
+ } catch (err) {
73
+ console.error(err);
74
+ response.sendStatus(500);
75
+ }
76
+ });
src/endpoints/backups.js ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import express from 'express';
2
+ import fs, { promises as fsPromises } from 'node:fs';
3
+ import path from 'node:path';
4
+ import sanitize from 'sanitize-filename';
5
+ import { CHAT_BACKUPS_PREFIX, getChatInfo } from './chats.js';
6
+
7
+ export const router = express.Router();
8
+
9
+ router.post('/chat/get', async (request, response) => {
10
+ try {
11
+ const backupModels = [];
12
+ const backupFiles = await fsPromises
13
+ .readdir(request.user.directories.backups, { withFileTypes: true })
14
+ .then(d => d .filter(d => d.isFile() && path.extname(d.name) === '.jsonl' && d.name.startsWith(CHAT_BACKUPS_PREFIX)).map(d => d.name));
15
+
16
+ for (const name of backupFiles) {
17
+ const filePath = path.join(request.user.directories.backups, name);
18
+ const info = await getChatInfo(filePath);
19
+ if (!info || !info.file_name) {
20
+ continue;
21
+ }
22
+ backupModels.push(info);
23
+ }
24
+
25
+ return response.json(backupModels);
26
+ } catch (error) {
27
+ console.error(error);
28
+ return response.sendStatus(500);
29
+ }
30
+ });
31
+
32
+ router.post('/chat/delete', async (request, response) => {
33
+ try {
34
+ const { name } = request.body;
35
+ const filePath = path.join(request.user.directories.backups, sanitize(name));
36
+
37
+ if (!path.parse(filePath).base.startsWith(CHAT_BACKUPS_PREFIX)) {
38
+ console.warn('Attempt to delete non-chat backup file:', name);
39
+ return response.sendStatus(400);
40
+ }
41
+
42
+ if (!fs.existsSync(filePath)) {
43
+ return response.sendStatus(404);
44
+ }
45
+
46
+ await fsPromises.unlink(filePath);
47
+ return response.sendStatus(200);
48
+ }
49
+ catch (error) {
50
+ console.error(error);
51
+ return response.sendStatus(500);
52
+ }
53
+ });
54
+
55
+ router.post('/chat/download', async (request, response) => {
56
+ try {
57
+ const { name } = request.body;
58
+ const filePath = path.join(request.user.directories.backups, sanitize(name));
59
+
60
+ if (!path.parse(filePath).base.startsWith(CHAT_BACKUPS_PREFIX)) {
61
+ console.warn('Attempt to download non-chat backup file:', name);
62
+ return response.sendStatus(400);
63
+ }
64
+
65
+ if (!fs.existsSync(filePath)) {
66
+ return response.sendStatus(404);
67
+ }
68
+
69
+ return response.download(filePath);
70
+ }
71
+ catch (error) {
72
+ console.error(error);
73
+ return response.sendStatus(500);
74
+ }
75
+ });
src/endpoints/caption.js ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import express from 'express';
2
+ import { getPipeline, getRawImage } from '../transformers.js';
3
+
4
+ export const router = express.Router();
5
+
6
+ const TASK = 'image-to-text';
7
+
8
+ router.post('/', async (req, res) => {
9
+ try {
10
+ const { image } = req.body;
11
+
12
+ const rawImage = await getRawImage(image);
13
+
14
+ if (!rawImage) {
15
+ console.warn('Failed to parse captioned image');
16
+ return res.sendStatus(400);
17
+ }
18
+
19
+ const pipe = await getPipeline(TASK);
20
+ const result = await pipe(rawImage);
21
+ const text = result[0].generated_text;
22
+ console.info('Image caption:', text);
23
+
24
+ return res.json({ caption: text });
25
+ } catch (error) {
26
+ console.error(error);
27
+ return res.sendStatus(500);
28
+ }
29
+ });
src/endpoints/characters.js ADDED
@@ -0,0 +1,1547 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { promises as fsPromises } from 'node:fs';
4
+ import { Buffer } from 'node:buffer';
5
+
6
+ import express from 'express';
7
+ import sanitize from 'sanitize-filename';
8
+ import { sync as writeFileAtomicSync } from 'write-file-atomic';
9
+ import yaml from 'yaml';
10
+ import _ from 'lodash';
11
+ import mime from 'mime-types';
12
+ import { Jimp, JimpMime } from '../jimp.js';
13
+ import storage from 'node-persist';
14
+
15
+ import { AVATAR_WIDTH, AVATAR_HEIGHT, DEFAULT_AVATAR_PATH } from '../constants.js';
16
+ import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction } from '../middleware/validateFileName.js';
17
+ import { deepMerge, humanizedDateTime, tryParse, MemoryLimitedMap, getConfigValue, mutateJsonString, clientRelativePath, getUniqueName, sanitizeSafeCharacterReplacements } from '../util.js';
18
+ import { TavernCardValidator } from '../validator/TavernCardValidator.js';
19
+ import { parse, read, write } from '../character-card-parser.js';
20
+ import { readWorldInfoFile } from './worldinfo.js';
21
+ import { invalidateThumbnail } from './thumbnails.js';
22
+ import { importRisuSprites } from './sprites.js';
23
+ import { getUserDirectories } from '../users.js';
24
+ import { getChatInfo } from './chats.js';
25
+ import { ByafParser } from '../byaf.js';
26
+ import { CharXParser, persistCharXAssets } from '../charx.js';
27
+ import cacheBuster from '../middleware/cacheBuster.js';
28
+
29
+ // With 100 MB limit it would take roughly 3000 characters to reach this limit
30
+ const memoryCacheCapacity = getConfigValue('performance.memoryCacheCapacity', '100mb');
31
+ const memoryCache = new MemoryLimitedMap(memoryCacheCapacity);
32
+ // Some Android devices require tighter memory management
33
+ const isAndroid = process.platform === 'android';
34
+ // Use shallow character data for the character list
35
+ const useShallowCharacters = !!getConfigValue('performance.lazyLoadCharacters', false, 'boolean');
36
+ const useDiskCache = !!getConfigValue('performance.useDiskCache', true, 'boolean');
37
+
38
+ class DiskCache {
39
+ /**
40
+ * @type {string}
41
+ * @readonly
42
+ */
43
+ static DIRECTORY = 'characters';
44
+
45
+ /**
46
+ * @type {number}
47
+ * @readonly
48
+ */
49
+ static SYNC_INTERVAL = 5 * 60 * 1000;
50
+
51
+ /** @type {import('node-persist').LocalStorage} */
52
+ #instance;
53
+
54
+ /** @type {NodeJS.Timeout} */
55
+ #syncInterval;
56
+
57
+ /**
58
+ * Queue of user handles to sync.
59
+ * @type {Set<string>}
60
+ * @readonly
61
+ */
62
+ syncQueue = new Set();
63
+
64
+ /**
65
+ * Path to the cache directory.
66
+ * @returns {string}
67
+ */
68
+ get cachePath() {
69
+ return path.join(globalThis.DATA_ROOT, '_cache', DiskCache.DIRECTORY);
70
+ }
71
+
72
+ /**
73
+ * Returns the list of hashed keys in the cache.
74
+ * @returns {string[]}
75
+ */
76
+ get hashedKeys() {
77
+ return fs.readdirSync(this.cachePath);
78
+ }
79
+
80
+ /**
81
+ * Processes the synchronization queue.
82
+ * @returns {Promise<void>}
83
+ */
84
+ async #syncCacheEntries() {
85
+ try {
86
+ if (!useDiskCache || this.syncQueue.size === 0) {
87
+ return;
88
+ }
89
+
90
+ const directories = [...this.syncQueue].map(entry => getUserDirectories(entry));
91
+ this.syncQueue.clear();
92
+
93
+ await this.verify(directories);
94
+ } catch (error) {
95
+ console.error('Error while synchronizing cache entries:', error);
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Gets the disk cache instance.
101
+ * @returns {Promise<import('node-persist').LocalStorage>}
102
+ */
103
+ async instance() {
104
+ if (this.#instance) {
105
+ return this.#instance;
106
+ }
107
+
108
+ this.#instance = storage.create({
109
+ dir: this.cachePath,
110
+ ttl: false,
111
+ forgiveParseErrors: true,
112
+ expiredInterval: 0,
113
+ // @ts-ignore
114
+ maxFileDescriptors: 100,
115
+ });
116
+ await this.#instance.init();
117
+ this.#syncInterval = setInterval(this.#syncCacheEntries.bind(this), DiskCache.SYNC_INTERVAL);
118
+ return this.#instance;
119
+ }
120
+
121
+ /**
122
+ * Verifies disk cache size and prunes it if necessary.
123
+ * @param {import('../users.js').UserDirectoryList[]} directoriesList List of user directories
124
+ * @returns {Promise<void>}
125
+ */
126
+ async verify(directoriesList) {
127
+ try {
128
+ if (!useDiskCache) {
129
+ return;
130
+ }
131
+
132
+ const cache = await this.instance();
133
+ const validKeys = new Set();
134
+ for (const dir of directoriesList) {
135
+ const files = fs.readdirSync(dir.characters, { withFileTypes: true });
136
+ for (const file of files.filter(f => f.isFile() && path.extname(f.name) === '.png')) {
137
+ const filePath = path.join(dir.characters, file.name);
138
+ const cacheKey = getCacheKey(filePath);
139
+ validKeys.add(path.parse(cache.getDatumPath(cacheKey)).base);
140
+ }
141
+ }
142
+ for (const key of this.hashedKeys) {
143
+ if (!validKeys.has(key)) {
144
+ await cache.removeItem(key);
145
+ }
146
+ }
147
+ } catch (error) {
148
+ console.error('Error while verifying disk cache:', error);
149
+ }
150
+ }
151
+
152
+ dispose() {
153
+ if (this.#syncInterval) {
154
+ clearInterval(this.#syncInterval);
155
+ }
156
+ }
157
+ }
158
+
159
+ export const diskCache = new DiskCache();
160
+
161
+ /**
162
+ * Gets the cache key for the specified image file.
163
+ * @param {string} inputFile - Path to the image file
164
+ * @returns {string} - Cache key
165
+ */
166
+ function getCacheKey(inputFile) {
167
+ if (fs.existsSync(inputFile)) {
168
+ const stat = fs.statSync(inputFile);
169
+ return `${inputFile}-${stat.mtimeMs}`;
170
+ }
171
+
172
+ return inputFile;
173
+ }
174
+
175
+ /**
176
+ * Reads the character card from the specified image file.
177
+ * @param {string} inputFile - Path to the image file
178
+ * @param {string} inputFormat - 'png'
179
+ * @returns {Promise<string | undefined>} - Character card data
180
+ */
181
+ async function readCharacterData(inputFile, inputFormat = 'png') {
182
+ const cacheKey = getCacheKey(inputFile);
183
+ if (memoryCache.has(cacheKey)) {
184
+ return memoryCache.get(cacheKey);
185
+ }
186
+ if (useDiskCache) {
187
+ try {
188
+ const cache = await diskCache.instance();
189
+ const cachedData = await cache.getItem(cacheKey);
190
+ if (cachedData) {
191
+ return cachedData;
192
+ }
193
+ } catch (error) {
194
+ console.warn('Error while reading from disk cache:', error);
195
+ }
196
+ }
197
+
198
+ const result = await parse(inputFile, inputFormat);
199
+ !isAndroid && memoryCache.set(cacheKey, result);
200
+ if (useDiskCache) {
201
+ try {
202
+ const cache = await diskCache.instance();
203
+ await cache.setItem(cacheKey, result);
204
+ } catch (error) {
205
+ console.warn('Error while writing to disk cache:', error);
206
+ }
207
+ }
208
+ return result;
209
+ }
210
+
211
+ /**
212
+ * Writes the character card to the specified image file.
213
+ * @param {string|Buffer} inputFile - Path to the image file or image buffer
214
+ * @param {string} data - Character card data
215
+ * @param {string} outputFile - Target image file name
216
+ * @param {import('express').Request} request - Express request obejct
217
+ * @param {Crop|undefined} crop - Crop parameters
218
+ * @returns {Promise<boolean>} - True if the operation was successful
219
+ */
220
+ async function writeCharacterData(inputFile, data, outputFile, request, crop = undefined) {
221
+ try {
222
+ // Reset the cache
223
+ for (const key of memoryCache.keys()) {
224
+ if (Buffer.isBuffer(inputFile)) {
225
+ break;
226
+ }
227
+ if (key.startsWith(inputFile)) {
228
+ memoryCache.delete(key);
229
+ break;
230
+ }
231
+ }
232
+ if (useDiskCache && !Buffer.isBuffer(inputFile)) {
233
+ diskCache.syncQueue.add(request.user.profile.handle);
234
+ }
235
+ /**
236
+ * Read the image, resize, and save it as a PNG into the buffer.
237
+ * @returns {Promise<Buffer>} Image buffer
238
+ */
239
+ async function getInputImage() {
240
+ try {
241
+ if (Buffer.isBuffer(inputFile)) {
242
+ return await parseImageBuffer(inputFile, crop);
243
+ }
244
+
245
+ return await tryReadImage(inputFile, crop);
246
+ } catch (error) {
247
+ const message = Buffer.isBuffer(inputFile) ? 'Failed to read image buffer.' : `Failed to read image: ${inputFile}.`;
248
+ console.warn(message, 'Using a fallback image.', error);
249
+ return await fs.promises.readFile(DEFAULT_AVATAR_PATH);
250
+ }
251
+ }
252
+
253
+ const inputImage = await getInputImage();
254
+
255
+ // Get the chunks
256
+ const outputImage = write(inputImage, data);
257
+ const outputImagePath = path.join(request.user.directories.characters, `${outputFile}.png`);
258
+
259
+ writeFileAtomicSync(outputImagePath, outputImage);
260
+ return true;
261
+ } catch (err) {
262
+ console.error(err);
263
+ return false;
264
+ }
265
+ }
266
+
267
+ /**
268
+ * @typedef {Object} Crop
269
+ * @property {number} x X-coordinate
270
+ * @property {number} y Y-coordinate
271
+ * @property {number} width Width
272
+ * @property {number} height Height
273
+ * @property {boolean} want_resize Resize the image to the standard avatar size
274
+ */
275
+
276
+ /**
277
+ * Applies avatar crop and resize operations to an image.
278
+ * I couldn't fix the type issue, so the first argument has {any} type.
279
+ * @param {object} jimp Jimp image instance
280
+ * @param {Crop|undefined} [crop] Crop parameters
281
+ * @returns {Promise<Buffer>} Processed image buffer
282
+ */
283
+ export async function applyAvatarCropResize(jimp, crop) {
284
+ if (!(jimp instanceof Jimp)) {
285
+ throw new TypeError('Expected a Jimp instance');
286
+ }
287
+
288
+ const image = /** @type {InstanceType<typeof Jimp>} */ (jimp);
289
+ let finalWidth = image.bitmap.width, finalHeight = image.bitmap.height;
290
+
291
+ // Apply crop if defined
292
+ if (typeof crop == 'object' && [crop.x, crop.y, crop.width, crop.height].every(x => typeof x === 'number')) {
293
+ image.crop({ x: crop.x, y: crop.y, w: crop.width, h: crop.height });
294
+ // Apply standard resize if requested
295
+ if (crop.want_resize) {
296
+ finalWidth = AVATAR_WIDTH;
297
+ finalHeight = AVATAR_HEIGHT;
298
+ } else {
299
+ finalWidth = crop.width;
300
+ finalHeight = crop.height;
301
+ }
302
+ }
303
+
304
+ image.cover({ w: finalWidth, h: finalHeight });
305
+ return await image.getBuffer(JimpMime.png);
306
+ }
307
+
308
+ /**
309
+ * Parses an image buffer and applies crop if defined.
310
+ * @param {Buffer} buffer Buffer of the image
311
+ * @param {Crop|undefined} [crop] Crop parameters
312
+ * @returns {Promise<Buffer>} Image buffer
313
+ */
314
+ async function parseImageBuffer(buffer, crop) {
315
+ const image = await Jimp.fromBuffer(buffer);
316
+ return await applyAvatarCropResize(image, crop);
317
+ }
318
+
319
+ /**
320
+ * Reads an image file and applies crop if defined.
321
+ * @param {string} imgPath Path to the image file
322
+ * @param {Crop|undefined} crop Crop parameters
323
+ * @returns {Promise<Buffer>} Image buffer
324
+ */
325
+ async function tryReadImage(imgPath, crop) {
326
+ try {
327
+ const rawImg = await Jimp.read(imgPath);
328
+ return await applyAvatarCropResize(rawImg, crop);
329
+ }
330
+ // If it's an unsupported type of image (APNG) - just read the file as buffer
331
+ catch (error) {
332
+ console.error(`Failed to read image: ${imgPath}`, error);
333
+ return fs.readFileSync(imgPath);
334
+ }
335
+ }
336
+
337
+ /**
338
+ * calculateChatSize - Calculates the total chat size for a given character.
339
+ *
340
+ * @param {string} charDir The directory where the chats are stored.
341
+ * @return { {chatSize: number, dateLastChat: number} } The total chat size.
342
+ */
343
+ const calculateChatSize = (charDir) => {
344
+ let chatSize = 0;
345
+ let dateLastChat = 0;
346
+
347
+ if (fs.existsSync(charDir)) {
348
+ const chats = fs.readdirSync(charDir);
349
+ if (Array.isArray(chats) && chats.length) {
350
+ for (const chat of chats) {
351
+ const chatStat = fs.statSync(path.join(charDir, chat));
352
+ chatSize += chatStat.size;
353
+ dateLastChat = Math.max(dateLastChat, chatStat.mtimeMs);
354
+ }
355
+ }
356
+ }
357
+
358
+ return { chatSize, dateLastChat };
359
+ };
360
+
361
+ // Calculate the total string length of the data object
362
+ const calculateDataSize = (data) => {
363
+ return typeof data === 'object' ? Object.values(data).reduce((acc, val) => acc + String(val).length, 0) : 0;
364
+ };
365
+
366
+ /**
367
+ * Only get fields that are used to display the character list.
368
+ * @param {object} character Character object
369
+ * @returns {{shallow: true, [key: string]: any}} Shallow character
370
+ */
371
+ const toShallow = (character) => {
372
+ return {
373
+ shallow: true,
374
+ name: character.name,
375
+ avatar: character.avatar,
376
+ chat: character.chat,
377
+ fav: character.fav,
378
+ date_added: character.date_added,
379
+ create_date: character.create_date,
380
+ date_last_chat: character.date_last_chat,
381
+ chat_size: character.chat_size,
382
+ data_size: character.data_size,
383
+ tags: character.tags,
384
+ data: {
385
+ name: _.get(character, 'data.name', ''),
386
+ character_version: _.get(character, 'data.character_version', ''),
387
+ creator: _.get(character, 'data.creator', ''),
388
+ creator_notes: _.get(character, 'data.creator_notes', ''),
389
+ tags: _.get(character, 'data.tags', []),
390
+ extensions: {
391
+ fav: _.get(character, 'data.extensions.fav', false),
392
+ },
393
+ },
394
+ };
395
+ };
396
+
397
+ /**
398
+ * processCharacter - Process a given character, read its data and calculate its statistics.
399
+ *
400
+ * @param {string} item The name of the character.
401
+ * @param {import('../users.js').UserDirectoryList} directories User directories
402
+ * @param {object} options Options for the character processing
403
+ * @param {boolean} options.shallow If true, only return the core character's metadata
404
+ * @return {Promise<object>} A Promise that resolves when the character processing is done.
405
+ */
406
+ const processCharacter = async (item, directories, { shallow }) => {
407
+ try {
408
+ const imgFile = path.join(directories.characters, item);
409
+ const imgData = await readCharacterData(imgFile);
410
+ if (imgData === undefined) throw new Error('Failed to read character file');
411
+
412
+ let jsonObject = getCharaCardV2(JSON.parse(imgData), directories, false);
413
+ jsonObject.avatar = item;
414
+ const character = jsonObject;
415
+ character['json_data'] = imgData;
416
+ const charStat = fs.statSync(path.join(directories.characters, item));
417
+ character['date_added'] = charStat.ctimeMs;
418
+ character['create_date'] = jsonObject['create_date'] || new Date(Math.round(charStat.ctimeMs)).toISOString();
419
+ const chatsDirectory = path.join(directories.chats, item.replace('.png', ''));
420
+
421
+ const { chatSize, dateLastChat } = calculateChatSize(chatsDirectory);
422
+ character['chat_size'] = chatSize;
423
+ character['date_last_chat'] = dateLastChat;
424
+ character['data_size'] = calculateDataSize(jsonObject?.data);
425
+ return shallow ? toShallow(character) : character;
426
+ }
427
+ catch (err) {
428
+ console.error(`Could not process character: ${item}`);
429
+
430
+ if (err instanceof SyntaxError) {
431
+ console.error(`${item} does not contain a valid JSON object.`);
432
+ } else {
433
+ console.error('An unexpected error occurred: ', err);
434
+ }
435
+
436
+ return {
437
+ date_added: 0,
438
+ date_last_chat: 0,
439
+ chat_size: 0,
440
+ };
441
+ }
442
+ };
443
+
444
+ /**
445
+ * Convert a character object to Spec V2 format.
446
+ * @param {object} jsonObject Character object
447
+ * @param {import('../users.js').UserDirectoryList} directories User directories
448
+ * @param {boolean} hoistDate Will set the chat and create_date fields to the current date if they are missing
449
+ * @returns {object} Character object in Spec V2 format
450
+ */
451
+ function getCharaCardV2(jsonObject, directories, hoistDate = true) {
452
+ if (jsonObject.spec === undefined) {
453
+ jsonObject = convertToV2(jsonObject, directories);
454
+
455
+ if (hoistDate && !jsonObject.create_date) {
456
+ jsonObject.create_date = new Date().toISOString();
457
+ }
458
+ } else {
459
+ jsonObject = readFromV2(jsonObject);
460
+ }
461
+ return jsonObject;
462
+ }
463
+
464
+ /**
465
+ * Convert a character object to Spec V2 format.
466
+ * @param {object} char Character object
467
+ * @param {import('../users.js').UserDirectoryList} directories User directories
468
+ * @returns {object} Character object in Spec V2 format
469
+ */
470
+ function convertToV2(char, directories) {
471
+ // Simulate incoming data from frontend form
472
+ const result = charaFormatData({
473
+ json_data: JSON.stringify(char),
474
+ ch_name: char.name,
475
+ description: char.description,
476
+ personality: char.personality,
477
+ scenario: char.scenario,
478
+ first_mes: char.first_mes,
479
+ mes_example: char.mes_example,
480
+ creator_notes: char.creatorcomment,
481
+ talkativeness: char.talkativeness,
482
+ fav: char.fav,
483
+ creator: char.creator,
484
+ tags: char.tags,
485
+ depth_prompt_prompt: char.depth_prompt_prompt,
486
+ depth_prompt_depth: char.depth_prompt_depth,
487
+ depth_prompt_role: char.depth_prompt_role,
488
+ }, directories);
489
+
490
+ result.chat = char.chat ?? `${char.name} - ${humanizedDateTime()}`;
491
+ result.create_date = char.create_date;
492
+
493
+ return result;
494
+ }
495
+
496
+ /**
497
+ * Removes fields that are not meant to be shared.
498
+ */
499
+ function unsetPrivateFields(char) {
500
+ _.set(char, 'fav', false);
501
+ _.set(char, 'data.extensions.fav', false);
502
+ _.unset(char, 'chat');
503
+ }
504
+
505
+ function readFromV2(char) {
506
+ if (_.isUndefined(char.data)) {
507
+ console.warn(`Char ${char['name']} has Spec v2 data missing`);
508
+ return char;
509
+ }
510
+
511
+ // If 'json_data' was already saved, don't let it propagate
512
+ _.unset(char, 'json_data');
513
+
514
+ const fieldMappings = {
515
+ name: 'name',
516
+ description: 'description',
517
+ personality: 'personality',
518
+ scenario: 'scenario',
519
+ first_mes: 'first_mes',
520
+ mes_example: 'mes_example',
521
+ talkativeness: 'extensions.talkativeness',
522
+ fav: 'extensions.fav',
523
+ tags: 'tags',
524
+ };
525
+
526
+ _.forEach(fieldMappings, (v2Path, charField) => {
527
+ //console.info(`Migrating field: ${charField} from ${v2Path}`);
528
+ const v2Value = _.get(char.data, v2Path);
529
+ if (_.isUndefined(v2Value)) {
530
+ let defaultValue = undefined;
531
+
532
+ // Backfill default values for missing ST extension fields
533
+ if (v2Path === 'extensions.talkativeness') {
534
+ defaultValue = 0.5;
535
+ }
536
+
537
+ if (v2Path === 'extensions.fav') {
538
+ defaultValue = false;
539
+ }
540
+
541
+ if (!_.isUndefined(defaultValue)) {
542
+ //console.warn(`Spec v2 extension data missing for field: ${charField}, using default value: ${defaultValue}`);
543
+ char[charField] = defaultValue;
544
+ } else {
545
+ console.warn(`Char ${char['name']} has Spec v2 data missing for unknown field: ${charField}`);
546
+ return;
547
+ }
548
+ }
549
+ if (!_.isUndefined(char[charField]) && !_.isUndefined(v2Value) && String(char[charField]) !== String(v2Value)) {
550
+ console.warn(`Char ${char['name']} has Spec v2 data mismatch with Spec v1 for field: ${charField}`, char[charField], v2Value);
551
+ }
552
+ char[charField] = v2Value;
553
+ });
554
+
555
+ char['chat'] = char['chat'] ?? `${char.name} - ${humanizedDateTime()}`;
556
+
557
+ return char;
558
+ }
559
+
560
+ /**
561
+ * Format character data to Spec V2 format.
562
+ * @param {object} data Character data
563
+ * @param {import('../users.js').UserDirectoryList} directories User directories
564
+ * @returns
565
+ */
566
+ function charaFormatData(data, directories) {
567
+ // This is supposed to save all the foreign keys that ST doesn't care about
568
+ const char = tryParse(data.json_data) || {};
569
+
570
+ // Prevent erroneous 'json_data' recursive saving
571
+ _.unset(char, 'json_data');
572
+
573
+ // Checks if data.alternate_greetings is an array, a string, or neither, and acts accordingly. (expected to be an array of strings)
574
+ const getAlternateGreetings = data => {
575
+ if (Array.isArray(data.alternate_greetings)) return data.alternate_greetings;
576
+ if (typeof data.alternate_greetings === 'string') return [data.alternate_greetings];
577
+ return [];
578
+ };
579
+
580
+ // Spec V1 fields
581
+ _.set(char, 'name', data.ch_name);
582
+ _.set(char, 'description', data.description || '');
583
+ _.set(char, 'personality', data.personality || '');
584
+ _.set(char, 'scenario', data.scenario || '');
585
+ _.set(char, 'first_mes', data.first_mes || '');
586
+ _.set(char, 'mes_example', data.mes_example || '');
587
+
588
+ // Old ST extension fields (for backward compatibility, will be deprecated)
589
+ _.set(char, 'creatorcomment', data.creator_notes || '');
590
+ _.set(char, 'avatar', 'none');
591
+ _.set(char, 'chat', data.ch_name + ' - ' + humanizedDateTime());
592
+ _.set(char, 'talkativeness', data.talkativeness || 0.5);
593
+ _.set(char, 'fav', data.fav == 'true');
594
+ _.set(char, 'tags', typeof data.tags == 'string' ? (data.tags.split(',').map(x => x.trim()).filter(x => x)) : data.tags || []);
595
+
596
+ // Spec V2 fields
597
+ _.set(char, 'spec', 'chara_card_v2');
598
+ _.set(char, 'spec_version', '2.0');
599
+ _.set(char, 'data.name', data.ch_name);
600
+ _.set(char, 'data.description', data.description || '');
601
+ _.set(char, 'data.personality', data.personality || '');
602
+ _.set(char, 'data.scenario', data.scenario || '');
603
+ _.set(char, 'data.first_mes', data.first_mes || '');
604
+ _.set(char, 'data.mes_example', data.mes_example || '');
605
+
606
+ // New V2 fields
607
+ _.set(char, 'data.creator_notes', data.creator_notes || '');
608
+ _.set(char, 'data.system_prompt', data.system_prompt || '');
609
+ _.set(char, 'data.post_history_instructions', data.post_history_instructions || '');
610
+ _.set(char, 'data.tags', typeof data.tags == 'string' ? (data.tags.split(',').map(x => x.trim()).filter(x => x)) : data.tags || []);
611
+ _.set(char, 'data.creator', data.creator || '');
612
+ _.set(char, 'data.character_version', data.character_version || '');
613
+ _.set(char, 'data.alternate_greetings', getAlternateGreetings(data));
614
+
615
+ // ST extension fields to V2 object
616
+ _.set(char, 'data.extensions.talkativeness', data.talkativeness || 0.5);
617
+ _.set(char, 'data.extensions.fav', data.fav == 'true');
618
+ _.set(char, 'data.extensions.world', data.world || '');
619
+
620
+ // Spec extension: depth prompt
621
+ const depth_default = 4;
622
+ const role_default = 'system';
623
+ const depth_value = !isNaN(Number(data.depth_prompt_depth)) ? Number(data.depth_prompt_depth) : depth_default;
624
+ const role_value = data.depth_prompt_role ?? role_default;
625
+ _.set(char, 'data.extensions.depth_prompt.prompt', data.depth_prompt_prompt ?? '');
626
+ _.set(char, 'data.extensions.depth_prompt.depth', depth_value);
627
+ _.set(char, 'data.extensions.depth_prompt.role', role_value);
628
+
629
+ if (data.world) {
630
+ try {
631
+ const file = readWorldInfoFile(directories, data.world, false);
632
+
633
+ // File was imported - save it to the character book
634
+ if (file && file.originalData) {
635
+ _.set(char, 'data.character_book', file.originalData);
636
+ }
637
+
638
+ // File was not imported - convert the world info to the character book
639
+ if (file && file.entries) {
640
+ _.set(char, 'data.character_book', convertWorldInfoToCharacterBook(data.world, file.entries));
641
+ }
642
+
643
+ } catch {
644
+ console.warn(`Failed to read world info file: ${data.world}. Character book will not be available.`);
645
+ }
646
+ }
647
+
648
+ if (data.extensions) {
649
+ try {
650
+ const extensions = JSON.parse(data.extensions);
651
+ // Deep merge the extensions object
652
+ _.set(char, 'data.extensions', deepMerge(char.data.extensions, extensions));
653
+ } catch {
654
+ console.warn(`Failed to parse extensions JSON: ${data.extensions}`);
655
+ }
656
+ }
657
+
658
+ return char;
659
+ }
660
+
661
+ /**
662
+ * @param {string} name Name of World Info file
663
+ * @param {object} entries Entries object
664
+ */
665
+ function convertWorldInfoToCharacterBook(name, entries) {
666
+ /** @type {{ entries: object[]; name: string }} */
667
+ const result = { entries: [], name };
668
+
669
+ for (const index in entries) {
670
+ const entry = entries[index];
671
+
672
+ const originalEntry = {
673
+ id: entry.uid,
674
+ keys: entry.key,
675
+ secondary_keys: entry.keysecondary,
676
+ comment: entry.comment,
677
+ content: entry.content,
678
+ constant: entry.constant,
679
+ selective: entry.selective,
680
+ insertion_order: entry.order,
681
+ enabled: !entry.disable,
682
+ position: entry.position == 0 ? 'before_char' : 'after_char',
683
+ use_regex: true, // ST keys are always regex
684
+ extensions: {
685
+ ...entry.extensions,
686
+ position: entry.position,
687
+ exclude_recursion: entry.excludeRecursion,
688
+ display_index: entry.displayIndex,
689
+ probability: entry.probability ?? null,
690
+ useProbability: entry.useProbability ?? false,
691
+ depth: entry.depth ?? 4,
692
+ selectiveLogic: entry.selectiveLogic ?? 0,
693
+ outlet_name: entry.outletName ?? '',
694
+ group: entry.group ?? '',
695
+ group_override: entry.groupOverride ?? false,
696
+ group_weight: entry.groupWeight ?? null,
697
+ prevent_recursion: entry.preventRecursion ?? false,
698
+ delay_until_recursion: entry.delayUntilRecursion ?? false,
699
+ scan_depth: entry.scanDepth ?? null,
700
+ match_whole_words: entry.matchWholeWords ?? null,
701
+ use_group_scoring: entry.useGroupScoring ?? false,
702
+ case_sensitive: entry.caseSensitive ?? null,
703
+ automation_id: entry.automationId ?? '',
704
+ role: entry.role ?? 0,
705
+ vectorized: entry.vectorized ?? false,
706
+ sticky: entry.sticky ?? null,
707
+ cooldown: entry.cooldown ?? null,
708
+ delay: entry.delay ?? null,
709
+ match_persona_description: entry.matchPersonaDescription ?? false,
710
+ match_character_description: entry.matchCharacterDescription ?? false,
711
+ match_character_personality: entry.matchCharacterPersonality ?? false,
712
+ match_character_depth_prompt: entry.matchCharacterDepthPrompt ?? false,
713
+ match_scenario: entry.matchScenario ?? false,
714
+ match_creator_notes: entry.matchCreatorNotes ?? false,
715
+ triggers: entry.triggers ?? [],
716
+ ignore_budget: entry.ignoreBudget ?? false,
717
+ },
718
+ };
719
+
720
+ result.entries.push(originalEntry);
721
+ }
722
+
723
+ return result;
724
+ }
725
+
726
+ /**
727
+ * Import a character from a YAML file.
728
+ * @param {string} uploadPath Path to the uploaded file
729
+ * @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects
730
+ * @param {string|undefined} preservedFileName Preserved file name
731
+ * @returns {Promise<string>} Internal name of the character
732
+ */
733
+ async function importFromYaml(uploadPath, context, preservedFileName) {
734
+ const fileText = fs.readFileSync(uploadPath, 'utf8');
735
+ fs.unlinkSync(uploadPath);
736
+ const yamlData = yaml.parse(fileText);
737
+ console.info('Importing from YAML');
738
+ yamlData.name = sanitize(yamlData.name);
739
+ const fileName = preservedFileName || getPngName(yamlData.name, context.request.user.directories);
740
+ let char = convertToV2({
741
+ 'name': yamlData.name,
742
+ 'description': yamlData.context ?? '',
743
+ 'first_mes': yamlData.greeting ?? '',
744
+ 'create_date': new Date().toISOString(),
745
+ 'chat': `${yamlData.name} - ${humanizedDateTime()}`,
746
+ 'personality': '',
747
+ 'creatorcomment': '',
748
+ 'avatar': 'none',
749
+ 'mes_example': '',
750
+ 'scenario': '',
751
+ 'talkativeness': 0.5,
752
+ 'creator': '',
753
+ 'tags': '',
754
+ }, context.request.user.directories);
755
+ const result = await writeCharacterData(DEFAULT_AVATAR_PATH, JSON.stringify(char), fileName, context.request);
756
+ return result ? fileName : '';
757
+ }
758
+
759
+ /**
760
+ * Imports a character card from CharX (ZIP) file.
761
+ * @param {string} uploadPath
762
+ * @param {object} params
763
+ * @param {import('express').Request} params.request
764
+ * @param {string|undefined} preservedFileName Preserved file name
765
+ * @returns {Promise<string>} Internal name of the character
766
+ */
767
+ async function importFromCharX(uploadPath, { request }, preservedFileName) {
768
+ const fileBuffer = fs.readFileSync(uploadPath);
769
+ // Create a properly-sized ArrayBuffer (Node's buffer pool can cause oversized .buffer)
770
+ const data = fileBuffer.buffer.slice(fileBuffer.byteOffset, fileBuffer.byteOffset + fileBuffer.byteLength);
771
+ fs.unlinkSync(uploadPath);
772
+
773
+ const parser = new CharXParser(data);
774
+ const { card, avatar, auxiliaryAssets, extractedBuffers } = await parser.parse();
775
+
776
+ // Apply standard character transformations
777
+ let processedCard = readFromV2(card);
778
+ unsetPrivateFields(processedCard);
779
+ processedCard['create_date'] = new Date().toISOString();
780
+ processedCard.name = sanitize(processedCard.name);
781
+
782
+ const fileName = preservedFileName || getPngName(processedCard.name, request.user.directories);
783
+ // Use the actual character name for asset folders, not the unique filename
784
+ // ST's sprite system looks up by character name, not PNG filename
785
+ const characterFolder = processedCard.name;
786
+
787
+ if (auxiliaryAssets.length > 0) {
788
+ try {
789
+ const summary = persistCharXAssets(auxiliaryAssets, extractedBuffers, request.user.directories, characterFolder);
790
+ if (summary.sprites || summary.backgrounds || summary.misc) {
791
+ console.log(`CharX: Imported ${summary.sprites} sprite(s), ${summary.backgrounds} background(s), ${summary.misc} misc asset(s) for ${characterFolder}`);
792
+ }
793
+ } catch (error) {
794
+ console.warn(`CharX: Failed to persist auxiliary assets for ${characterFolder}`, error);
795
+ }
796
+ }
797
+
798
+ const result = await writeCharacterData(avatar, JSON.stringify(processedCard), fileName, request);
799
+ return result ? fileName : '';
800
+ }
801
+
802
+ async function importFromByaf(uploadPath, { request }, preservedFileName) {
803
+ const data = (await fsPromises.readFile(uploadPath)).buffer;
804
+ await fsPromises.unlink(uploadPath);
805
+ console.info('Importing from BYAF');
806
+
807
+ const byafData = await new ByafParser(data).parse();
808
+ const card = readFromV2(byafData.card);
809
+ const fileName = preservedFileName || getPngName(sanitize(byafData.character.displayName || card.name, { replacement: sanitizeSafeCharacterReplacements }), request.user.directories);
810
+
811
+ // Don't import chats and images if the character is being replaced or updated, instead of newly imported.
812
+ if (!preservedFileName) {
813
+ /**
814
+ * @param {Partial<ByafScenario>} scenario
815
+ */
816
+ const createChatAsCurrentPersona = (scenario) => {
817
+ const chatName = sanitize(`${scenario.title || card.name} - ${humanizedDateTime()} imported.jsonl`, { replacement: sanitizeSafeCharacterReplacements });
818
+ const filePath = path.join(request.user.directories.chats, path.basename(fileName), chatName);
819
+ const dir = path.dirname(filePath);
820
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
821
+ writeFileAtomicSync(filePath, ByafParser.getChatFromScenario(scenario, request.body.user_name, card.name, byafData.chatBackgrounds), 'utf8');
822
+ console.log(`Created ${chatName} chat from BYAF import`);
823
+ return chatName;
824
+ };
825
+
826
+ // Upload backgrounds
827
+ for (const bg of byafData.chatBackgrounds) {
828
+ const extension = path.extname(bg.paths?.[0]) || '.png';
829
+ const baseName = `${path.basename(fileName)}_bg`;
830
+ const filePath = path.join(request.user.directories.userImages, fileName);
831
+ if (!fs.existsSync(filePath)) fs.mkdirSync(filePath, { recursive: true });
832
+ const file = getUniqueName(baseName, (name) => fs.existsSync(path.join(filePath, `${name}${extension}`)));
833
+ if (Buffer.isBuffer(bg.data)) {
834
+ const newFile = `${file}${extension}`;
835
+ writeFileAtomicSync(path.join(filePath, newFile), bg.data);
836
+ bg.name = clientRelativePath(request.user.directories.root, path.join(filePath, newFile)); // Update background name to the new file
837
+ console.log(`Created ${newFile} background from BYAF import`);
838
+ }
839
+ }
840
+
841
+ const chats = [];
842
+ // Create chats for each scenario
843
+ if (Array.isArray(byafData.scenarios)) {
844
+ for (const scenario of byafData.scenarios) {
845
+ chats.push(createChatAsCurrentPersona(scenario));
846
+ }
847
+ }
848
+
849
+ // Update the default chat if there are any so we open to an existing chat instead of creating a new one and opening that.
850
+ if (chats.length > 0) {
851
+ card.chat = path.basename(chats[0], path.extname(chats[0]));
852
+ }
853
+
854
+ // Save alternate icons for the character.
855
+ for (const icon of byafData.images.slice(1)) {
856
+ // BYAF does not support character expressions, so using the same structure will not result in conflicts,
857
+ // even if the expression system did not tolerate additional icons that are not mapped to expressions.
858
+ // This will not yet allow changing icons within the UI but at least the icons will be available for manual selection, rather than being lost.
859
+ const altImagesFolder = path.join(request.user.directories.characters, sanitize(card.name));
860
+ if (!fs.existsSync(altImagesFolder)) fs.mkdirSync(altImagesFolder, { recursive: true });
861
+ const extension = path.extname(icon.filename) || '.png';
862
+ const file = getUniqueName(`${sanitize(icon.label, { replacement: sanitizeSafeCharacterReplacements }) || 'alt'}`, (name) => fs.existsSync(path.join(altImagesFolder, `${name}${extension}`)));
863
+ if (Buffer.isBuffer(icon.image)) {
864
+ writeFileAtomicSync(path.join(altImagesFolder, `${file}${extension}`), icon.image);
865
+ console.log(`Created ${file}${extension} alternate icon from BYAF import`);
866
+ }
867
+ }
868
+ }
869
+
870
+ const result = await writeCharacterData(byafData.images[0].image, JSON.stringify(card), fileName, request);
871
+
872
+ return result ? fileName : '';
873
+ }
874
+
875
+ /**
876
+ * Import a character from a JSON file.
877
+ * @param {string} uploadPath Path to the uploaded file
878
+ * @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects
879
+ * @param {string|undefined} preservedFileName Preserved file name
880
+ * @returns {Promise<string>} Internal name of the character
881
+ */
882
+ async function importFromJson(uploadPath, { request }, preservedFileName) {
883
+ const data = fs.readFileSync(uploadPath, 'utf8');
884
+ fs.unlinkSync(uploadPath);
885
+
886
+ let jsonData = JSON.parse(data);
887
+
888
+ if (jsonData.spec !== undefined) {
889
+ console.info(`Importing from ${jsonData.spec} json`);
890
+ importRisuSprites(request.user.directories, jsonData);
891
+ unsetPrivateFields(jsonData);
892
+ jsonData = readFromV2(jsonData);
893
+ jsonData['create_date'] = new Date().toISOString();
894
+ const pngName = preservedFileName || getPngName(jsonData.data?.name || jsonData.name, request.user.directories);
895
+ const char = JSON.stringify(jsonData);
896
+ const result = await writeCharacterData(DEFAULT_AVATAR_PATH, char, pngName, request);
897
+ return result ? pngName : '';
898
+ } else if (jsonData.name !== undefined) {
899
+ console.info('Importing from v1 json');
900
+ jsonData.name = sanitize(jsonData.name);
901
+ if (jsonData.creator_notes) {
902
+ jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', '');
903
+ }
904
+ const pngName = preservedFileName || getPngName(jsonData.name, request.user.directories);
905
+ let char = {
906
+ 'name': jsonData.name,
907
+ 'description': jsonData.description ?? '',
908
+ 'creatorcomment': jsonData.creatorcomment ?? jsonData.creator_notes ?? '',
909
+ 'personality': jsonData.personality ?? '',
910
+ 'first_mes': jsonData.first_mes ?? '',
911
+ 'avatar': 'none',
912
+ 'chat': jsonData.name + ' - ' + humanizedDateTime(),
913
+ 'mes_example': jsonData.mes_example ?? '',
914
+ 'scenario': jsonData.scenario ?? '',
915
+ 'create_date': new Date().toISOString(),
916
+ 'talkativeness': jsonData.talkativeness ?? 0.5,
917
+ 'creator': jsonData.creator ?? '',
918
+ 'tags': jsonData.tags ?? '',
919
+ };
920
+ char = convertToV2(char, request.user.directories);
921
+ let charJSON = JSON.stringify(char);
922
+ const result = await writeCharacterData(DEFAULT_AVATAR_PATH, charJSON, pngName, request);
923
+ return result ? pngName : '';
924
+ } else if (jsonData.char_name !== undefined) {//json Pygmalion notepad
925
+ console.info('Importing from gradio json');
926
+ jsonData.char_name = sanitize(jsonData.char_name);
927
+ if (jsonData.creator_notes) {
928
+ jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', '');
929
+ }
930
+ const pngName = preservedFileName || getPngName(jsonData.char_name, request.user.directories);
931
+ let char = {
932
+ 'name': jsonData.char_name,
933
+ 'description': jsonData.char_persona ?? '',
934
+ 'creatorcomment': jsonData.creatorcomment ?? jsonData.creator_notes ?? '',
935
+ 'personality': '',
936
+ 'first_mes': jsonData.char_greeting ?? '',
937
+ 'avatar': 'none',
938
+ 'chat': jsonData.name + ' - ' + humanizedDateTime(),
939
+ 'mes_example': jsonData.example_dialogue ?? '',
940
+ 'scenario': jsonData.world_scenario ?? '',
941
+ 'create_date': new Date().toISOString(),
942
+ 'talkativeness': jsonData.talkativeness ?? 0.5,
943
+ 'creator': jsonData.creator ?? '',
944
+ 'tags': jsonData.tags ?? '',
945
+ };
946
+ char = convertToV2(char, request.user.directories);
947
+ const charJSON = JSON.stringify(char);
948
+ const result = await writeCharacterData(DEFAULT_AVATAR_PATH, charJSON, pngName, request);
949
+ return result ? pngName : '';
950
+ }
951
+
952
+ return '';
953
+ }
954
+
955
+ /**
956
+ * Import a character from a PNG file.
957
+ * @param {string} uploadPath Path to the uploaded file
958
+ * @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects
959
+ * @param {string|undefined} preservedFileName Preserved file name
960
+ * @returns {Promise<string>} Internal name of the character
961
+ */
962
+ async function importFromPng(uploadPath, { request }, preservedFileName) {
963
+ const imgData = await readCharacterData(uploadPath);
964
+ if (imgData === undefined) throw new Error('Failed to read character data');
965
+
966
+ let jsonData = JSON.parse(imgData);
967
+
968
+ jsonData.name = sanitize(jsonData.data?.name || jsonData.name);
969
+ const pngName = preservedFileName || getPngName(jsonData.name, request.user.directories);
970
+
971
+ if (jsonData.spec !== undefined) {
972
+ console.info(`Found a ${jsonData.spec} character file.`);
973
+ importRisuSprites(request.user.directories, jsonData);
974
+ unsetPrivateFields(jsonData);
975
+ jsonData = readFromV2(jsonData);
976
+ jsonData['create_date'] = new Date().toISOString();
977
+ const char = JSON.stringify(jsonData);
978
+ const result = await writeCharacterData(uploadPath, char, pngName, request);
979
+ fs.unlinkSync(uploadPath);
980
+ return result ? pngName : '';
981
+ } else if (jsonData.name !== undefined) {
982
+ console.info('Found a v1 character file.');
983
+
984
+ if (jsonData.creator_notes) {
985
+ jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', '');
986
+ }
987
+
988
+ let char = {
989
+ 'name': jsonData.name,
990
+ 'description': jsonData.description ?? '',
991
+ 'creatorcomment': jsonData.creatorcomment ?? jsonData.creator_notes ?? '',
992
+ 'personality': jsonData.personality ?? '',
993
+ 'first_mes': jsonData.first_mes ?? '',
994
+ 'avatar': 'none',
995
+ 'chat': jsonData.name + ' - ' + humanizedDateTime(),
996
+ 'mes_example': jsonData.mes_example ?? '',
997
+ 'scenario': jsonData.scenario ?? '',
998
+ 'create_date': new Date().toISOString(),
999
+ 'talkativeness': jsonData.talkativeness ?? 0.5,
1000
+ 'creator': jsonData.creator ?? '',
1001
+ 'tags': jsonData.tags ?? '',
1002
+ };
1003
+ char = convertToV2(char, request.user.directories);
1004
+ const charJSON = JSON.stringify(char);
1005
+ const result = await writeCharacterData(uploadPath, charJSON, pngName, request);
1006
+ fs.unlinkSync(uploadPath);
1007
+ return result ? pngName : '';
1008
+ }
1009
+
1010
+ return '';
1011
+ }
1012
+
1013
+ export const router = express.Router();
1014
+
1015
+ router.post('/create', getFileNameValidationFunction('file_name'), async function (request, response) {
1016
+ try {
1017
+ if (!request.body) return response.sendStatus(400);
1018
+
1019
+ request.body.ch_name = sanitize(request.body.ch_name);
1020
+
1021
+ const char = JSON.stringify(charaFormatData(request.body, request.user.directories));
1022
+ const internalName = request.body.file_name || getPngName(request.body.ch_name, request.user.directories);
1023
+ const avatarName = `${internalName}.png`;
1024
+ const chatsPath = path.join(request.user.directories.chats, internalName);
1025
+
1026
+ if (!fs.existsSync(chatsPath)) fs.mkdirSync(chatsPath);
1027
+
1028
+ if (!request.file) {
1029
+ await writeCharacterData(DEFAULT_AVATAR_PATH, char, internalName, request);
1030
+ return response.send(avatarName);
1031
+ } else {
1032
+ const crop = tryParse(request.query.crop);
1033
+ const uploadPath = path.join(request.file.destination, request.file.filename);
1034
+ await writeCharacterData(uploadPath, char, internalName, request, crop);
1035
+ fs.unlinkSync(uploadPath);
1036
+ return response.send(avatarName);
1037
+ }
1038
+ } catch (err) {
1039
+ console.error(err);
1040
+ response.sendStatus(500);
1041
+ }
1042
+ });
1043
+
1044
+ router.post('/rename', validateAvatarUrlMiddleware, async function (request, response) {
1045
+ if (!request.body.avatar_url || !request.body.new_name) {
1046
+ return response.sendStatus(400);
1047
+ }
1048
+
1049
+ const oldAvatarName = request.body.avatar_url;
1050
+ const newName = sanitize(request.body.new_name);
1051
+ const oldInternalName = path.parse(request.body.avatar_url).name;
1052
+ const newInternalName = getPngName(newName, request.user.directories);
1053
+ const newAvatarName = `${newInternalName}.png`;
1054
+
1055
+ const oldAvatarPath = path.join(request.user.directories.characters, oldAvatarName);
1056
+
1057
+ const oldChatsPath = path.join(request.user.directories.chats, oldInternalName);
1058
+ const newChatsPath = path.join(request.user.directories.chats, newInternalName);
1059
+
1060
+ try {
1061
+ // Read old file, replace name int it
1062
+ const rawOldData = await readCharacterData(oldAvatarPath);
1063
+ if (rawOldData === undefined) throw new Error('Failed to read character file');
1064
+
1065
+ const oldData = getCharaCardV2(JSON.parse(rawOldData), request.user.directories);
1066
+ _.set(oldData, 'data.name', newName);
1067
+ _.set(oldData, 'name', newName);
1068
+ const newData = JSON.stringify(oldData);
1069
+
1070
+ // Write data to new location
1071
+ await writeCharacterData(oldAvatarPath, newData, newInternalName, request);
1072
+
1073
+ // Rename chats folder
1074
+ if (fs.existsSync(oldChatsPath) && !fs.existsSync(newChatsPath)) {
1075
+ fs.cpSync(oldChatsPath, newChatsPath, { recursive: true });
1076
+ fs.rmSync(oldChatsPath, { recursive: true, force: true });
1077
+ }
1078
+
1079
+ // Remove the old character file
1080
+ fs.unlinkSync(oldAvatarPath);
1081
+
1082
+ // Return new avatar name to ST
1083
+ return response.send({ avatar: newAvatarName });
1084
+ }
1085
+ catch (err) {
1086
+ console.error(err);
1087
+ return response.sendStatus(500);
1088
+ }
1089
+ });
1090
+
1091
+ router.post('/edit', validateAvatarUrlMiddleware, async function (request, response) {
1092
+ if (!request.body) {
1093
+ console.warn('Error: no response body detected');
1094
+ response.status(400).send('Error: no response body detected');
1095
+ return;
1096
+ }
1097
+
1098
+ if (request.body.ch_name === '' || request.body.ch_name === undefined || request.body.ch_name === '.') {
1099
+ console.warn('Error: invalid name.');
1100
+ response.status(400).send('Error: invalid name.');
1101
+ return;
1102
+ }
1103
+
1104
+ let char = charaFormatData(request.body, request.user.directories);
1105
+ char.chat = request.body.chat;
1106
+ char.create_date = request.body.create_date;
1107
+ char = JSON.stringify(char);
1108
+ let targetFile = (request.body.avatar_url).replace('.png', '');
1109
+
1110
+ try {
1111
+ if (!request.file) {
1112
+ const avatarPath = path.join(request.user.directories.characters, request.body.avatar_url);
1113
+ await writeCharacterData(avatarPath, char, targetFile, request);
1114
+ } else {
1115
+ const crop = tryParse(request.query.crop);
1116
+ const newAvatarPath = path.join(request.file.destination, request.file.filename);
1117
+ invalidateThumbnail(request.user.directories, 'avatar', request.body.avatar_url);
1118
+ await writeCharacterData(newAvatarPath, char, targetFile, request, crop);
1119
+ fs.unlinkSync(newAvatarPath);
1120
+
1121
+ // Bust cache to reload the new avatar
1122
+ cacheBuster.bust(request, response);
1123
+ }
1124
+
1125
+ return response.sendStatus(200);
1126
+ } catch (err) {
1127
+ console.error('An error occurred, character edit invalidated.', err);
1128
+ return response.sendStatus(500);
1129
+ }
1130
+ });
1131
+
1132
+ router.post('/edit-avatar', validateAvatarUrlMiddleware, async function (request, response) {
1133
+ try {
1134
+ if (!request.file) {
1135
+ return response.status(400).send('Error: no file uploaded');
1136
+ }
1137
+
1138
+ if (!request.body || !request.body.avatar_url) {
1139
+ return response.status(400).send('Error: no avatar_url in request body');
1140
+ }
1141
+
1142
+ const uploadPath = path.join(request.file.destination, request.file.filename);
1143
+ if (!fs.existsSync(uploadPath)) {
1144
+ return response.status(400).send('Error: uploaded file does not exist');
1145
+ }
1146
+ const characterPath = path.join(request.user.directories.characters, request.body.avatar_url);
1147
+ if (!fs.existsSync(characterPath)) {
1148
+ return response.status(400).send('Error: character file does not exist');
1149
+ }
1150
+ const data = await readCharacterData(characterPath);
1151
+ if (!data) {
1152
+ return response.status(400).send('Error: failed to read character data');
1153
+ }
1154
+
1155
+ const crop = tryParse(request.query.crop);
1156
+ const fileName = request.body.avatar_url.replace('.png', '');
1157
+ await writeCharacterData(uploadPath, data, fileName, request, crop);
1158
+
1159
+ // Remove uploaded temp file
1160
+ fs.unlinkSync(uploadPath);
1161
+
1162
+ // Reset images caches
1163
+ cacheBuster.bust(request, response);
1164
+ invalidateThumbnail(request.user.directories, 'avatar', request.body.avatar_url);
1165
+
1166
+ return response.sendStatus(200);
1167
+ } catch (err) {
1168
+ console.error('An error occurred while editing avatar', err);
1169
+ return response.sendStatus(500);
1170
+ }
1171
+ });
1172
+
1173
+ /**
1174
+ * Handle a POST request to edit a character attribute.
1175
+ *
1176
+ * This function reads the character data from a file, updates the specified attribute,
1177
+ * and writes the updated data back to the file.
1178
+ *
1179
+ * @param {Object} request - The HTTP request object.
1180
+ * @param {Object} response - The HTTP response object.
1181
+ * @returns {void}
1182
+ */
1183
+ router.post('/edit-attribute', validateAvatarUrlMiddleware, async function (request, response) {
1184
+ console.debug(request.body);
1185
+ if (!request.body) {
1186
+ console.warn('Error: no response body detected');
1187
+ return response.status(400).send('Error: no response body detected');
1188
+ }
1189
+
1190
+ if (request.body.ch_name === '' || request.body.ch_name === undefined || request.body.ch_name === '.') {
1191
+ console.warn('Error: invalid name.');
1192
+ return response.status(400).send('Error: invalid name.');
1193
+ }
1194
+
1195
+ if (request.body.field === 'json_data') {
1196
+ console.warn('Error: cannot edit json_data field.');
1197
+ return response.status(400).send('Error: cannot edit json_data field.');
1198
+ }
1199
+
1200
+ try {
1201
+ const avatarPath = path.join(request.user.directories.characters, request.body.avatar_url);
1202
+ const charJSON = await readCharacterData(avatarPath);
1203
+ if (typeof charJSON !== 'string') throw new Error('Failed to read character file');
1204
+
1205
+ const char = JSON.parse(charJSON);
1206
+ //check if the field exists
1207
+ if (char[request.body.field] === undefined && char.data[request.body.field] === undefined) {
1208
+ console.warn('Error: invalid field.');
1209
+ response.status(400).send('Error: invalid field.');
1210
+ return;
1211
+ }
1212
+ char[request.body.field] = request.body.value;
1213
+ char.data[request.body.field] = request.body.value;
1214
+ let newCharJSON = JSON.stringify(char);
1215
+ const targetFile = (request.body.avatar_url).replace('.png', '');
1216
+ await writeCharacterData(avatarPath, newCharJSON, targetFile, request);
1217
+ return response.sendStatus(200);
1218
+ } catch (err) {
1219
+ console.error('An error occurred, character edit invalidated.', err);
1220
+ return response.sendStatus(500);
1221
+ }
1222
+ });
1223
+
1224
+ /**
1225
+ * Handle a POST request to edit character properties.
1226
+ *
1227
+ * Merges the request body with the selected character and
1228
+ * validates the result against TavernCard V2 specification.
1229
+ *
1230
+ * @param {Object} request - The HTTP request object.
1231
+ * @param {Object} response - The HTTP response object.
1232
+ *
1233
+ * @returns {void}
1234
+ * */
1235
+ router.post('/merge-attributes', getFileNameValidationFunction('avatar'), async function (request, response) {
1236
+ try {
1237
+ const update = request.body;
1238
+ const avatarPath = path.join(request.user.directories.characters, update.avatar);
1239
+
1240
+ const pngStringData = await readCharacterData(avatarPath);
1241
+
1242
+ if (!pngStringData) {
1243
+ console.error('Error: invalid character file.');
1244
+ return response.status(400).send('Error: invalid character file.');
1245
+ }
1246
+
1247
+ let character = JSON.parse(pngStringData);
1248
+
1249
+ _.unset(update, 'json_data');
1250
+ _.unset(character, 'json_data');
1251
+
1252
+ character = deepMerge(character, update);
1253
+
1254
+ const validator = new TavernCardValidator(character);
1255
+ const targetImg = (update.avatar).replace('.png', '');
1256
+
1257
+ //Accept either V1 or V2.
1258
+ if (validator.validate()) {
1259
+ await writeCharacterData(avatarPath, JSON.stringify(character), targetImg, request);
1260
+ response.sendStatus(200);
1261
+ } else {
1262
+ console.warn(validator.lastValidationError);
1263
+ response.status(400).send({ message: `Validation failed for ${character.name}`, error: validator.lastValidationError });
1264
+ }
1265
+ } catch (exception) {
1266
+ response.status(500).send({ message: 'Unexpected error while saving character.', error: exception.toString() });
1267
+ }
1268
+ });
1269
+
1270
+ router.post('/delete', validateAvatarUrlMiddleware, async function (request, response) {
1271
+ if (!request.body || !request.body.avatar_url) {
1272
+ return response.sendStatus(400);
1273
+ }
1274
+
1275
+ if (request.body.avatar_url !== sanitize(request.body.avatar_url)) {
1276
+ console.error('Malicious filename prevented');
1277
+ return response.sendStatus(403);
1278
+ }
1279
+
1280
+ const avatarPath = path.join(request.user.directories.characters, request.body.avatar_url);
1281
+ if (!fs.existsSync(avatarPath)) {
1282
+ return response.sendStatus(400);
1283
+ }
1284
+
1285
+ fs.unlinkSync(avatarPath);
1286
+ invalidateThumbnail(request.user.directories, 'avatar', request.body.avatar_url);
1287
+ let dir_name = (request.body.avatar_url.replace('.png', ''));
1288
+
1289
+ if (!dir_name.length) {
1290
+ console.error('Malicious dirname prevented');
1291
+ return response.sendStatus(403);
1292
+ }
1293
+
1294
+ if (request.body.delete_chats == true) {
1295
+ try {
1296
+ await fs.promises.rm(path.join(request.user.directories.chats, sanitize(dir_name)), { recursive: true, force: true });
1297
+ } catch (err) {
1298
+ console.error(err);
1299
+ return response.sendStatus(500);
1300
+ }
1301
+ }
1302
+
1303
+ return response.sendStatus(200);
1304
+ });
1305
+
1306
+ /**
1307
+ * HTTP POST endpoint for the "/api/characters/all" route.
1308
+ *
1309
+ * This endpoint is responsible for reading character files from the `charactersPath` directory,
1310
+ * parsing character data, calculating stats for each character and responding with the data.
1311
+ * Stats are calculated only on the first run, on subsequent runs the stats are fetched from
1312
+ * the `charStats` variable.
1313
+ * The stats are calculated by the `calculateStats` function.
1314
+ * The characters are processed by the `processCharacter` function.
1315
+ *
1316
+ * @param {import("express").Request} request The HTTP request object.
1317
+ * @param {import("express").Response} response The HTTP response object.
1318
+ * @return {void}
1319
+ */
1320
+ router.post('/all', async function (request, response) {
1321
+ try {
1322
+ const files = fs.readdirSync(request.user.directories.characters);
1323
+ const pngFiles = files.filter(file => file.endsWith('.png'));
1324
+ const processingPromises = pngFiles.map(file => processCharacter(file, request.user.directories, { shallow: useShallowCharacters }));
1325
+ const data = (await Promise.all(processingPromises)).filter(c => c.name);
1326
+ return response.send(data);
1327
+ } catch (err) {
1328
+ console.error(err);
1329
+ const isRangeError = err instanceof RangeError;
1330
+ response.status(500).send({ overflow: isRangeError, error: true });
1331
+ }
1332
+ });
1333
+
1334
+ router.post('/get', validateAvatarUrlMiddleware, async function (request, response) {
1335
+ try {
1336
+ if (!request.body) return response.sendStatus(400);
1337
+ const item = request.body.avatar_url;
1338
+ const filePath = path.join(request.user.directories.characters, item);
1339
+
1340
+ if (!fs.existsSync(filePath)) {
1341
+ return response.sendStatus(404);
1342
+ }
1343
+
1344
+ const data = await processCharacter(item, request.user.directories, { shallow: false });
1345
+
1346
+ return response.send(data);
1347
+ } catch (err) {
1348
+ console.error(err);
1349
+ response.sendStatus(500);
1350
+ }
1351
+ });
1352
+
1353
+ router.post('/chats', validateAvatarUrlMiddleware, async function (request, response) {
1354
+ try {
1355
+ if (!request.body) return response.sendStatus(400);
1356
+
1357
+ const characterDirectory = (request.body.avatar_url).replace('.png', '');
1358
+ const chatsDirectory = path.join(request.user.directories.chats, characterDirectory);
1359
+
1360
+ if (!fs.existsSync(chatsDirectory)) {
1361
+ return response.send({ error: true });
1362
+ }
1363
+
1364
+ const files = fs.readdirSync(chatsDirectory, { withFileTypes: true });
1365
+ const jsonFiles = files.filter(file => file.isFile() && path.extname(file.name) === '.jsonl').map(file => file.name);
1366
+
1367
+ if (jsonFiles.length === 0) {
1368
+ return response.send([]);
1369
+ }
1370
+
1371
+ if (request.body.simple) {
1372
+ return response.send(jsonFiles.map(file => ({ file_name: file, file_id: path.parse(file).name })));
1373
+ }
1374
+
1375
+ const jsonFilesPromise = jsonFiles.map((file) => {
1376
+ const withMetadata = !!request.body.metadata;
1377
+ const pathToFile = path.join(request.user.directories.chats, characterDirectory, file);
1378
+ return getChatInfo(pathToFile, {}, withMetadata);
1379
+ });
1380
+
1381
+ const chatData = (await Promise.allSettled(jsonFilesPromise)).filter(x => x.status === 'fulfilled').map(x => x.value);
1382
+ const validFiles = chatData.filter(i => i.file_name);
1383
+
1384
+ return response.send(validFiles);
1385
+ } catch (error) {
1386
+ console.error(error);
1387
+ return response.send({ error: true });
1388
+ }
1389
+ });
1390
+
1391
+ /**
1392
+ * Gets the name for the uploaded PNG file.
1393
+ * @param {string} file File name
1394
+ * @param {import('../users.js').UserDirectoryList} directories User directories
1395
+ * @returns {string} - The name for the uploaded PNG file
1396
+ */
1397
+ function getPngName(file, directories) {
1398
+ let i = 1;
1399
+ const baseName = file;
1400
+ while (fs.existsSync(path.join(directories.characters, `${file}.png`))) {
1401
+ file = baseName + i;
1402
+ i++;
1403
+ }
1404
+ return file;
1405
+ }
1406
+
1407
+ /**
1408
+ * Gets the preserved name for the uploaded file if the request is valid.
1409
+ * @param {import("express").Request} request - Express request object
1410
+ * @returns {string | undefined} - The preserved name if the request is valid, otherwise undefined
1411
+ */
1412
+ function getPreservedName(request) {
1413
+ return typeof request.body.preserved_name === 'string' && request.body.preserved_name.length > 0
1414
+ ? path.parse(request.body.preserved_name).name
1415
+ : undefined;
1416
+ }
1417
+
1418
+ router.post('/import', async function (request, response) {
1419
+ if (!request.body || !request.file) return response.sendStatus(400);
1420
+
1421
+ const uploadPath = path.join(request.file.destination, request.file.filename);
1422
+ const format = request.body.file_type;
1423
+ const preservedFileName = getPreservedName(request);
1424
+
1425
+ const formatImportFunctions = {
1426
+ 'yaml': importFromYaml,
1427
+ 'yml': importFromYaml,
1428
+ 'json': importFromJson,
1429
+ 'png': importFromPng,
1430
+ 'charx': importFromCharX,
1431
+ 'byaf': importFromByaf,
1432
+ };
1433
+
1434
+ try {
1435
+ const importFunction = formatImportFunctions[format];
1436
+
1437
+ if (!importFunction) {
1438
+ throw new Error(`Unsupported format: ${format}`);
1439
+ }
1440
+
1441
+ const fileName = await importFunction(uploadPath, { request, response }, preservedFileName);
1442
+
1443
+ if (!fileName) {
1444
+ console.warn('Failed to import character');
1445
+ return response.sendStatus(400);
1446
+ }
1447
+
1448
+ if (preservedFileName) {
1449
+ invalidateThumbnail(request.user.directories, 'avatar', `${preservedFileName}.png`);
1450
+ }
1451
+
1452
+ response.send({ file_name: fileName });
1453
+ } catch (err) {
1454
+ console.error(err);
1455
+ response.send({ error: true });
1456
+ }
1457
+ });
1458
+
1459
+ router.post('/duplicate', validateAvatarUrlMiddleware, async function (request, response) {
1460
+ try {
1461
+ if (!request.body.avatar_url) {
1462
+ console.warn('avatar URL not found in request body');
1463
+ console.debug(request.body);
1464
+ return response.sendStatus(400);
1465
+ }
1466
+ let filename = path.join(request.user.directories.characters, sanitize(request.body.avatar_url));
1467
+ if (!fs.existsSync(filename)) {
1468
+ console.error('file for dupe not found', filename);
1469
+ return response.sendStatus(404);
1470
+ }
1471
+ let suffix = 1;
1472
+ let newFilename = filename;
1473
+
1474
+ // If filename ends with a _number, increment the number
1475
+ const nameParts = path.basename(filename, path.extname(filename)).split('_');
1476
+ const lastPart = nameParts[nameParts.length - 1];
1477
+
1478
+ let baseName;
1479
+
1480
+ if (!isNaN(Number(lastPart)) && nameParts.length > 1) {
1481
+ suffix = parseInt(lastPart) + 1;
1482
+ baseName = nameParts.slice(0, -1).join('_'); // construct baseName without suffix
1483
+ } else {
1484
+ baseName = nameParts.join('_'); // original filename is completely the baseName
1485
+ }
1486
+
1487
+ newFilename = path.join(request.user.directories.characters, `${baseName}_${suffix}${path.extname(filename)}`);
1488
+
1489
+ while (fs.existsSync(newFilename)) {
1490
+ let suffixStr = '_' + suffix;
1491
+ newFilename = path.join(request.user.directories.characters, `${baseName}${suffixStr}${path.extname(filename)}`);
1492
+ suffix++;
1493
+ }
1494
+
1495
+ fs.copyFileSync(filename, newFilename);
1496
+ console.info(`${filename} was copied to ${newFilename}`);
1497
+ response.send({ path: path.parse(newFilename).base });
1498
+ }
1499
+ catch (error) {
1500
+ console.error(error);
1501
+ return response.send({ error: true });
1502
+ }
1503
+ });
1504
+
1505
+ router.post('/export', validateAvatarUrlMiddleware, async function (request, response) {
1506
+ try {
1507
+ if (!request.body.format || !request.body.avatar_url) {
1508
+ return response.sendStatus(400);
1509
+ }
1510
+
1511
+ let filename = path.join(request.user.directories.characters, sanitize(request.body.avatar_url));
1512
+
1513
+ if (!fs.existsSync(filename)) {
1514
+ return response.sendStatus(404);
1515
+ }
1516
+
1517
+ switch (request.body.format) {
1518
+ case 'png': {
1519
+ const rawBuffer = await fsPromises.readFile(filename);
1520
+ const rawData = read(rawBuffer);
1521
+ const mutatedData = mutateJsonString(rawData, unsetPrivateFields);
1522
+ const mutatedBuffer = write(rawBuffer, mutatedData);
1523
+ const contentType = mime.lookup(filename) || 'image/png';
1524
+ response.setHeader('Content-Type', contentType);
1525
+ response.setHeader('Content-Disposition', `attachment; filename="${encodeURI(path.basename(filename))}"`);
1526
+ return response.send(mutatedBuffer);
1527
+ }
1528
+ case 'json': {
1529
+ try {
1530
+ const json = await readCharacterData(filename);
1531
+ if (json === undefined) return response.sendStatus(400);
1532
+ const jsonObject = getCharaCardV2(JSON.parse(json), request.user.directories);
1533
+ unsetPrivateFields(jsonObject);
1534
+ return response.type('json').send(JSON.stringify(jsonObject, null, 4));
1535
+ }
1536
+ catch {
1537
+ return response.sendStatus(400);
1538
+ }
1539
+ }
1540
+ }
1541
+
1542
+ return response.sendStatus(400);
1543
+ } catch (err) {
1544
+ console.error('Character export failed', err);
1545
+ response.sendStatus(500);
1546
+ }
1547
+ });
src/endpoints/chats.js ADDED
@@ -0,0 +1,1020 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline';
4
+ import process from 'node:process';
5
+
6
+ import express from 'express';
7
+ import sanitize from 'sanitize-filename';
8
+ import { sync as writeFileAtomicSync } from 'write-file-atomic';
9
+ import _ from 'lodash';
10
+
11
+ import validateAvatarUrlMiddleware from '../middleware/validateFileName.js';
12
+ import {
13
+ getConfigValue,
14
+ humanizedDateTime,
15
+ tryParse,
16
+ generateTimestamp,
17
+ removeOldBackups,
18
+ formatBytes,
19
+ tryWriteFileSync,
20
+ tryReadFileSync,
21
+ tryDeleteFile,
22
+ readFirstLine,
23
+ } from '../util.js';
24
+
25
+ const isBackupEnabled = !!getConfigValue('backups.chat.enabled', true, 'boolean');
26
+ const maxTotalChatBackups = Number(getConfigValue('backups.chat.maxTotalBackups', -1, 'number'));
27
+ const throttleInterval = Number(getConfigValue('backups.chat.throttleInterval', 10_000, 'number'));
28
+ const checkIntegrity = !!getConfigValue('backups.chat.checkIntegrity', true, 'boolean');
29
+
30
+ export const CHAT_BACKUPS_PREFIX = 'chat_';
31
+
32
+ /**
33
+ * Saves a chat to the backups directory.
34
+ * @param {string} directory The user's backup directory.
35
+ * @param {string} name The name of the chat.
36
+ * @param {string} data The serialized chat to save.
37
+ * @param {string} backupPrefix The file prefix. Typically CHAT_BACKUPS_PREFIX.
38
+ * @returns
39
+ */
40
+ function backupChat(directory, name, data, backupPrefix = CHAT_BACKUPS_PREFIX) {
41
+ try {
42
+ if (!isBackupEnabled) { return; }
43
+ if (!fs.existsSync(directory)) {
44
+ console.error(`The chat couldn't be backed up because no directory exists at ${directory}!`);
45
+ }
46
+ // replace non-alphanumeric characters with underscores
47
+ name = sanitize(name).replace(/[^a-z0-9]/gi, '_').toLowerCase();
48
+
49
+ const backupFile = path.join(directory, `${backupPrefix}${name}_${generateTimestamp()}.jsonl`);
50
+
51
+ tryWriteFileSync(backupFile, data);
52
+ removeOldBackups(directory, `${backupPrefix}${name}_`);
53
+ if (isNaN(maxTotalChatBackups) || maxTotalChatBackups < 0) {
54
+ return;
55
+ }
56
+ removeOldBackups(directory, backupPrefix, maxTotalChatBackups);
57
+ } catch (err) {
58
+ console.error(`Could not backup chat for ${name}`, err);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * @type {Map<string, import('lodash').DebouncedFunc<typeof backupChat>>}
64
+ */
65
+ const backupFunctions = new Map();
66
+
67
+ /**
68
+ * Gets a backup function for a user.
69
+ * @param {string} handle User handle
70
+ * @returns {typeof backupChat} Backup function
71
+ */
72
+ function getBackupFunction(handle) {
73
+ if (!backupFunctions.has(handle)) {
74
+ backupFunctions.set(handle, _.throttle(backupChat, throttleInterval, { leading: true, trailing: true }));
75
+ }
76
+ return backupFunctions.get(handle) || (() => { });
77
+ }
78
+
79
+ /**
80
+ * Gets a preview message from an array of chat messages
81
+ * @param {Array<Object>} messages - Array of chat messages, each with a 'mes' property
82
+ * @returns {string} A truncated preview of the last message or empty string if no messages
83
+ */
84
+ function getPreviewMessage(messages) {
85
+ const strlen = 400;
86
+ const lastMessage = messages[messages.length - 1]?.mes;
87
+
88
+ if (!lastMessage) {
89
+ return '';
90
+ }
91
+
92
+ return lastMessage.length > strlen
93
+ ? '...' + lastMessage.substring(lastMessage.length - strlen)
94
+ : lastMessage;
95
+ }
96
+
97
+ process.on('exit', () => {
98
+ for (const func of backupFunctions.values()) {
99
+ func.flush();
100
+ }
101
+ });
102
+
103
+ /**
104
+ * Imports a chat from Ooba's format.
105
+ * @param {string} userName User name
106
+ * @param {string} characterName Character name
107
+ * @param {object} jsonData JSON data
108
+ * @returns {string} Chat data
109
+ */
110
+ function importOobaChat(userName, characterName, jsonData) {
111
+ /** @type {object[]} */
112
+ const chat = [{
113
+ chat_metadata: {},
114
+ user_name: 'unused',
115
+ character_name: 'unused',
116
+ }];
117
+
118
+ for (const arr of jsonData.data_visible) {
119
+ if (arr[0]) {
120
+ const userMessage = {
121
+ name: userName,
122
+ is_user: true,
123
+ send_date: new Date().toISOString(),
124
+ mes: arr[0],
125
+ extra: {},
126
+ };
127
+ chat.push(userMessage);
128
+ }
129
+ if (arr[1]) {
130
+ const charMessage = {
131
+ name: characterName,
132
+ is_user: false,
133
+ send_date: new Date().toISOString(),
134
+ mes: arr[1],
135
+ extra: {},
136
+ };
137
+ chat.push(charMessage);
138
+ }
139
+ }
140
+
141
+ return chat.map(obj => JSON.stringify(obj)).join('\n');
142
+ }
143
+
144
+ /**
145
+ * Imports a chat from Agnai's format.
146
+ * @param {string} userName User name
147
+ * @param {string} characterName Character name
148
+ * @param {object} jsonData Chat data
149
+ * @returns {string} Chat data
150
+ */
151
+ function importAgnaiChat(userName, characterName, jsonData) {
152
+ /** @type {object[]} */
153
+ const chat = [{
154
+ chat_metadata: {},
155
+ user_name: 'unused',
156
+ character_name: 'unused',
157
+ }];
158
+
159
+ for (const message of jsonData.messages) {
160
+ const isUser = !!message.userId;
161
+ chat.push({
162
+ name: isUser ? userName : characterName,
163
+ is_user: isUser,
164
+ send_date: new Date().toISOString(),
165
+ mes: message.msg,
166
+ extra: {},
167
+ });
168
+ }
169
+
170
+ return chat.map(obj => JSON.stringify(obj)).join('\n');
171
+ }
172
+
173
+ /**
174
+ * Imports a chat from CAI Tools format.
175
+ * @param {string} userName User name
176
+ * @param {string} characterName Character name
177
+ * @param {object} jsonData JSON data
178
+ * @returns {string[]} Converted data
179
+ */
180
+ function importCAIChat(userName, characterName, jsonData) {
181
+ /**
182
+ * Converts the chat data to suitable format.
183
+ * @param {object} history Imported chat data
184
+ * @returns {object[]} Converted chat data
185
+ */
186
+ function convert(history) {
187
+ const starter = {
188
+ chat_metadata: {},
189
+ user_name: 'unused',
190
+ character_name: 'unused',
191
+ };
192
+
193
+ const historyData = history.msgs.map((msg) => ({
194
+ name: msg.src.is_human ? userName : characterName,
195
+ is_user: msg.src.is_human,
196
+ send_date: new Date().toISOString(),
197
+ mes: msg.text,
198
+ extra: {},
199
+ }));
200
+
201
+ return [starter, ...historyData];
202
+ }
203
+
204
+ const newChats = (jsonData.histories.histories ?? []).map(history => newChats.push(convert(history).map(obj => JSON.stringify(obj)).join('\n')));
205
+ return newChats;
206
+ }
207
+
208
+ /**
209
+ * Imports a chat from Kobold Lite format.
210
+ * @param {string} _userName User name
211
+ * @param {string} _characterName Character name
212
+ * @param {object} data JSON data
213
+ * @returns {string} Chat data
214
+ */
215
+ function importKoboldLiteChat(_userName, _characterName, data) {
216
+ const inputToken = '{{[INPUT]}}';
217
+ const outputToken = '{{[OUTPUT]}}';
218
+
219
+ /** @type {function(string): object} */
220
+ function processKoboldMessage(msg) {
221
+ const isUser = msg.includes(inputToken);
222
+ return {
223
+ name: isUser ? userName : characterName,
224
+ is_user: isUser,
225
+ mes: msg.replaceAll(inputToken, '').replaceAll(outputToken, '').trim(),
226
+ send_date: new Date().toISOString(),
227
+ extra: {},
228
+ };
229
+ }
230
+
231
+ // Create the header
232
+ const userName = String(data.savedsettings.chatname);
233
+ const characterName = String(data.savedsettings.chatopponent).split('||$||')[0];
234
+ const header = {
235
+ chat_metadata: {},
236
+ user_name: 'unused',
237
+ character_name: 'unused',
238
+ };
239
+ // Format messages
240
+ const formattedMessages = data.actions.map(processKoboldMessage);
241
+ // Add prompt if available
242
+ if (data.prompt) {
243
+ formattedMessages.unshift(processKoboldMessage(data.prompt));
244
+ }
245
+ // Combine header and messages
246
+ const chatData = [header, ...formattedMessages];
247
+ return chatData.map(obj => JSON.stringify(obj)).join('\n');
248
+ }
249
+
250
+ /**
251
+ * Flattens `msg` and `swipes` data from Chub Chat format.
252
+ * Only changes enough to make it compatible with the standard chat serialization format.
253
+ * @param {string} userName User name
254
+ * @param {string} characterName Character name
255
+ * @param {string[]} lines serialised JSONL data
256
+ * @returns {string} Converted data
257
+ */
258
+ function flattenChubChat(userName, characterName, lines) {
259
+ function flattenSwipe(swipe) {
260
+ return swipe.message ? swipe.message : swipe;
261
+ }
262
+
263
+ function convert(line) {
264
+ const lineData = tryParse(line);
265
+ if (!lineData) return line;
266
+
267
+ if (lineData.mes && lineData.mes.message) {
268
+ lineData.mes = lineData?.mes.message;
269
+ }
270
+
271
+ if (lineData?.swipes && Array.isArray(lineData.swipes)) {
272
+ lineData.swipes = lineData.swipes.map(swipe => flattenSwipe(swipe));
273
+ }
274
+
275
+ return JSON.stringify(lineData);
276
+ }
277
+
278
+ return (lines ?? []).map(convert).join('\n');
279
+ }
280
+
281
+ /**
282
+ * Imports a chat from RisuAI format.
283
+ * @param {string} userName User name
284
+ * @param {string} characterName Character name
285
+ * @param {object} jsonData Imported chat data
286
+ * @returns {string} Chat data
287
+ */
288
+ function importRisuChat(userName, characterName, jsonData) {
289
+ /** @type {object[]} */
290
+ const chat = [{
291
+ chat_metadata: {},
292
+ user_name: 'unused',
293
+ character_name: 'unused',
294
+ }];
295
+
296
+ for (const message of jsonData.data.message) {
297
+ const isUser = message.role === 'user';
298
+ chat.push({
299
+ name: message.name ?? (isUser ? userName : characterName),
300
+ is_user: isUser,
301
+ send_date: new Date(Number(message.time ?? Date.now())).toISOString(),
302
+ mes: message.data ?? '',
303
+ extra: {},
304
+ });
305
+ }
306
+
307
+ return chat.map(obj => JSON.stringify(obj)).join('\n');
308
+ }
309
+
310
+ /**
311
+ * Checks if the chat being saved has the same integrity as the one being loaded.
312
+ * @param {string} filePath Path to the chat file
313
+ * @param {string} integritySlug Integrity slug
314
+ * @returns {Promise<boolean>} Whether the chat is intact
315
+ */
316
+ async function checkChatIntegrity(filePath, integritySlug) {
317
+ // If the chat file doesn't exist, assume it's intact
318
+ if (!fs.existsSync(filePath)) {
319
+ return true;
320
+ }
321
+
322
+ // Parse the first line of the chat file as JSON
323
+ const firstLine = await readFirstLine(filePath);
324
+ const jsonData = tryParse(firstLine);
325
+ const chatIntegrity = jsonData?.chat_metadata?.integrity;
326
+
327
+ // If the chat has no integrity metadata, assume it's intact
328
+ if (!chatIntegrity) {
329
+ console.debug(`File "${filePath}" does not have integrity metadata matching "${integritySlug}". The integrity validation has been skipped.`);
330
+ return true;
331
+ }
332
+
333
+ // Check if the integrity matches
334
+ return chatIntegrity === integritySlug;
335
+ }
336
+
337
+ /**
338
+ * @typedef {Object} ChatInfo
339
+ * @property {string} [file_id] - The name of the chat file (without extension)
340
+ * @property {string} [file_name] - The name of the chat file (with extension)
341
+ * @property {string} [file_size] - The size of the chat file in a human-readable format
342
+ * @property {number} [chat_items] - The number of chat items in the file
343
+ * @property {string} [mes] - The last message in the chat
344
+ * @property {number} [last_mes] - The timestamp of the last message
345
+ * @property {object} [chat_metadata] - Additional chat metadata
346
+ */
347
+
348
+ /**
349
+ * Reads the information from a chat file.
350
+ * @param {string} pathToFile - Path to the chat file
351
+ * @param {object} additionalData - Additional data to include in the result
352
+ * @param {boolean} withMetadata - Whether to read chat metadata
353
+ * @returns {Promise<ChatInfo>}
354
+ */
355
+ export async function getChatInfo(pathToFile, additionalData = {}, withMetadata = false) {
356
+ return new Promise(async (res) => {
357
+ const parsedPath = path.parse(pathToFile);
358
+ const stats = await fs.promises.stat(pathToFile);
359
+
360
+ const chatData = {
361
+ file_id: parsedPath.name,
362
+ file_name: parsedPath.base,
363
+ file_size: formatBytes(stats.size),
364
+ chat_items: 0,
365
+ mes: '[The chat is empty]',
366
+ last_mes: stats.mtimeMs,
367
+ ...additionalData,
368
+ };
369
+
370
+ if (stats.size === 0) {
371
+ res(chatData);
372
+ return;
373
+ }
374
+
375
+ const fileStream = fs.createReadStream(pathToFile);
376
+ const rl = readline.createInterface({
377
+ input: fileStream,
378
+ crlfDelay: Infinity,
379
+ });
380
+
381
+ let lastLine;
382
+ let itemCounter = 0;
383
+ rl.on('line', (line) => {
384
+ if (withMetadata && itemCounter === 0) {
385
+ const jsonData = tryParse(line);
386
+ if (jsonData && _.isObjectLike(jsonData.chat_metadata)) {
387
+ chatData.chat_metadata = jsonData.chat_metadata;
388
+ }
389
+ }
390
+ itemCounter++;
391
+ lastLine = line;
392
+ });
393
+ rl.on('close', () => {
394
+ rl.close();
395
+
396
+ if (lastLine) {
397
+ const jsonData = tryParse(lastLine);
398
+ if (jsonData && (jsonData.name || jsonData.character_name || jsonData.chat_metadata)) {
399
+ chatData.chat_items = (itemCounter - 1);
400
+ chatData.mes = jsonData['mes'] || '[The message is empty]';
401
+ chatData.last_mes = jsonData['send_date'] || new Date(Math.round(stats.mtimeMs)).toISOString();
402
+
403
+ res(chatData);
404
+ } else {
405
+ console.warn('Found an invalid or corrupted chat file:', pathToFile);
406
+ res({});
407
+ }
408
+ }
409
+ });
410
+ });
411
+ }
412
+
413
+ export const router = express.Router();
414
+
415
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error
416
+ class IntegrityMismatchError extends Error {
417
+ constructor(...params) {
418
+ // Pass remaining arguments (including vendor specific ones) to parent constructor
419
+ super(...params);
420
+ // Maintains proper stack trace for where our error was thrown (non-standard)
421
+ if (Error.captureStackTrace) {
422
+ Error.captureStackTrace(this, IntegrityMismatchError);
423
+ }
424
+ this.date = new Date();
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Tries to save the chat data to a file, performing an integrity check if required.
430
+ * @param {Array} chatData The chat array to save.
431
+ * @param {string} filePath Target file path for the data.
432
+ * @param {boolean} skipIntegrityCheck If undefined, the chat's integrity will not be checked.
433
+ * @param {string} handle The users handle, passed to getBackupFunction.
434
+ * @param {string} cardName Passed to backupChat.
435
+ * @param {string} backupDirectory Passed to backupChat.
436
+ */
437
+ export async function trySaveChat(chatData, filePath, skipIntegrityCheck = false, handle, cardName, backupDirectory) {
438
+ const jsonlData = chatData?.map(m => JSON.stringify(m)).join('\n');
439
+
440
+ const doIntegrityCheck = (checkIntegrity && !skipIntegrityCheck);
441
+ const chatIntegritySlug = doIntegrityCheck ? chatData?.[0]?.chat_metadata?.integrity : undefined;
442
+
443
+ if (chatIntegritySlug && !await checkChatIntegrity(filePath, chatIntegritySlug)) {
444
+ throw new IntegrityMismatchError(`Chat integrity check failed for "${filePath}". The expected integrity slug was "${chatIntegritySlug}".`);
445
+ }
446
+ tryWriteFileSync(filePath, jsonlData);
447
+ getBackupFunction(handle)(backupDirectory, cardName, jsonlData);
448
+ }
449
+
450
+ router.post('/save', validateAvatarUrlMiddleware, async function (request, response) {
451
+ try {
452
+ const handle = request.user.profile.handle;
453
+ const cardName = String(request.body.avatar_url).replace('.png', '');
454
+ const chatData = request.body.chat;
455
+ const chatFileName = `${String(request.body.file_name)}.jsonl`;
456
+ const chatFilePath = path.join(request.user.directories.chats, cardName, sanitize(chatFileName));
457
+
458
+ if (Array.isArray(chatData)) {
459
+ await trySaveChat(chatData, chatFilePath, request.body.force, handle, cardName, request.user.directories.backups);
460
+ return response.send({ ok: true });
461
+ } else {
462
+ return response.status(400).send({ error: 'The request\'s body.chat is not an array.' });
463
+ }
464
+ } catch (error) {
465
+ if (error instanceof IntegrityMismatchError) {
466
+ console.error(error.message);
467
+ return response.status(400).send({ error: 'integrity' });
468
+ }
469
+ console.error(error);
470
+ return response.status(500).send({ error: 'An error has occurred, see the console logs for more information.' });
471
+ }
472
+ });
473
+
474
+ /**
475
+ * Gets the chat as an object.
476
+ * @param {string} chatFilePath The full chat file path.
477
+ * @returns {Array}} If the chatFilePath cannot be read, this will return [].
478
+ */
479
+ export function getChatData(chatFilePath) {
480
+ let chatData = [];
481
+
482
+ const chatJSON = tryReadFileSync(chatFilePath) ?? '';
483
+ if (chatJSON.length > 0) {
484
+ const lines = chatJSON.split('\n');
485
+ // Iterate through the array of strings and parse each line as JSON
486
+ chatData = lines.map(line => tryParse(line)).filter(x => x);
487
+ } else {
488
+ console.warn(`File not found: ${chatFilePath}. The chat does not exist or is empty.`);
489
+ }
490
+
491
+ return chatData;
492
+ }
493
+
494
+ router.post('/get', validateAvatarUrlMiddleware, function (request, response) {
495
+ try {
496
+ const dirName = String(request.body.avatar_url).replace('.png', '');
497
+ const directoryPath = path.join(request.user.directories.chats, dirName);
498
+ const chatDirExists = fs.existsSync(directoryPath);
499
+
500
+ //if no chat dir for the character is found, make one with the character name
501
+ if (!chatDirExists) {
502
+ fs.mkdirSync(directoryPath);
503
+ return response.send({});
504
+ }
505
+
506
+ if (!request.body.file_name) {
507
+ return response.send({});
508
+ }
509
+
510
+ const chatFileName = `${String(request.body.file_name)}.jsonl`;
511
+ const chatFilePath = path.join(directoryPath, sanitize(chatFileName));
512
+
513
+ return response.send(getChatData(chatFilePath));
514
+ } catch (error) {
515
+ console.error(error);
516
+ return response.send({});
517
+ }
518
+ });
519
+
520
+ router.post('/rename', validateAvatarUrlMiddleware, async function (request, response) {
521
+ try {
522
+ if (!request.body || !request.body.original_file || !request.body.renamed_file) {
523
+ return response.sendStatus(400);
524
+ }
525
+
526
+ const pathToFolder = request.body.is_group
527
+ ? request.user.directories.groupChats
528
+ : path.join(request.user.directories.chats, String(request.body.avatar_url).replace('.png', ''));
529
+ const pathToOriginalFile = path.join(pathToFolder, sanitize(request.body.original_file));
530
+ const pathToRenamedFile = path.join(pathToFolder, sanitize(request.body.renamed_file));
531
+ const sanitizedFileName = path.parse(pathToRenamedFile).name;
532
+ console.debug('Old chat name', pathToOriginalFile);
533
+ console.debug('New chat name', pathToRenamedFile);
534
+
535
+ if (!fs.existsSync(pathToOriginalFile) || fs.existsSync(pathToRenamedFile)) {
536
+ console.error('Either Source or Destination files are not available');
537
+ return response.status(400).send({ error: true });
538
+ }
539
+
540
+ fs.copyFileSync(pathToOriginalFile, pathToRenamedFile);
541
+ fs.unlinkSync(pathToOriginalFile);
542
+ console.info('Successfully renamed chat file.');
543
+ return response.send({ ok: true, sanitizedFileName });
544
+ } catch (error) {
545
+ console.error('Error renaming chat file:', error);
546
+ return response.status(500).send({ error: true });
547
+ }
548
+ });
549
+
550
+ router.post('/delete', validateAvatarUrlMiddleware, function (request, response) {
551
+ try {
552
+ if (!path.extname(request.body.chatfile)) {
553
+ request.body.chatfile += '.jsonl';
554
+ }
555
+
556
+ const dirName = String(request.body.avatar_url).replace('.png', '');
557
+ const chatFileName = String(request.body.chatfile);
558
+ const chatFilePath = path.join(request.user.directories.chats, dirName, sanitize(chatFileName));
559
+ //Return success if the file was deleted.
560
+ if (tryDeleteFile(chatFilePath)) {
561
+ return response.send({ ok: true });
562
+ } else {
563
+ console.error('The chat file was not deleted.');
564
+ return response.sendStatus(400);
565
+ }
566
+ } catch (error) {
567
+ console.error(error);
568
+ return response.sendStatus(500);
569
+ }
570
+ });
571
+
572
+ router.post('/export', validateAvatarUrlMiddleware, async function (request, response) {
573
+ if (!request.body.file || (!request.body.avatar_url && request.body.is_group === false)) {
574
+ return response.sendStatus(400);
575
+ }
576
+ const pathToFolder = request.body.is_group
577
+ ? request.user.directories.groupChats
578
+ : path.join(request.user.directories.chats, String(request.body.avatar_url).replace('.png', ''));
579
+ let filename = path.join(pathToFolder, request.body.file);
580
+ let exportfilename = request.body.exportfilename;
581
+ if (!fs.existsSync(filename)) {
582
+ const errorMessage = {
583
+ message: `Could not find JSONL file to export. Source chat file: ${filename}.`,
584
+ };
585
+ console.error(errorMessage.message);
586
+ return response.status(404).json(errorMessage);
587
+ }
588
+ try {
589
+ // Short path for JSONL files
590
+ if (request.body.format === 'jsonl') {
591
+ try {
592
+ const rawFile = fs.readFileSync(filename, 'utf8');
593
+ const successMessage = {
594
+ message: `Chat saved to ${exportfilename}`,
595
+ result: rawFile,
596
+ };
597
+
598
+ console.info(`Chat exported as ${exportfilename}`);
599
+ return response.status(200).json(successMessage);
600
+ } catch (err) {
601
+ console.error(err);
602
+ const errorMessage = {
603
+ message: `Could not read JSONL file to export. Source chat file: ${filename}.`,
604
+ };
605
+ console.error(errorMessage.message);
606
+ return response.status(500).json(errorMessage);
607
+ }
608
+ }
609
+
610
+ const readStream = fs.createReadStream(filename);
611
+ const rl = readline.createInterface({
612
+ input: readStream,
613
+ });
614
+ let buffer = '';
615
+ rl.on('line', (line) => {
616
+ const data = JSON.parse(line);
617
+ // Skip non-printable/prompt-hidden messages
618
+ if (data.is_system) {
619
+ return;
620
+ }
621
+ if (data.mes) {
622
+ const name = data.name;
623
+ const message = (data?.extra?.display_text || data?.mes || '').replace(/\r?\n/g, '\n');
624
+ buffer += (`${name}: ${message}\n\n`);
625
+ }
626
+ });
627
+ rl.on('close', () => {
628
+ const successMessage = {
629
+ message: `Chat saved to ${exportfilename}`,
630
+ result: buffer,
631
+ };
632
+ console.info(`Chat exported as ${exportfilename}`);
633
+ return response.status(200).json(successMessage);
634
+ });
635
+ } catch (err) {
636
+ console.error('chat export failed.', err);
637
+ return response.sendStatus(400);
638
+ }
639
+ });
640
+
641
+ router.post('/group/import', function (request, response) {
642
+ try {
643
+ const filedata = request.file;
644
+
645
+ if (!filedata) {
646
+ return response.sendStatus(400);
647
+ }
648
+
649
+ const chatname = humanizedDateTime();
650
+ const pathToUpload = path.join(filedata.destination, filedata.filename);
651
+ const pathToNewFile = path.join(request.user.directories.groupChats, `${chatname}.jsonl`);
652
+ fs.copyFileSync(pathToUpload, pathToNewFile);
653
+ fs.unlinkSync(pathToUpload);
654
+ return response.send({ res: chatname });
655
+ } catch (error) {
656
+ console.error(error);
657
+ return response.send({ error: true });
658
+ }
659
+ });
660
+
661
+ router.post('/import', validateAvatarUrlMiddleware, function (request, response) {
662
+ if (!request.body) return response.sendStatus(400);
663
+
664
+ const format = request.body.file_type;
665
+ const avatarUrl = (request.body.avatar_url).replace('.png', '');
666
+ const characterName = request.body.character_name;
667
+ const userName = request.body.user_name || 'User';
668
+ const fileNames = [];
669
+
670
+ if (!request.file) {
671
+ return response.sendStatus(400);
672
+ }
673
+
674
+ try {
675
+ const pathToUpload = path.join(request.file.destination, request.file.filename);
676
+ const data = fs.readFileSync(pathToUpload, 'utf8');
677
+
678
+ if (format === 'json') {
679
+ fs.unlinkSync(pathToUpload);
680
+ const jsonData = JSON.parse(data);
681
+
682
+ /** @type {function(string, string, object): string|string[]} */
683
+ let importFunc;
684
+
685
+ if (jsonData.savedsettings !== undefined) { // Kobold Lite format
686
+ importFunc = importKoboldLiteChat;
687
+ } else if (jsonData.histories !== undefined) { // CAI Tools format
688
+ importFunc = importCAIChat;
689
+ } else if (Array.isArray(jsonData.data_visible)) { // oobabooga's format
690
+ importFunc = importOobaChat;
691
+ } else if (Array.isArray(jsonData.messages)) { // Agnai's format
692
+ importFunc = importAgnaiChat;
693
+ } else if (jsonData.type === 'risuChat') { // RisuAI format
694
+ importFunc = importRisuChat;
695
+ } else { // Unknown format
696
+ console.error('Incorrect chat format .json');
697
+ return response.send({ error: true });
698
+ }
699
+
700
+ const handleChat = (chat) => {
701
+ const fileName = `${characterName} - ${humanizedDateTime()} imported.jsonl`;
702
+ const filePath = path.join(request.user.directories.chats, avatarUrl, fileName);
703
+ fileNames.push(fileName);
704
+ writeFileAtomicSync(filePath, chat, 'utf8');
705
+ };
706
+
707
+ const chat = importFunc(userName, characterName, jsonData);
708
+
709
+ if (Array.isArray(chat)) {
710
+ chat.forEach(handleChat);
711
+ } else {
712
+ handleChat(chat);
713
+ }
714
+
715
+ return response.send({ res: true, fileNames });
716
+ }
717
+
718
+ if (format === 'jsonl') {
719
+ let lines = data.split('\n');
720
+ const header = lines[0];
721
+
722
+ const jsonData = JSON.parse(header);
723
+
724
+ if (!(jsonData.user_name !== undefined || jsonData.name !== undefined || jsonData.chat_metadata !== undefined)) {
725
+ console.error('Incorrect chat format .jsonl');
726
+ return response.send({ error: true });
727
+ }
728
+
729
+ // Do a tiny bit of work to import Chub Chat data
730
+ // Processing the entire file is so fast that it's not worth checking if it's a Chub chat first
731
+ let flattenedChat = data;
732
+ try {
733
+ // flattening is unlikely to break, but it's not worth failing to
734
+ // import normal chats in an attempt to import a Chub chat
735
+ flattenedChat = flattenChubChat(userName, characterName, lines);
736
+ } catch (error) {
737
+ console.warn('Failed to flatten Chub Chat data: ', error);
738
+ }
739
+
740
+ const fileName = `${characterName} - ${humanizedDateTime()} imported.jsonl`;
741
+ const filePath = path.join(request.user.directories.chats, avatarUrl, fileName);
742
+ fileNames.push(fileName);
743
+ if (flattenedChat !== data) {
744
+ writeFileAtomicSync(filePath, flattenedChat, 'utf8');
745
+ } else {
746
+ fs.copyFileSync(pathToUpload, filePath);
747
+ }
748
+ fs.unlinkSync(pathToUpload);
749
+ response.send({ res: true, fileNames });
750
+ }
751
+ } catch (error) {
752
+ console.error(error);
753
+ return response.send({ error: true });
754
+ }
755
+ });
756
+
757
+ router.post('/group/get', (request, response) => {
758
+ if (!request.body || !request.body.id) {
759
+ return response.sendStatus(400);
760
+ }
761
+
762
+ const id = request.body.id;
763
+ const chatFilePath = path.join(request.user.directories.groupChats, `${id}.jsonl`);
764
+
765
+ return response.send(getChatData(chatFilePath));
766
+ });
767
+
768
+ router.post('/group/delete', (request, response) => {
769
+ try {
770
+ if (!request.body || !request.body.id) {
771
+ return response.sendStatus(400);
772
+ }
773
+
774
+ const id = request.body.id;
775
+ const chatFilePath = path.join(request.user.directories.groupChats, `${id}.jsonl`);
776
+
777
+ //Return success if the file was deleted.
778
+ if (tryDeleteFile(chatFilePath)) {
779
+ return response.send({ ok: true });
780
+ } else {
781
+ console.error('The group chat file was not deleted.\'');
782
+ return response.sendStatus(400);
783
+ }
784
+ } catch (error) {
785
+ console.error(error);
786
+ return response.sendStatus(500);
787
+ }
788
+ });
789
+
790
+ router.post('/group/save', async function (request, response) {
791
+ try {
792
+ if (!request.body || !request.body.id) {
793
+ return response.sendStatus(400);
794
+ }
795
+
796
+ const id = request.body.id;
797
+ const handle = request.user.profile.handle;
798
+ const chatFilePath = path.join(request.user.directories.groupChats, sanitize(`${id}.jsonl`));
799
+ const chatData = request.body.chat;
800
+
801
+ if (Array.isArray(chatData)) {
802
+ await trySaveChat(chatData, chatFilePath, request.body.force, handle, String(id), request.user.directories.backups);
803
+ return response.send({ ok: true });
804
+ }
805
+ else {
806
+ return response.status(400).send({ error: 'The request\'s body.chat is not an array.' });
807
+ }
808
+ } catch (error) {
809
+ if (error instanceof IntegrityMismatchError) {
810
+ console.error(error.message);
811
+ return response.status(400).send({ error: 'integrity' });
812
+ }
813
+ console.error(error);
814
+ return response.status(500).send({ error: 'An error has occurred, see the console logs for more information.' });
815
+ }
816
+ });
817
+
818
+ router.post('/search', validateAvatarUrlMiddleware, function (request, response) {
819
+ try {
820
+ const { query, avatar_url, group_id } = request.body;
821
+ let chatFiles = [];
822
+
823
+ if (group_id) {
824
+ // Find group's chat IDs first
825
+ const groupDir = path.join(request.user.directories.groups);
826
+ const groupFiles = fs.readdirSync(groupDir)
827
+ .filter(file => file.endsWith('.json'));
828
+
829
+ let targetGroup;
830
+ for (const groupFile of groupFiles) {
831
+ try {
832
+ const groupData = JSON.parse(fs.readFileSync(path.join(groupDir, groupFile), 'utf8'));
833
+ if (groupData.id === group_id) {
834
+ targetGroup = groupData;
835
+ break;
836
+ }
837
+ } catch (error) {
838
+ console.warn(groupFile, 'group file is corrupted:', error);
839
+ }
840
+ }
841
+
842
+ if (!targetGroup?.chats) {
843
+ return response.send([]);
844
+ }
845
+
846
+ // Find group chat files for given group ID
847
+ const groupChatsDir = path.join(request.user.directories.groupChats);
848
+ chatFiles = targetGroup.chats
849
+ .map(chatId => {
850
+ const filePath = path.join(groupChatsDir, `${chatId}.jsonl`);
851
+ if (!fs.existsSync(filePath)) return null;
852
+ const stats = fs.statSync(filePath);
853
+ return {
854
+ file_name: chatId,
855
+ file_size: formatBytes(stats.size),
856
+ path: filePath,
857
+ };
858
+ })
859
+ .filter(x => x);
860
+ } else {
861
+ // Regular character chat directory
862
+ const character_name = avatar_url.replace('.png', '');
863
+ const directoryPath = path.join(request.user.directories.chats, character_name);
864
+
865
+ if (!fs.existsSync(directoryPath)) {
866
+ return response.send([]);
867
+ }
868
+
869
+ chatFiles = fs.readdirSync(directoryPath)
870
+ .filter(file => file.endsWith('.jsonl'))
871
+ .map(fileName => {
872
+ const filePath = path.join(directoryPath, fileName);
873
+ const stats = fs.statSync(filePath);
874
+ return {
875
+ file_name: fileName,
876
+ file_size: formatBytes(stats.size),
877
+ path: filePath,
878
+ };
879
+ });
880
+ }
881
+
882
+ const results = [];
883
+
884
+ // Search logic
885
+ for (const chatFile of chatFiles) {
886
+ const data = getChatData(chatFile.path);
887
+ const messages = data.filter(x => x && typeof x.mes === 'string');
888
+
889
+ if (query && messages.length === 0) {
890
+ continue;
891
+ }
892
+
893
+ const lastMessage = messages[messages.length - 1];
894
+ const lastMesDate = lastMessage?.send_date || new Date(fs.statSync(chatFile.path).mtimeMs).toISOString();
895
+
896
+ // If no search query, just return metadata
897
+ if (!query) {
898
+ results.push({
899
+ file_name: chatFile.file_name,
900
+ file_size: chatFile.file_size,
901
+ message_count: messages.length,
902
+ last_mes: lastMesDate,
903
+ preview_message: getPreviewMessage(messages),
904
+ });
905
+ continue;
906
+ }
907
+
908
+ // Search through title and messages of the chat
909
+ const fragments = query.trim().toLowerCase().split(/\s+/).filter(x => x);
910
+ const text = [path.parse(chatFile.path).name, ...messages.map(message => message?.mes)].join('\n').toLowerCase();
911
+ const hasMatch = fragments.every(fragment => text.includes(fragment));
912
+
913
+ if (hasMatch) {
914
+ results.push({
915
+ file_name: chatFile.file_name,
916
+ file_size: chatFile.file_size,
917
+ message_count: messages.length,
918
+ last_mes: lastMesDate,
919
+ preview_message: getPreviewMessage(messages),
920
+ });
921
+ }
922
+ }
923
+
924
+ // Sort by last message date descending
925
+ results.sort((a, b) => new Date(b.last_mes).getTime() - new Date(a.last_mes).getTime());
926
+ return response.send(results);
927
+
928
+ } catch (error) {
929
+ console.error('Chat search error:', error);
930
+ return response.status(500).json({ error: 'Search failed' });
931
+ }
932
+ });
933
+
934
+ router.post('/recent', async function (request, response) {
935
+ try {
936
+ /** @type {{pngFile?: string, groupId?: string, filePath: string, mtime: number}[]} */
937
+ const allChatFiles = [];
938
+
939
+ const getCharacterChatFiles = async () => {
940
+ const pngDirents = await fs.promises.readdir(request.user.directories.characters, { withFileTypes: true });
941
+ const pngFiles = pngDirents.filter(e => e.isFile() && path.extname(e.name) === '.png').map(e => e.name);
942
+
943
+ for (const pngFile of pngFiles) {
944
+ const chatsDirectory = pngFile.replace('.png', '');
945
+ const pathToChats = path.join(request.user.directories.chats, chatsDirectory);
946
+ if (!fs.existsSync(pathToChats)) {
947
+ continue;
948
+ }
949
+ const pathStats = await fs.promises.stat(pathToChats);
950
+ if (pathStats.isDirectory()) {
951
+ const chatFiles = await fs.promises.readdir(pathToChats);
952
+ const jsonlFiles = chatFiles.filter(file => path.extname(file) === '.jsonl');
953
+
954
+ for (const file of jsonlFiles) {
955
+ const filePath = path.join(pathToChats, file);
956
+ const stats = await fs.promises.stat(filePath);
957
+ allChatFiles.push({ pngFile, filePath, mtime: stats.mtimeMs });
958
+ }
959
+ }
960
+ }
961
+ };
962
+
963
+ const getGroupChatFiles = async () => {
964
+ const groupDirents = await fs.promises.readdir(request.user.directories.groups, { withFileTypes: true });
965
+ const groups = groupDirents.filter(e => e.isFile() && path.extname(e.name) === '.json').map(e => e.name);
966
+
967
+ for (const group of groups) {
968
+ try {
969
+ const groupPath = path.join(request.user.directories.groups, group);
970
+ const groupContents = await fs.promises.readFile(groupPath, 'utf8');
971
+ const groupData = JSON.parse(groupContents);
972
+
973
+ if (Array.isArray(groupData.chats)) {
974
+ for (const chat of groupData.chats) {
975
+ const filePath = path.join(request.user.directories.groupChats, `${chat}.jsonl`);
976
+ if (!fs.existsSync(filePath)) {
977
+ continue;
978
+ }
979
+ const stats = await fs.promises.stat(filePath);
980
+ allChatFiles.push({ groupId: groupData.id, filePath, mtime: stats.mtimeMs });
981
+ }
982
+ }
983
+ } catch (error) {
984
+ // Skip group files that can't be read or parsed
985
+ continue;
986
+ }
987
+ }
988
+ };
989
+
990
+ const getRootChatFiles = async () => {
991
+ const dirents = await fs.promises.readdir(request.user.directories.chats, { withFileTypes: true });
992
+ const chatFiles = dirents.filter(e => e.isFile() && path.extname(e.name) === '.jsonl').map(e => e.name);
993
+
994
+ for (const file of chatFiles) {
995
+ const filePath = path.join(request.user.directories.chats, file);
996
+ const stats = await fs.promises.stat(filePath);
997
+ allChatFiles.push({ filePath, mtime: stats.mtimeMs });
998
+ }
999
+ };
1000
+
1001
+ await Promise.allSettled([getCharacterChatFiles(), getGroupChatFiles(), getRootChatFiles()]);
1002
+
1003
+ const max = parseInt(request.body.max ?? Number.MAX_SAFE_INTEGER);
1004
+ const recentChats = allChatFiles.sort((a, b) => b.mtime - a.mtime).slice(0, max);
1005
+ const jsonFilesPromise = recentChats.map((file) => {
1006
+ const withMetadata = !!request.body.metadata;
1007
+ return file.groupId
1008
+ ? getChatInfo(file.filePath, { group: file.groupId }, withMetadata)
1009
+ : getChatInfo(file.filePath, { avatar: file.pngFile }, withMetadata);
1010
+ });
1011
+
1012
+ const chatData = (await Promise.allSettled(jsonFilesPromise)).filter(x => x.status === 'fulfilled').map(x => x.value);
1013
+ const validFiles = chatData.filter(i => i.file_name);
1014
+
1015
+ return response.send(validFiles);
1016
+ } catch (error) {
1017
+ console.error(error);
1018
+ return response.sendStatus(500);
1019
+ }
1020
+ });
src/endpoints/classify.js ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import express from 'express';
2
+
3
+ import { getPipeline } from '../transformers.js';
4
+
5
+ const TASK = 'text-classification';
6
+
7
+ export const router = express.Router();
8
+
9
+ /**
10
+ * @type {Map<string, object>} Cache for classification results
11
+ */
12
+ const cacheObject = new Map();
13
+
14
+ router.post('/labels', async (req, res) => {
15
+ try {
16
+ const pipe = await 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('/', 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 pipe = await getPipeline(TASK);
39
+ const result = await pipe(text, { topk: 5 });
40
+ result.sort((a, b) => b.score - a.score);
41
+ cacheObject.set(text, result);
42
+ return result;
43
+ }
44
+ }
45
+
46
+ console.debug('Classify input:', text);
47
+ const result = await getResult(text);
48
+ console.debug('Classify output:', result);
49
+
50
+ return res.json({ classification: result });
51
+ } catch (error) {
52
+ console.error(error);
53
+ return res.sendStatus(500);
54
+ }
55
+ });
src/endpoints/data-maid.js ADDED
@@ -0,0 +1,816 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import express from 'express';
5
+ import mime from 'mime-types';
6
+ import { getSettingsBackupFilePrefix } from './settings.js';
7
+ import { CHAT_BACKUPS_PREFIX } from './chats.js';
8
+ import { isPathUnderParent, tryParse } from '../util.js';
9
+ import { SETTINGS_FILE } from '../constants.js';
10
+
11
+ const sha256 = str => crypto.createHash('sha256').update(str).digest('hex');
12
+
13
+ /**
14
+ * @typedef {object} DataMaidRawReport
15
+ * @property {string[]} images - List of loose user images
16
+ * @property {string[]} files - List of loose user files
17
+ * @property {string[]} chats - List of loose character chats
18
+ * @property {string[]} groupChats - List of loose group chats
19
+ * @property {string[]} avatarThumbnails - List of loose avatar thumbnails
20
+ * @property {string[]} backgroundThumbnails - List of loose background thumbnails
21
+ * @property {string[]} personaThumbnails - List of loose persona thumbnails
22
+ * @property {string[]} chatBackups - List of chat backups
23
+ * @property {string[]} settingsBackups - List of settings backups
24
+ */
25
+
26
+ /**
27
+ * @typedef {object} DataMaidSanitizedRecord - The entry excluding the sensitive paths.
28
+ * @property {string} name - The name of the file.
29
+ * @property {string} hash - The SHA-256 hash of the file path.
30
+ * @property {string} [parent] - The name of the parent directory, if applicable.
31
+ * @property {number} [size] - The size of the file in bytes, if available.
32
+ * @property {number} [mtime] - The last modification time of the file, if available.
33
+ */
34
+
35
+ /**
36
+ * @typedef {object} DataMaidSanitizedReport - The report containing loose user data.
37
+ * @property {DataMaidSanitizedRecord[]} images - List of sanitized loose user images
38
+ * @property {DataMaidSanitizedRecord[]} files - List of sanitized loose user files
39
+ * @property {DataMaidSanitizedRecord[]} chats - List of sanitized loose character chats
40
+ * @property {DataMaidSanitizedRecord[]} groupChats - List of sanitized loose group chats
41
+ * @property {DataMaidSanitizedRecord[]} avatarThumbnails - List of sanitized loose avatar thumbnails
42
+ * @property {DataMaidSanitizedRecord[]} backgroundThumbnails - List of sanitized loose background thumbnails
43
+ * @property {DataMaidSanitizedRecord[]} personaThumbnails - List of sanitized loose persona thumbnails
44
+ * @property {DataMaidSanitizedRecord[]} chatBackups - List of sanitized chat backups
45
+ * @property {DataMaidSanitizedRecord[]} settingsBackups - List of sanitized settings backups
46
+ */
47
+
48
+ /**
49
+ * @typedef {object} DataMaidMessage - The chat message object.
50
+ * @property {DataMaidMessageExtra} [extra] - The extra data object.
51
+ * @property {DataMaidChatMetadata} [chat_metadata] - The chat metadata object.
52
+ */
53
+
54
+ /**
55
+ * @typedef {object} DataMaidFile - The file object.
56
+ * @property {string} url - The file URL
57
+ */
58
+
59
+ /**
60
+ * @typedef {object} DataMaidMedia - The media object.
61
+ * @property {string} url - The media URL
62
+ */
63
+
64
+ /**
65
+ * @typedef {object} DataMaidChatMetadata - The chat metadata object.
66
+ * @property {DataMaidFile[]} [attachments] - The array of attachments, if any.
67
+ * @property {string[]} [chat_backgrounds] - The array of chat background image links, if any.
68
+ */
69
+
70
+ /**
71
+ * @typedef {object} DataMaidMessageExtra - The extra data object.
72
+ * @property {string} [image] - The link to the image, if any - DEPRECATED, use `media` instead.
73
+ * @property {string} [video] - The link to the video, if any - DEPRECATED, use `media` instead.
74
+ * @property {string[]} [image_swipes] - The links to the image swipes, if any - DEPRECATED, use `media` instead.
75
+ * @property {DataMaidMedia[]} [media] - The links to the media, if any.
76
+ * @property {DataMaidFile} [file] - The file object, if any - DEPRECATED, use `files` instead.
77
+ * @property {DataMaidFile[]} [files] - The array of file objects, if any.
78
+ */
79
+
80
+ /**
81
+ * @typedef {object} DataMaidTokenEntry
82
+ * @property {string} handle - The user's handle or identifier.
83
+ * @property {{path: string, hash: string}[]} paths - The list of file paths and their hashes that can be cleaned up.
84
+ */
85
+
86
+ /**
87
+ * Service for detecting and managing loose user data files.
88
+ * Helps identify orphaned files that are no longer referenced by the application.
89
+ */
90
+ export class DataMaidService {
91
+ /**
92
+ * @type {Map<string, DataMaidTokenEntry>} Map clean-up tokens to user IDs
93
+ */
94
+ static TOKENS = new Map();
95
+
96
+ /**
97
+ * Creates a new DataMaidService instance for a specific user.
98
+ * @param {string} handle - The user's handle.
99
+ * @param {import('../users.js').UserDirectoryList} directories - List of user directories to scan for loose data.
100
+ */
101
+ constructor(handle, directories) {
102
+ this.handle = handle;
103
+ this.directories = directories;
104
+ }
105
+
106
+ /**
107
+ * Generates a report of loose user data.
108
+ * @returns {Promise<DataMaidRawReport>} A report containing lists of loose user data.
109
+ */
110
+ async generateReport() {
111
+ /** @type {DataMaidRawReport} */
112
+ const report = {
113
+ images: await this.#collectImages(),
114
+ files: await this.#collectFiles(),
115
+ chats: await this.#collectChats(),
116
+ groupChats: await this.#collectGroupChats(),
117
+ avatarThumbnails: await this.#collectAvatarThumbnails(),
118
+ backgroundThumbnails: await this.#collectBackgroundThumbnails(),
119
+ personaThumbnails: await this.#collectPersonaThumbnails(),
120
+ chatBackups: await this.#collectChatBackups(),
121
+ settingsBackups: await this.#collectSettingsBackups(),
122
+ };
123
+
124
+ return report;
125
+ }
126
+
127
+
128
+ /**
129
+ * Sanitizes a record by hashing the file name and removing sensitive information.
130
+ * Additionally, adds metadata like size and modification time.
131
+ * @param {string} name The file or directory name to sanitize.
132
+ * @param {boolean} withParent If the model should include the parent directory name.
133
+ * @returns {Promise<DataMaidSanitizedRecord>} A sanitized record with the file name, hash, parent directory name, size, and modification time.
134
+ */
135
+ async #sanitizeRecord(name, withParent) {
136
+ const stat = fs.existsSync(name) ? await fs.promises.stat(name) : null;
137
+ return {
138
+ name: path.basename(name),
139
+ hash: sha256(name),
140
+ parent: withParent ? path.basename(path.dirname(name)) : void 0,
141
+ size: stat?.size,
142
+ mtime: stat?.mtimeMs,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Sanitizes the report by hashing the file paths and removing sensitive information.
148
+ * @param {DataMaidRawReport} report - The raw report containing loose user data.
149
+ * @returns {Promise<DataMaidSanitizedReport>} A sanitized report with sensitive paths removed.
150
+ */
151
+ async sanitizeReport(report) {
152
+ const sanitizedReport = {
153
+ images: await Promise.all(report.images.map(i => this.#sanitizeRecord(i, true))),
154
+ files: await Promise.all(report.files.map(i => this.#sanitizeRecord(i, false))),
155
+ chats: await Promise.all(report.chats.map(i => this.#sanitizeRecord(i, true))),
156
+ groupChats: await Promise.all(report.groupChats.map(i => this.#sanitizeRecord(i, false))),
157
+ avatarThumbnails: await Promise.all(report.avatarThumbnails.map(i => this.#sanitizeRecord(i, false))),
158
+ backgroundThumbnails: await Promise.all(report.backgroundThumbnails.map(i => this.#sanitizeRecord(i, false))),
159
+ personaThumbnails: await Promise.all(report.personaThumbnails.map(i => this.#sanitizeRecord(i, false))),
160
+ chatBackups: await Promise.all(report.chatBackups.map(i => this.#sanitizeRecord(i, false))),
161
+ settingsBackups: await Promise.all(report.settingsBackups.map(i => this.#sanitizeRecord(i, false))),
162
+ };
163
+
164
+ return sanitizedReport;
165
+ }
166
+
167
+ /**
168
+ * Collects loose user images from the provided directories.
169
+ * Images are considered loose if they exist in the user images directory
170
+ * but are not referenced in any chat messages.
171
+ * @returns {Promise<string[]>} List of paths to loose user images
172
+ */
173
+ async #collectImages() {
174
+ const result = [];
175
+
176
+ try {
177
+ const messages = await this.#parseAllChats(x => !!x?.extra?.image || !!x?.extra?.video || Array.isArray(x?.extra?.image_swipes) || Array.isArray(x?.extra?.media));
178
+ const knownImages = new Set();
179
+ for (const message of messages) {
180
+ if (message?.extra?.image) {
181
+ knownImages.add(message.extra.image);
182
+ }
183
+ if (message?.extra?.video) {
184
+ knownImages.add(message.extra.video);
185
+ }
186
+ if (Array.isArray(message?.extra?.image_swipes)) {
187
+ for (const swipe of message.extra.image_swipes) {
188
+ knownImages.add(swipe);
189
+ }
190
+ }
191
+ if (Array.isArray(message?.extra?.media)) {
192
+ for (const media of message.extra.media) {
193
+ if (media?.url) {
194
+ knownImages.add(media.url);
195
+ }
196
+ }
197
+ }
198
+ }
199
+ const metadata = await this.#parseAllMetadata(x => Array.isArray(x?.chat_backgrounds) && x.chat_backgrounds.length > 0);
200
+ for (const meta of metadata) {
201
+ if (Array.isArray(meta?.chat_backgrounds)) {
202
+ for (const background of meta.chat_backgrounds) {
203
+ if (background) {
204
+ knownImages.add(background);
205
+ }
206
+ }
207
+ }
208
+ }
209
+ const knownImageFullPaths = new Set();
210
+ knownImages.forEach(image => {
211
+ if (image.startsWith('http') || image.startsWith('data:')) {
212
+ return; // Skip URLs and data URIs
213
+ }
214
+ knownImageFullPaths.add(path.normalize(path.join(this.directories.root, image)));
215
+ });
216
+ const images = await fs.promises.readdir(this.directories.userImages, { withFileTypes: true });
217
+ for (const dirent of images) {
218
+ const direntPath = path.join(dirent.parentPath, dirent.name);
219
+ if (dirent.isFile() && !knownImageFullPaths.has(direntPath)) {
220
+ result.push(direntPath);
221
+ }
222
+ if (dirent.isDirectory()) {
223
+ const subdirFiles = await fs.promises.readdir(direntPath, { withFileTypes: true });
224
+ for (const file of subdirFiles) {
225
+ const subdirFilePath = path.join(direntPath, file.name);
226
+ if (file.isFile() && !knownImageFullPaths.has(subdirFilePath)) {
227
+ result.push(subdirFilePath);
228
+ }
229
+ }
230
+ }
231
+ }
232
+ } catch (error) {
233
+ console.error('[Data Maid] Error collecting user images:', error);
234
+ }
235
+
236
+ return result;
237
+ }
238
+
239
+ /**
240
+ * Collects loose user files from the provided directories.
241
+ * Files are considered loose if they exist in the files directory
242
+ * but are not referenced in chat messages, metadata, or settings.
243
+ * @returns {Promise<string[]>} List of paths to loose user files
244
+ */
245
+ async #collectFiles() {
246
+ const result = [];
247
+
248
+ try {
249
+ const messages = await this.#parseAllChats(x => !!x?.extra?.file?.url || (Array.isArray(x?.extra?.files) && x.extra.files.length > 0));
250
+ const knownFiles = new Set();
251
+ for (const message of messages) {
252
+ if (message?.extra?.file?.url) {
253
+ knownFiles.add(message.extra.file.url);
254
+ }
255
+ if (Array.isArray(message?.extra?.files)) {
256
+ for (const file of message.extra.files) {
257
+ if (file?.url) {
258
+ knownFiles.add(file.url);
259
+ }
260
+ }
261
+ }
262
+ }
263
+ const metadata = await this.#parseAllMetadata(x => Array.isArray(x?.attachments) && x.attachments.length > 0);
264
+ for (const meta of metadata) {
265
+ if (Array.isArray(meta?.attachments)) {
266
+ for (const attachment of meta.attachments) {
267
+ if (attachment?.url) {
268
+ knownFiles.add(attachment.url);
269
+ }
270
+ }
271
+ }
272
+ }
273
+ const pathToSettings = path.join(this.directories.root, SETTINGS_FILE);
274
+ if (fs.existsSync(pathToSettings)) {
275
+ try {
276
+ const settingsContent = await fs.promises.readFile(pathToSettings, 'utf-8');
277
+ const settings = tryParse(settingsContent);
278
+ if (Array.isArray(settings?.extension_settings?.attachments)) {
279
+ for (const file of settings.extension_settings.attachments) {
280
+ if (file?.url) {
281
+ knownFiles.add(file.url);
282
+ }
283
+ }
284
+ }
285
+ if (typeof settings?.extension_settings?.character_attachments === 'object') {
286
+ for (const files of Object.values(settings.extension_settings.character_attachments)) {
287
+ if (!Array.isArray(files)) {
288
+ continue;
289
+ }
290
+ for (const file of files) {
291
+ if (file?.url) {
292
+ knownFiles.add(file.url);
293
+ }
294
+ }
295
+ }
296
+ }
297
+ } catch (error) {
298
+ console.error('[Data Maid] Error reading settings file:', error);
299
+ }
300
+ }
301
+ const knownFileFullPaths = new Set();
302
+ knownFiles.forEach(file => {
303
+ knownFileFullPaths.add(path.normalize(path.join(this.directories.root, file)));
304
+ });
305
+ const files = await fs.promises.readdir(this.directories.files, { withFileTypes: true });
306
+ for (const file of files) {
307
+ const filePath = path.join(this.directories.files, file.name);
308
+ if (file.isFile() && !knownFileFullPaths.has(filePath)) {
309
+ result.push(filePath);
310
+ }
311
+ }
312
+ } catch (error) {
313
+ console.error('[Data Maid] Error collecting user files:', error);
314
+ }
315
+
316
+ return result;
317
+ }
318
+
319
+ /**
320
+ * Collects loose character chats from the provided directories.
321
+ * Chat folders are considered loose if they don't have corresponding character files.
322
+ * @returns {Promise<string[]>} List of paths to loose character chats
323
+ */
324
+ async #collectChats() {
325
+ const result = [];
326
+
327
+ try {
328
+ const knownChatFolders = new Set();
329
+ const characters = await fs.promises.readdir(this.directories.characters, { withFileTypes: true });
330
+ for (const file of characters) {
331
+ if (file.isFile() && path.parse(file.name).ext === '.png') {
332
+ knownChatFolders.add(file.name.replace('.png', ''));
333
+ }
334
+ }
335
+ const chatFolders = await fs.promises.readdir(this.directories.chats, { withFileTypes: true });
336
+ for (const folder of chatFolders) {
337
+ if (folder.isDirectory() && !knownChatFolders.has(folder.name)) {
338
+ const chatFiles = await fs.promises.readdir(path.join(this.directories.chats, folder.name), { withFileTypes: true });
339
+ for (const file of chatFiles) {
340
+ if (file.isFile() && path.parse(file.name).ext === '.jsonl') {
341
+ result.push(path.join(this.directories.chats, folder.name, file.name));
342
+ }
343
+ }
344
+ }
345
+ }
346
+ } catch (error) {
347
+ console.error('[Data Maid] Error collecting character chats:', error);
348
+ }
349
+
350
+ return result;
351
+ }
352
+
353
+ /**
354
+ * Collects loose group chats from the provided directories.
355
+ * Group chat files are considered loose if they're not referenced by any group definition.
356
+ * @returns {Promise<string[]>} List of paths to loose group chats
357
+ */
358
+ async #collectGroupChats() {
359
+ const result = [];
360
+
361
+ try {
362
+ const groups = await fs.promises.readdir(this.directories.groups, { withFileTypes: true });
363
+ const knownGroupChats = new Set();
364
+ for (const file of groups) {
365
+ if (file.isFile() && path.parse(file.name).ext === '.json') {
366
+ try {
367
+ const pathToFile = path.join(this.directories.groups, file.name);
368
+ const fileContent = await fs.promises.readFile(pathToFile, 'utf-8');
369
+ const groupData = tryParse(fileContent);
370
+ if (groupData?.chat_id) {
371
+ knownGroupChats.add(groupData.chat_id);
372
+ }
373
+ if (Array.isArray(groupData?.chats)) {
374
+ for (const chat of groupData.chats) {
375
+ knownGroupChats.add(chat);
376
+ }
377
+ }
378
+ } catch (error) {
379
+ console.error(`[Data Maid] Error parsing group chat file ${file.name}:`, error);
380
+ }
381
+ }
382
+ }
383
+ const groupChats = await fs.promises.readdir(this.directories.groupChats, { withFileTypes: true });
384
+ for (const file of groupChats) {
385
+ if (file.isFile() && path.parse(file.name).ext === '.jsonl') {
386
+ if (!knownGroupChats.has(path.parse(file.name).name)) {
387
+ result.push(path.join(this.directories.groupChats, file.name));
388
+ }
389
+ }
390
+ }
391
+ } catch (error) {
392
+ console.error('[Data Maid] Error collecting group chats:', error);
393
+ }
394
+
395
+ return result;
396
+ }
397
+
398
+ /**
399
+ * Collects loose avatar thumbnails from the provided directories.
400
+ * @returns {Promise<string[]>} List of paths to loose avatar thumbnails
401
+ */
402
+ async #collectAvatarThumbnails() {
403
+ const result = [];
404
+
405
+ try {
406
+ const knownAvatars = new Set();
407
+ const avatars = await fs.promises.readdir(this.directories.characters, { withFileTypes: true });
408
+ for (const file of avatars) {
409
+ if (file.isFile()) {
410
+ knownAvatars.add(file.name);
411
+ }
412
+ }
413
+ const avatarThumbnails = await fs.promises.readdir(this.directories.thumbnailsAvatar, { withFileTypes: true });
414
+ for (const file of avatarThumbnails) {
415
+ if (file.isFile() && !knownAvatars.has(file.name)) {
416
+ result.push(path.join(this.directories.thumbnailsAvatar, file.name));
417
+ }
418
+ }
419
+ } catch (error) {
420
+ console.error('[Data Maid] Error collecting avatar thumbnails:', error);
421
+ }
422
+
423
+ return result;
424
+ }
425
+
426
+ /**
427
+ * Collects loose background thumbnails from the provided directories.
428
+ * @returns {Promise<string[]>} List of paths to loose background thumbnails
429
+ */
430
+ async #collectBackgroundThumbnails() {
431
+ const result = [];
432
+
433
+ try {
434
+ const knownBackgrounds = new Set();
435
+ const backgrounds = await fs.promises.readdir(this.directories.backgrounds, { withFileTypes: true });
436
+ for (const file of backgrounds) {
437
+ if (file.isFile()) {
438
+ knownBackgrounds.add(file.name);
439
+ }
440
+ }
441
+ const backgroundThumbnails = await fs.promises.readdir(this.directories.thumbnailsBg, { withFileTypes: true });
442
+ for (const file of backgroundThumbnails) {
443
+ if (file.isFile() && !knownBackgrounds.has(file.name)) {
444
+ result.push(path.join(this.directories.thumbnailsBg, file.name));
445
+ }
446
+ }
447
+ } catch (error) {
448
+ console.error('[Data Maid] Error collecting background thumbnails:', error);
449
+ }
450
+
451
+ return result;
452
+ }
453
+
454
+ /**
455
+ * Collects loose persona thumbnails from the provided directories.
456
+ * @returns {Promise<string[]>} List of paths to loose persona thumbnails
457
+ */
458
+ async #collectPersonaThumbnails() {
459
+ const result = [];
460
+
461
+ try {
462
+ const knownPersonas = new Set();
463
+ const personas = await fs.promises.readdir(this.directories.avatars, { withFileTypes: true });
464
+ for (const file of personas) {
465
+ if (file.isFile()) {
466
+ knownPersonas.add(file.name);
467
+ }
468
+ }
469
+ const personaThumbnails = await fs.promises.readdir(this.directories.thumbnailsPersona, { withFileTypes: true });
470
+ for (const file of personaThumbnails) {
471
+ if (file.isFile() && !knownPersonas.has(file.name)) {
472
+ result.push(path.join(this.directories.thumbnailsPersona, file.name));
473
+ }
474
+ }
475
+ } catch (error) {
476
+ console.error('[Data Maid] Error collecting persona thumbnails:', error);
477
+ }
478
+
479
+ return result;
480
+ }
481
+
482
+ /**
483
+ * Collects chat backups from the provided directories.
484
+ * @returns {Promise<string[]>} List of paths to chat backups
485
+ */
486
+ async #collectChatBackups() {
487
+ const result = [];
488
+
489
+ try {
490
+ const prefix = CHAT_BACKUPS_PREFIX;
491
+ const backups = await fs.promises.readdir(this.directories.backups, { withFileTypes: true });
492
+ for (const file of backups) {
493
+ if (file.isFile() && file.name.startsWith(prefix)) {
494
+ result.push(path.join(this.directories.backups, file.name));
495
+ }
496
+ }
497
+ } catch (error) {
498
+ console.error('[Data Maid] Error collecting chat backups:', error);
499
+ }
500
+
501
+ return result;
502
+ }
503
+
504
+ /**
505
+ * Collects settings backups from the provided directories.
506
+ * @returns {Promise<string[]>} List of paths to settings backups
507
+ */
508
+ async #collectSettingsBackups() {
509
+ const result = [];
510
+
511
+ try {
512
+ const prefix = getSettingsBackupFilePrefix(this.handle);
513
+ const backups = await fs.promises.readdir(this.directories.backups, { withFileTypes: true });
514
+ for (const file of backups) {
515
+ if (file.isFile() && file.name.startsWith(prefix)) {
516
+ result.push(path.join(this.directories.backups, file.name));
517
+ }
518
+ }
519
+ } catch (error) {
520
+ console.error('[Data Maid] Error collecting settings backups:', error);
521
+ }
522
+
523
+ return result;
524
+ }
525
+
526
+ /**
527
+ * Parses all chat files and returns an array of chat messages.
528
+ * Searches both individual character chats and group chats.
529
+ * @param {function(DataMaidMessage): boolean} filterFn - Filter function to apply to each message.
530
+ * @returns {Promise<DataMaidMessage[]>} Array of chat messages
531
+ */
532
+ async #parseAllChats(filterFn) {
533
+ try {
534
+ const allChats = [];
535
+
536
+ const groupChats = await fs.promises.readdir(this.directories.groupChats, { withFileTypes: true });
537
+ for (const file of groupChats) {
538
+ if (file.isFile() && path.parse(file.name).ext === '.jsonl') {
539
+ const chatMessages = await this.#parseChatFile(path.join(this.directories.groupChats, file.name));
540
+ allChats.push(...chatMessages.filter(filterFn));
541
+ }
542
+ }
543
+
544
+ const chatDirectories = await fs.promises.readdir(this.directories.chats, { withFileTypes: true });
545
+ for (const directory of chatDirectories) {
546
+ if (directory.isDirectory()) {
547
+ const chatFiles = await fs.promises.readdir(path.join(this.directories.chats, directory.name), { withFileTypes: true });
548
+ for (const file of chatFiles) {
549
+ if (file.isFile() && path.parse(file.name).ext === '.jsonl') {
550
+ const chatMessages = await this.#parseChatFile(path.join(this.directories.chats, directory.name, file.name));
551
+ allChats.push(...chatMessages.filter(filterFn));
552
+ }
553
+ }
554
+ }
555
+ }
556
+
557
+ return allChats;
558
+ } catch (error) {
559
+ console.error('[Data Maid] Error parsing chats:', error);
560
+ return [];
561
+ }
562
+ }
563
+
564
+ /**
565
+ * Parses all metadata from chat files and group definitions.
566
+ * Extracts metadata from both active and historical chat data.
567
+ * @param {function(DataMaidChatMetadata): boolean} filterFn - Filter function to apply to each metadata entry.
568
+ * @returns {Promise<DataMaidChatMetadata[]>} Parsed chat metadata as an array.
569
+ */
570
+ async #parseAllMetadata(filterFn) {
571
+ try {
572
+ const allMetadata = [];
573
+
574
+ const groups = await fs.promises.readdir(this.directories.groups, { withFileTypes: true });
575
+ for (const file of groups) {
576
+ if (file.isFile() && path.parse(file.name).ext === '.json') {
577
+ try {
578
+ const pathToFile = path.join(this.directories.groups, file.name);
579
+ const fileContent = await fs.promises.readFile(pathToFile, 'utf-8');
580
+ const groupData = tryParse(fileContent);
581
+ if (groupData?.chat_metadata && filterFn(groupData.chat_metadata)) {
582
+ console.warn('Found group chat metadata in group definition - this is deprecated behavior.');
583
+ allMetadata.push(groupData.chat_metadata);
584
+ }
585
+ if (groupData?.past_metadata) {
586
+ console.warn('Found group past chat metadata in group definition - this is deprecated behavior.');
587
+ allMetadata.push(...Object.values(groupData.past_metadata).filter(filterFn));
588
+ }
589
+ } catch (error) {
590
+ console.error(`[Data Maid] Error parsing group chat file ${file.name}:`, error);
591
+ }
592
+ }
593
+ }
594
+
595
+ const groupChats = await fs.promises.readdir(this.directories.groupChats, { withFileTypes: true });
596
+ for (const file of groupChats) {
597
+ if (file.isFile() && path.parse(file.name).ext === '.jsonl') {
598
+ const chatMessages = await this.#parseChatFile(path.join(this.directories.groupChats, file.name));
599
+ const chatMetadata = chatMessages?.[0]?.chat_metadata;
600
+ if (chatMetadata && filterFn(chatMetadata)) {
601
+ allMetadata.push(chatMetadata);
602
+ }
603
+ }
604
+ }
605
+
606
+ const chatDirectories = await fs.promises.readdir(this.directories.chats, { withFileTypes: true });
607
+ for (const directory of chatDirectories) {
608
+ if (directory.isDirectory()) {
609
+ const chatFiles = await fs.promises.readdir(path.join(this.directories.chats, directory.name), { withFileTypes: true });
610
+ for (const file of chatFiles) {
611
+ if (file.isFile() && path.parse(file.name).ext === '.jsonl') {
612
+ const chatMessages = await this.#parseChatFile(path.join(this.directories.chats, directory.name, file.name));
613
+ const chatMetadata = chatMessages?.[0]?.chat_metadata;
614
+ if (chatMetadata && filterFn(chatMetadata)) {
615
+ allMetadata.push(chatMetadata);
616
+ }
617
+ }
618
+ }
619
+ }
620
+ }
621
+
622
+ return allMetadata;
623
+ } catch (error) {
624
+ console.error('[Data Maid] Error parsing chats:', error);
625
+ return [];
626
+ }
627
+ }
628
+
629
+ /**
630
+ * Parses a single chat file and returns an array of chat messages.
631
+ * Each line in the JSONL file represents one message.
632
+ * @param {string} filePath Path to the chat file to parse.
633
+ * @returns {Promise<DataMaidMessage[]>} Parsed chat messages as an array.
634
+ */
635
+ async #parseChatFile(filePath) {
636
+ try {
637
+ const content = await fs.promises.readFile(filePath, 'utf-8');
638
+ const chatData = content.split('\n').map(tryParse).filter(Boolean);
639
+ return chatData;
640
+ } catch (error) {
641
+ console.error(`[Data Maid] Error reading chat file ${filePath}:`, error);
642
+ return [];
643
+ }
644
+ }
645
+
646
+ /**
647
+ * Generates a unique token for the user to clean up their data.
648
+ * Replaces any existing token for the same user.
649
+ * @param {string} handle - The user's handle or identifier.
650
+ * @param {DataMaidRawReport} report - The report containing loose user data.
651
+ * @returns {string} A unique token.
652
+ */
653
+ static generateToken(handle, report) {
654
+ // Remove any existing token for this user
655
+ for (const [token, entry] of this.TOKENS.entries()) {
656
+ if (entry.handle === handle) {
657
+ this.TOKENS.delete(token);
658
+ }
659
+ }
660
+
661
+ const token = crypto.randomBytes(32).toString('hex');
662
+ const tokenEntry = {
663
+ handle,
664
+ paths: Object.values(report).filter(v => Array.isArray(v)).flat().map(x => ({ path: x, hash: sha256(x) })),
665
+ };
666
+ this.TOKENS.set(token, tokenEntry);
667
+ return token;
668
+ }
669
+ }
670
+
671
+ export const router = express.Router();
672
+
673
+ router.post('/report', async (req, res) => {
674
+ try {
675
+ if (!req.user || !req.user.directories) {
676
+ return res.sendStatus(403);
677
+ }
678
+
679
+ const dataMaid = new DataMaidService(req.user.profile.handle, req.user.directories);
680
+ const rawReport = await dataMaid.generateReport();
681
+
682
+ const report = await dataMaid.sanitizeReport(rawReport);
683
+ const token = DataMaidService.generateToken(req.user.profile.handle, rawReport);
684
+
685
+ return res.json({ report, token });
686
+ } catch (error) {
687
+ console.error('[Data Maid] Error generating data maid report:', error);
688
+ return res.sendStatus(500);
689
+ }
690
+ });
691
+
692
+ router.post('/finalize', async (req, res) => {
693
+ try {
694
+ if (!req.user || !req.user.directories) {
695
+ return res.sendStatus(403);
696
+ }
697
+
698
+ if (!req.body.token) {
699
+ return res.sendStatus(400);
700
+ }
701
+
702
+ const token = req.body.token.toString();
703
+ if (!DataMaidService.TOKENS.has(token)) {
704
+ return res.sendStatus(403);
705
+ }
706
+
707
+ const tokenEntry = DataMaidService.TOKENS.get(token);
708
+ if (!tokenEntry || tokenEntry.handle !== req.user.profile.handle) {
709
+ return res.sendStatus(403);
710
+ }
711
+
712
+ // Remove the token after finalization
713
+ DataMaidService.TOKENS.delete(token);
714
+ return res.sendStatus(204);
715
+ } catch (error) {
716
+ console.error('[Data Maid] Error finalizing the token:', error);
717
+ return res.sendStatus(500);
718
+ }
719
+ });
720
+
721
+ router.get('/view', async (req, res) => {
722
+ try {
723
+ if (!req.user || !req.user.directories) {
724
+ return res.sendStatus(403);
725
+ }
726
+
727
+ if (!req.query.token || !req.query.hash) {
728
+ return res.sendStatus(400);
729
+ }
730
+
731
+ const token = req.query.token.toString();
732
+ const hash = req.query.hash.toString();
733
+
734
+ if (!DataMaidService.TOKENS.has(token)) {
735
+ return res.sendStatus(403);
736
+ }
737
+
738
+ const tokenEntry = DataMaidService.TOKENS.get(token);
739
+ if (!tokenEntry || tokenEntry.handle !== req.user.profile.handle) {
740
+ return res.sendStatus(403);
741
+ }
742
+
743
+ const fileEntry = tokenEntry.paths.find(entry => entry.hash === hash);
744
+ if (!fileEntry) {
745
+ return res.sendStatus(404);
746
+ }
747
+
748
+ if (!isPathUnderParent(req.user.directories.root, fileEntry.path)) {
749
+ console.warn('[Data Maid] Attempted access to a file outside of the user directory:', fileEntry.path);
750
+ return res.sendStatus(403);
751
+ }
752
+
753
+ const pathToFile = fileEntry.path;
754
+ const fileExists = fs.existsSync(pathToFile);
755
+
756
+ if (!fileExists) {
757
+ return res.sendStatus(404);
758
+ }
759
+
760
+ const fileBuffer = await fs.promises.readFile(pathToFile);
761
+ const mimeType = mime.lookup(pathToFile) || 'text/plain';
762
+ res.setHeader('Content-Type', mimeType);
763
+ return res.send(fileBuffer);
764
+ } catch (error) {
765
+ console.error('[Data Maid] Error viewing file:', error);
766
+ return res.sendStatus(500);
767
+ }
768
+ });
769
+
770
+ router.post('/delete', async (req, res) => {
771
+ try {
772
+ if (!req.user || !req.user.directories) {
773
+ return res.sendStatus(403);
774
+ }
775
+
776
+ const { token, hashes } = req.body;
777
+ if (!token || !Array.isArray(hashes) || hashes.length === 0) {
778
+ return res.sendStatus(400);
779
+ }
780
+
781
+ if (!DataMaidService.TOKENS.has(token)) {
782
+ return res.sendStatus(403);
783
+ }
784
+
785
+ const tokenEntry = DataMaidService.TOKENS.get(token);
786
+ if (!tokenEntry || tokenEntry.handle !== req.user.profile.handle) {
787
+ return res.sendStatus(403);
788
+ }
789
+
790
+ for (const hash of hashes) {
791
+ const fileEntry = tokenEntry.paths.find(entry => entry.hash === hash);
792
+ if (!fileEntry) {
793
+ continue;
794
+ }
795
+
796
+ if (!isPathUnderParent(req.user.directories.root, fileEntry.path)) {
797
+ console.warn('[Data Maid] Attempted deletion of a file outside of the user directory:', fileEntry.path);
798
+ continue;
799
+ }
800
+
801
+ const pathToFile = fileEntry.path;
802
+ const fileExists = fs.existsSync(pathToFile);
803
+
804
+ if (!fileExists) {
805
+ continue;
806
+ }
807
+
808
+ await fs.promises.unlink(pathToFile);
809
+ }
810
+
811
+ return res.sendStatus(204);
812
+ } catch (error) {
813
+ console.error('[Data Maid] Error deleting files:', error);
814
+ return res.sendStatus(500);
815
+ }
816
+ });
src/endpoints/extensions.js ADDED
@@ -0,0 +1,455 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+
4
+ import express from 'express';
5
+ import sanitize from 'sanitize-filename';
6
+ import { CheckRepoActions, default as simpleGit } from 'simple-git';
7
+
8
+ import { PUBLIC_DIRECTORIES } from '../constants.js';
9
+
10
+ /**
11
+ * @type {Partial<import('simple-git').SimpleGitOptions>}
12
+ */
13
+ const OPTIONS = Object.freeze({ timeout: { block: 5 * 60 * 1000 } });
14
+
15
+ /**
16
+ * This function extracts the extension information from the manifest file.
17
+ * @param {string} extensionPath - The path of the extension folder
18
+ * @returns {Promise<Object>} - Returns the manifest data as an object
19
+ */
20
+ async function getManifest(extensionPath) {
21
+ const manifestPath = path.join(extensionPath, 'manifest.json');
22
+
23
+ // Check if manifest.json exists
24
+ if (!fs.existsSync(manifestPath)) {
25
+ throw new Error(`Manifest file not found at ${manifestPath}`);
26
+ }
27
+
28
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
29
+ return manifest;
30
+ }
31
+
32
+ /**
33
+ * This function checks if the local repository is up-to-date with the remote repository.
34
+ * @param {string} extensionPath - The path of the extension folder
35
+ * @returns {Promise<Object>} - Returns the extension information as an object
36
+ */
37
+ async function checkIfRepoIsUpToDate(extensionPath) {
38
+ const git = simpleGit({ baseDir: extensionPath, ...OPTIONS });
39
+ await git.fetch('origin');
40
+ const currentBranch = await git.branch();
41
+ const currentCommitHash = await git.revparse(['HEAD']);
42
+ const log = await git.log({
43
+ from: currentCommitHash,
44
+ to: `origin/${currentBranch.current}`,
45
+ });
46
+
47
+ // Fetch remote repository information
48
+ const remotes = await git.getRemotes(true);
49
+ if (remotes.length === 0) {
50
+ return {
51
+ isUpToDate: true,
52
+ remoteUrl: '',
53
+ };
54
+ }
55
+
56
+ return {
57
+ isUpToDate: log.total === 0,
58
+ remoteUrl: remotes[0].refs.fetch, // URL of the remote repository
59
+ };
60
+ }
61
+
62
+ export const router = express.Router();
63
+
64
+ /**
65
+ * HTTP POST handler function to clone a git repository from a provided URL, read the extension manifest,
66
+ * and return extension information and path.
67
+ *
68
+ * @param {Object} request - HTTP Request object, expects a JSON body with a 'url' property.
69
+ * @param {Object} response - HTTP Response object used to respond to the HTTP request.
70
+ *
71
+ * @returns {void}
72
+ */
73
+ router.post('/install', async (request, response) => {
74
+ if (!request.body.url) {
75
+ return response.status(400).send('Bad Request: URL is required in the request body.');
76
+ }
77
+
78
+ try {
79
+ // No timeout for cloning, as it may take a while depending on the repo size
80
+ const git = simpleGit();
81
+
82
+ // make sure the third-party directory exists
83
+ if (!fs.existsSync(path.join(request.user.directories.extensions))) {
84
+ fs.mkdirSync(path.join(request.user.directories.extensions));
85
+ }
86
+
87
+ if (!fs.existsSync(PUBLIC_DIRECTORIES.globalExtensions)) {
88
+ fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions);
89
+ }
90
+
91
+ const { url, global, branch } = request.body;
92
+
93
+ if (global && !request.user.profile.admin) {
94
+ console.error(`User ${request.user.profile.handle} does not have permission to install global extensions.`);
95
+ return response.status(403).send('Forbidden: No permission to install global extensions.');
96
+ }
97
+
98
+ const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
99
+ const extensionPath = path.join(basePath, sanitize(path.basename(url, '.git')));
100
+
101
+ if (fs.existsSync(extensionPath)) {
102
+ return response.status(409).send(`Directory already exists at ${extensionPath}`);
103
+ }
104
+
105
+ const cloneOptions = { '--depth': 1 };
106
+ if (branch) {
107
+ cloneOptions['--branch'] = branch;
108
+ }
109
+ await git.clone(url, extensionPath, cloneOptions);
110
+ console.info(`Extension has been cloned to ${extensionPath} from ${url} at ${branch || '(default)'} branch`);
111
+
112
+ const { version, author, display_name } = await getManifest(extensionPath);
113
+
114
+ return response.send({ version, author, display_name, extensionPath });
115
+ } catch (error) {
116
+ console.error('Importing custom content failed', error);
117
+ return response.status(500).send(`Server Error: ${error.message}`);
118
+ }
119
+ });
120
+
121
+ /**
122
+ * HTTP POST handler function to pull the latest updates from a git repository
123
+ * based on the extension name provided in the request body. It returns the latest commit hash,
124
+ * the path of the extension, the status of the repository (whether it's up-to-date or not),
125
+ * and the remote URL of the repository.
126
+ *
127
+ * @param {Object} request - HTTP Request object, expects a JSON body with an 'extensionName' property.
128
+ * @param {Object} response - HTTP Response object used to respond to the HTTP request.
129
+ *
130
+ * @returns {void}
131
+ */
132
+ router.post('/update', async (request, response) => {
133
+ if (!request.body.extensionName) {
134
+ return response.status(400).send('Bad Request: extensionName is required in the request body.');
135
+ }
136
+
137
+ try {
138
+ const { extensionName, global } = request.body;
139
+
140
+ if (global && !request.user.profile.admin) {
141
+ console.error(`User ${request.user.profile.handle} does not have permission to update global extensions.`);
142
+ return response.status(403).send('Forbidden: No permission to update global extensions.');
143
+ }
144
+
145
+ const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
146
+ const extensionPath = path.join(basePath, sanitize(extensionName));
147
+
148
+ if (!fs.existsSync(extensionPath)) {
149
+ return response.status(404).send(`Directory does not exist at ${extensionPath}`);
150
+ }
151
+
152
+ const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
153
+ const git = simpleGit({ baseDir: extensionPath, ...OPTIONS });
154
+ const isRepo = await git.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
155
+ if (!isRepo) {
156
+ throw new Error(`Directory is not a Git repository at ${extensionPath}`);
157
+ }
158
+ const currentBranch = await git.branch();
159
+ if (!isUpToDate) {
160
+ await git.pull('origin', currentBranch.current);
161
+ console.info(`Extension has been updated at ${extensionPath}`);
162
+ } else {
163
+ console.info(`Extension is up to date at ${extensionPath}`);
164
+ }
165
+ await git.fetch('origin');
166
+ const fullCommitHash = await git.revparse(['HEAD']);
167
+ const shortCommitHash = fullCommitHash.slice(0, 7);
168
+
169
+ return response.send({ shortCommitHash, extensionPath, isUpToDate, remoteUrl });
170
+ } catch (error) {
171
+ console.error('Updating extension failed', error);
172
+ return response.status(500).send('Internal Server Error. Check the server logs for more details.');
173
+ }
174
+ });
175
+
176
+ router.post('/branches', async (request, response) => {
177
+ try {
178
+ const { extensionName, global } = request.body;
179
+
180
+ if (!extensionName) {
181
+ return response.status(400).send('Bad Request: extensionName is required in the request body.');
182
+ }
183
+
184
+ if (global && !request.user.profile.admin) {
185
+ console.error(`User ${request.user.profile.handle} does not have permission to list branches of global extensions.`);
186
+ return response.status(403).send('Forbidden: No permission to list branches of global extensions.');
187
+ }
188
+
189
+ const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
190
+ const extensionPath = path.join(basePath, sanitize(extensionName));
191
+
192
+ if (!fs.existsSync(extensionPath)) {
193
+ return response.status(404).send(`Directory does not exist at ${extensionPath}`);
194
+ }
195
+
196
+ const git = simpleGit({ baseDir: extensionPath, ...OPTIONS });
197
+ // Unshallow the repository if it is shallow
198
+ const isShallow = await git.revparse(['--is-shallow-repository']) === 'true';
199
+ if (isShallow) {
200
+ console.info(`Unshallowing the repository at ${extensionPath}`);
201
+ await git.fetch('origin', ['--unshallow']);
202
+ }
203
+
204
+ // Fetch all branches
205
+ await git.remote(['set-branches', 'origin', '*']);
206
+ await git.fetch('origin');
207
+ const localBranches = await git.branchLocal();
208
+ const remoteBranches = await git.branch(['-r', '--list', 'origin/*']);
209
+ const result = [
210
+ ...Object.values(localBranches.branches),
211
+ ...Object.values(remoteBranches.branches),
212
+ ].map(b => ({ current: b.current, commit: b.commit, name: b.name, label: b.label }));
213
+
214
+ return response.send(result);
215
+ } catch (error) {
216
+ console.error('Getting branches failed', error);
217
+ return response.status(500).send('Internal Server Error. Check the server logs for more details.');
218
+ }
219
+ });
220
+
221
+ router.post('/switch', async (request, response) => {
222
+ try {
223
+ const { extensionName, branch, global } = request.body;
224
+
225
+ if (!extensionName || !branch) {
226
+ return response.status(400).send('Bad Request: extensionName and branch are required in the request body.');
227
+ }
228
+
229
+ if (global && !request.user.profile.admin) {
230
+ console.error(`User ${request.user.profile.handle} does not have permission to switch branches of global extensions.`);
231
+ return response.status(403).send('Forbidden: No permission to switch branches of global extensions.');
232
+ }
233
+
234
+ const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
235
+ const extensionPath = path.join(basePath, sanitize(extensionName));
236
+
237
+ if (!fs.existsSync(extensionPath)) {
238
+ return response.status(404).send(`Directory does not exist at ${extensionPath}`);
239
+ }
240
+
241
+ const git = simpleGit({ baseDir: extensionPath, ...OPTIONS });
242
+ const branches = await git.branchLocal();
243
+
244
+ if (String(branch).startsWith('origin/')) {
245
+ const localBranch = branch.replace('origin/', '');
246
+ if (branches.all.includes(localBranch)) {
247
+ console.info(`Branch ${localBranch} already exists locally, checking it out`);
248
+ await git.checkout(localBranch);
249
+ return response.sendStatus(204);
250
+ }
251
+
252
+ console.info(`Branch ${localBranch} does not exist locally, creating it from ${branch}`);
253
+ await git.checkoutBranch(localBranch, branch);
254
+ return response.sendStatus(204);
255
+ }
256
+
257
+ if (!branches.all.includes(branch)) {
258
+ console.error(`Branch ${branch} does not exist locally`);
259
+ return response.status(404).send(`Branch ${branch} does not exist locally`);
260
+ }
261
+
262
+ // Check if the branch is already checked out
263
+ const currentBranch = await git.branch();
264
+ if (currentBranch.current === branch) {
265
+ console.info(`Branch ${branch} is already checked out`);
266
+ return response.sendStatus(204);
267
+ }
268
+
269
+ // Checkout the branch
270
+ await git.checkout(branch);
271
+ console.info(`Checked out branch ${branch} at ${extensionPath}`);
272
+
273
+ return response.sendStatus(204);
274
+ } catch (error) {
275
+ console.error('Switching branches failed', error);
276
+ return response.status(500).send('Internal Server Error. Check the server logs for more details.');
277
+ }
278
+ });
279
+
280
+ router.post('/move', async (request, response) => {
281
+ try {
282
+ const { extensionName, source, destination } = request.body;
283
+
284
+ if (!extensionName || !source || !destination) {
285
+ return response.status(400).send('Bad Request. Not all required parameters are provided.');
286
+ }
287
+
288
+ if (!request.user.profile.admin) {
289
+ console.error(`User ${request.user.profile.handle} does not have permission to move extensions.`);
290
+ return response.status(403).send('Forbidden: No permission to move extensions.');
291
+ }
292
+
293
+ const sourceDirectory = source === 'global' ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
294
+ const destinationDirectory = destination === 'global' ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
295
+ const sourcePath = path.join(sourceDirectory, sanitize(extensionName));
296
+ const destinationPath = path.join(destinationDirectory, sanitize(extensionName));
297
+
298
+ if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isDirectory()) {
299
+ console.error(`Source directory does not exist at ${sourcePath}`);
300
+ return response.status(404).send('Source directory does not exist.');
301
+ }
302
+
303
+ if (fs.existsSync(destinationPath)) {
304
+ console.error(`Destination directory already exists at ${destinationPath}`);
305
+ return response.status(409).send('Destination directory already exists.');
306
+ }
307
+
308
+ if (source === destination) {
309
+ console.error('Source and destination directories are the same');
310
+ return response.status(409).send('Source and destination directories are the same.');
311
+ }
312
+
313
+ fs.cpSync(sourcePath, destinationPath, { recursive: true, force: true });
314
+ fs.rmSync(sourcePath, { recursive: true, force: true });
315
+ console.info(`Extension has been moved from ${sourcePath} to ${destinationPath}`);
316
+
317
+ return response.sendStatus(204);
318
+ } catch (error) {
319
+ console.error('Moving extension failed', error);
320
+ return response.status(500).send('Internal Server Error. Check the server logs for more details.');
321
+ }
322
+ });
323
+
324
+ /**
325
+ * HTTP POST handler function to get the current git commit hash and branch name for a given extension.
326
+ * It checks whether the repository is up-to-date with the remote, and returns the status along with
327
+ * the remote URL of the repository.
328
+ *
329
+ * @param {Object} request - HTTP Request object, expects a JSON body with an 'extensionName' property.
330
+ * @param {Object} response - HTTP Response object used to respond to the HTTP request.
331
+ *
332
+ * @returns {void}
333
+ */
334
+ router.post('/version', async (request, response) => {
335
+ if (!request.body.extensionName) {
336
+ return response.status(400).send('Bad Request: extensionName is required in the request body.');
337
+ }
338
+
339
+ try {
340
+ const { extensionName, global } = request.body;
341
+ const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
342
+ const extensionPath = path.join(basePath, sanitize(extensionName));
343
+
344
+ if (!fs.existsSync(extensionPath)) {
345
+ return response.status(404).send(`Directory does not exist at ${extensionPath}`);
346
+ }
347
+
348
+ const git = simpleGit({ baseDir: extensionPath, ...OPTIONS });
349
+ let currentCommitHash;
350
+ try {
351
+ const isRepo = await git.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
352
+ if (!isRepo) {
353
+ throw new Error(`Directory is not a Git repository at ${extensionPath}`);
354
+ }
355
+ currentCommitHash = await git.revparse(['HEAD']);
356
+ } catch (error) {
357
+ // it is not a git repo, or has no commits yet, or is a bare repo
358
+ // not possible to update it, most likely can't get the branch name either
359
+ return response.send({ currentBranchName: '', currentCommitHash: '', isUpToDate: true, remoteUrl: '' });
360
+ }
361
+
362
+ const currentBranch = await git.branch();
363
+ // get only the working branch
364
+ const currentBranchName = currentBranch.current;
365
+ await git.fetch('origin');
366
+ console.debug(extensionName, currentBranchName, currentCommitHash);
367
+ const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
368
+
369
+ return response.send({ currentBranchName, currentCommitHash, isUpToDate, remoteUrl });
370
+
371
+ } catch (error) {
372
+ console.error('Getting extension version failed', error);
373
+ return response.status(500).send(`Server Error: ${error.message}`);
374
+ }
375
+ });
376
+
377
+ /**
378
+ * HTTP POST handler function to delete a git repository based on the extension name provided in the request body.
379
+ *
380
+ * @param {Object} request - HTTP Request object, expects a JSON body with a 'url' property.
381
+ * @param {Object} response - HTTP Response object used to respond to the HTTP request.
382
+ *
383
+ * @returns {void}
384
+ */
385
+ router.post('/delete', async (request, response) => {
386
+ if (!request.body.extensionName) {
387
+ return response.status(400).send('Bad Request: extensionName is required in the request body.');
388
+ }
389
+
390
+ try {
391
+ const { extensionName, global } = request.body;
392
+
393
+ if (global && !request.user.profile.admin) {
394
+ console.error(`User ${request.user.profile.handle} does not have permission to delete global extensions.`);
395
+ return response.status(403).send('Forbidden: No permission to delete global extensions.');
396
+ }
397
+
398
+ const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
399
+ const extensionPath = path.join(basePath, sanitize(extensionName));
400
+
401
+ if (!fs.existsSync(extensionPath)) {
402
+ return response.status(404).send(`Directory does not exist at ${extensionPath}`);
403
+ }
404
+
405
+ await fs.promises.rm(extensionPath, { recursive: true });
406
+ console.info(`Extension has been deleted at ${extensionPath}`);
407
+
408
+ return response.send(`Extension has been deleted at ${extensionPath}`);
409
+
410
+ } catch (error) {
411
+ console.error('Deleting custom content failed', error);
412
+ return response.status(500).send(`Server Error: ${error.message}`);
413
+ }
414
+ });
415
+
416
+ /**
417
+ * Discover the extension folders
418
+ * If the folder is called third-party, search for subfolders instead
419
+ */
420
+ router.get('/discover', function (request, response) {
421
+ if (!fs.existsSync(path.join(request.user.directories.extensions))) {
422
+ fs.mkdirSync(path.join(request.user.directories.extensions));
423
+ }
424
+
425
+ if (!fs.existsSync(PUBLIC_DIRECTORIES.globalExtensions)) {
426
+ fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions);
427
+ }
428
+
429
+ // Get all folders in system extensions folder, excluding third-party
430
+ const builtInExtensions = fs
431
+ .readdirSync(PUBLIC_DIRECTORIES.extensions)
432
+ .filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.extensions, f)).isDirectory())
433
+ .filter(f => f !== 'third-party')
434
+ .map(f => ({ type: 'system', name: f }));
435
+
436
+ // Get all folders in local extensions folder
437
+ const userExtensions = fs
438
+ .readdirSync(path.join(request.user.directories.extensions))
439
+ .filter(f => fs.statSync(path.join(request.user.directories.extensions, f)).isDirectory())
440
+ .map(f => ({ type: 'local', name: `third-party/${f}` }));
441
+
442
+ // Get all folders in global extensions folder
443
+ // In case of a conflict, the extension will be loaded from the user folder
444
+ const globalExtensions = fs
445
+ .readdirSync(PUBLIC_DIRECTORIES.globalExtensions)
446
+ .filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.globalExtensions, f)).isDirectory())
447
+ .map(f => ({ type: 'global', name: `third-party/${f}` }))
448
+ .filter(f => !userExtensions.some(e => e.name === f.name));
449
+
450
+ // Combine all extensions
451
+ const allExtensions = [...builtInExtensions, ...userExtensions, ...globalExtensions];
452
+ console.debug('Extensions available for', request.user.profile.handle, allExtensions);
453
+
454
+ return response.send(allExtensions);
455
+ });
src/endpoints/files.js ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+
4
+ import express from 'express';
5
+ import sanitize from 'sanitize-filename';
6
+ import { sync as writeFileSyncAtomic } from 'write-file-atomic';
7
+
8
+ import { validateAssetFileName } from './assets.js';
9
+ import { clientRelativePath } from '../util.js';
10
+
11
+ export const router = express.Router();
12
+
13
+ router.post('/sanitize-filename', async (request, response) => {
14
+ try {
15
+ const fileName = String(request.body.fileName);
16
+ if (!fileName) {
17
+ return response.status(400).send('No fileName specified');
18
+ }
19
+
20
+ const sanitizedFilename = sanitize(fileName);
21
+ return response.send({ fileName: sanitizedFilename });
22
+ } catch (error) {
23
+ console.error(error);
24
+ return response.sendStatus(500);
25
+ }
26
+ });
27
+
28
+ router.post('/upload', async (request, response) => {
29
+ try {
30
+ if (!request.body.name) {
31
+ return response.status(400).send('No upload name specified');
32
+ }
33
+
34
+ if (!request.body.data) {
35
+ return response.status(400).send('No upload data specified');
36
+ }
37
+
38
+ // Validate filename
39
+ const validation = validateAssetFileName(request.body.name);
40
+ if (validation.error)
41
+ return response.status(400).send(validation.message);
42
+
43
+ const pathToUpload = path.join(request.user.directories.files, request.body.name);
44
+ writeFileSyncAtomic(pathToUpload, request.body.data, 'base64');
45
+ const url = clientRelativePath(request.user.directories.root, pathToUpload);
46
+ console.info(`Uploaded file: ${url} from ${request.user.profile.handle}`);
47
+ return response.send({ path: url });
48
+ } catch (error) {
49
+ console.error(error);
50
+ return response.sendStatus(500);
51
+ }
52
+ });
53
+
54
+ router.post('/delete', async (request, response) => {
55
+ try {
56
+ if (!request.body.path) {
57
+ return response.status(400).send('No path specified');
58
+ }
59
+
60
+ const pathToDelete = path.join(request.user.directories.root, request.body.path);
61
+ if (!pathToDelete.startsWith(request.user.directories.files)) {
62
+ return response.status(400).send('Invalid path');
63
+ }
64
+
65
+ if (!fs.existsSync(pathToDelete)) {
66
+ return response.status(404).send('File not found');
67
+ }
68
+
69
+ fs.unlinkSync(pathToDelete);
70
+ console.info(`Deleted file: ${request.body.path} from ${request.user.profile.handle}`);
71
+ return response.sendStatus(200);
72
+ } catch (error) {
73
+ console.error(error);
74
+ return response.sendStatus(500);
75
+ }
76
+ });
77
+
78
+ router.post('/verify', async (request, response) => {
79
+ try {
80
+ if (!Array.isArray(request.body.urls)) {
81
+ return response.status(400).send('No URLs specified');
82
+ }
83
+
84
+ const verified = {};
85
+
86
+ for (const url of request.body.urls) {
87
+ const pathToVerify = path.join(request.user.directories.root, url);
88
+ if (!pathToVerify.startsWith(request.user.directories.files)) {
89
+ console.warn(`File verification: Invalid path: ${pathToVerify}`);
90
+ continue;
91
+ }
92
+ const fileExists = fs.existsSync(pathToVerify);
93
+ verified[url] = fileExists;
94
+ }
95
+
96
+ return response.send(verified);
97
+ } catch (error) {
98
+ console.error(error);
99
+ return response.sendStatus(500);
100
+ }
101
+ });
src/endpoints/google.js ADDED
@@ -0,0 +1,641 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Buffer } from 'node:buffer';
2
+ import fetch from 'node-fetch';
3
+ import express from 'express';
4
+ import { speak, languages } from 'google-translate-api-x';
5
+ import crypto from 'node:crypto';
6
+ import util from 'node:util';
7
+ import urlJoin from 'url-join';
8
+ import lodash from 'lodash';
9
+
10
+ import { readSecret, SECRET_KEYS } from './secrets.js';
11
+ import { GEMINI_SAFETY, VERTEX_SAFETY } from '../constants.js';
12
+ import { delay, getConfigValue, trimTrailingSlash } from '../util.js';
13
+
14
+ const API_MAKERSUITE = 'https://generativelanguage.googleapis.com';
15
+ const API_VERTEX_AI = 'https://us-central1-aiplatform.googleapis.com';
16
+
17
+ function createWavHeader(dataSize, sampleRate, numChannels = 1, bitsPerSample = 16) {
18
+ const header = Buffer.alloc(44);
19
+ header.write('RIFF', 0);
20
+ header.writeUInt32LE(36 + dataSize, 4);
21
+ header.write('WAVE', 8);
22
+ header.write('fmt ', 12);
23
+ header.writeUInt32LE(16, 16);
24
+ header.writeUInt16LE(1, 20);
25
+ header.writeUInt16LE(numChannels, 22);
26
+ header.writeUInt32LE(sampleRate, 24);
27
+ header.writeUInt32LE(sampleRate * numChannels * bitsPerSample / 8, 28);
28
+ header.writeUInt16LE(numChannels * bitsPerSample / 8, 32);
29
+ header.writeUInt16LE(bitsPerSample, 34);
30
+ header.write('data', 36);
31
+ header.writeUInt32LE(dataSize, 40);
32
+ return header;
33
+ }
34
+
35
+ function createCompleteWavFile(pcmData, sampleRate) {
36
+ const header = createWavHeader(pcmData.length, sampleRate);
37
+ return Buffer.concat([header, pcmData]);
38
+ }
39
+
40
+ // Vertex AI authentication helper functions
41
+ export async function getVertexAIAuth(request) {
42
+ const authMode = request.body.vertexai_auth_mode || 'express';
43
+
44
+ if (request.body.reverse_proxy) {
45
+ return {
46
+ authHeader: `Bearer ${request.body.proxy_password}`,
47
+ authType: 'proxy',
48
+ };
49
+ }
50
+
51
+ if (authMode === 'express') {
52
+ const apiKey = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI);
53
+ if (apiKey) {
54
+ return {
55
+ authHeader: `Bearer ${apiKey}`,
56
+ authType: 'express',
57
+ };
58
+ }
59
+ throw new Error('API key is required for Vertex AI Express mode');
60
+ } else if (authMode === 'full') {
61
+ // Get service account JSON from backend storage
62
+ const serviceAccountJson = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT);
63
+
64
+ if (serviceAccountJson) {
65
+ try {
66
+ const serviceAccount = JSON.parse(serviceAccountJson);
67
+ const jwtToken = await generateJWTToken(serviceAccount);
68
+ const accessToken = await getAccessToken(jwtToken);
69
+ return {
70
+ authHeader: `Bearer ${accessToken}`,
71
+ authType: 'full',
72
+ };
73
+ } catch (error) {
74
+ console.error('Failed to authenticate with service account:', error);
75
+ throw new Error(`Service account authentication failed: ${error.message}`);
76
+ }
77
+ }
78
+ throw new Error('Service Account JSON is required for Vertex AI Full mode');
79
+ }
80
+
81
+ throw new Error(`Unsupported Vertex AI authentication mode: ${authMode}`);
82
+ }
83
+
84
+ /**
85
+ * Generates a JWT token for Google Cloud authentication using service account credentials.
86
+ * @param {object} serviceAccount Service account JSON object
87
+ * @returns {Promise<string>} JWT token
88
+ */
89
+ export async function generateJWTToken(serviceAccount) {
90
+ const now = Math.floor(Date.now() / 1000);
91
+ const expiry = now + 3600; // 1 hour
92
+
93
+ const header = {
94
+ alg: 'RS256',
95
+ typ: 'JWT',
96
+ };
97
+
98
+ const payload = {
99
+ iss: serviceAccount.client_email,
100
+ scope: 'https://www.googleapis.com/auth/cloud-platform',
101
+ aud: 'https://oauth2.googleapis.com/token',
102
+ iat: now,
103
+ exp: expiry,
104
+ };
105
+
106
+ const headerBase64 = Buffer.from(JSON.stringify(header)).toString('base64url');
107
+ const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
108
+ const signatureInput = `${headerBase64}.${payloadBase64}`;
109
+
110
+ // Create signature using private key
111
+ const sign = crypto.createSign('RSA-SHA256');
112
+ sign.update(signatureInput);
113
+ const signature = sign.sign(serviceAccount.private_key, 'base64url');
114
+
115
+ return `${signatureInput}.${signature}`;
116
+ }
117
+
118
+ export async function getAccessToken(jwtToken) {
119
+ const response = await fetch('https://oauth2.googleapis.com/token', {
120
+ method: 'POST',
121
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
122
+ body: new URLSearchParams({
123
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
124
+ assertion: jwtToken,
125
+ }),
126
+ });
127
+
128
+ if (!response.ok) {
129
+ const error = await response.text();
130
+ throw new Error(`Failed to get access token: ${error}`);
131
+ }
132
+
133
+ /** @type {any} */
134
+ const data = await response.json();
135
+ return data.access_token;
136
+ }
137
+
138
+ /**
139
+ * Extracts the project ID from a Service Account JSON object.
140
+ * @param {object} serviceAccount Service account JSON object
141
+ * @returns {string} Project ID
142
+ * @throws {Error} If project ID is not found in the service account
143
+ */
144
+ export function getProjectIdFromServiceAccount(serviceAccount) {
145
+ if (!serviceAccount || typeof serviceAccount !== 'object') {
146
+ throw new Error('Invalid service account object');
147
+ }
148
+
149
+ const projectId = serviceAccount.project_id;
150
+ if (!projectId || typeof projectId !== 'string') {
151
+ throw new Error('Project ID not found in service account JSON');
152
+ }
153
+
154
+ return projectId;
155
+ }
156
+
157
+ /**
158
+ * Generates Google API URL and headers based on request configuration
159
+ * @param {express.Request} request Express request object
160
+ * @param {string} model Model name to use
161
+ * @param {string} endpoint API endpoint (default: 'generateContent')
162
+ * @returns {Promise<{url: string, headers: object, apiName: string, baseUrl: string, safetySettings: object[]}>} URL, headers, and API name
163
+ */
164
+ export async function getGoogleApiConfig(request, model, endpoint = 'generateContent') {
165
+ const useVertexAi = request.body.api === 'vertexai';
166
+ const region = request.body.vertexai_region || 'us-central1';
167
+ const apiName = useVertexAi ? 'Google Vertex AI' : 'Google AI Studio';
168
+ const safetySettings = [...GEMINI_SAFETY, ...(useVertexAi ? VERTEX_SAFETY : [])];
169
+
170
+ let url;
171
+ let baseUrl;
172
+ let headers = {
173
+ 'Content-Type': 'application/json',
174
+ };
175
+
176
+ if (useVertexAi) {
177
+ // Get authentication for Vertex AI
178
+ const { authHeader, authType } = await getVertexAIAuth(request);
179
+
180
+ if (authType === 'express') {
181
+ // Express mode: use API key parameter
182
+ const keyParam = authHeader.replace('Bearer ', '');
183
+ const projectId = request.body.vertexai_express_project_id;
184
+ baseUrl = region === 'global'
185
+ ? 'https://aiplatform.googleapis.com/v1'
186
+ : `https://${region}-aiplatform.googleapis.com/v1`;
187
+ url = projectId
188
+ ? `${baseUrl}/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}`
189
+ : `${baseUrl}/publishers/google/models/${model}:${endpoint}`;
190
+ headers['x-goog-api-key'] = keyParam;
191
+ } else if (authType === 'full') {
192
+ // Full mode: use project-specific URL with Authorization header
193
+ // Get project ID from Service Account JSON
194
+ const serviceAccountJson = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT);
195
+ if (!serviceAccountJson) {
196
+ throw new Error('Vertex AI Service Account JSON is missing.');
197
+ }
198
+
199
+ let projectId;
200
+ try {
201
+ const serviceAccount = JSON.parse(serviceAccountJson);
202
+ projectId = getProjectIdFromServiceAccount(serviceAccount);
203
+ } catch (error) {
204
+ throw new Error('Failed to extract project ID from Service Account JSON.');
205
+ }
206
+ // Handle global region differently - no region prefix in hostname
207
+ baseUrl = region === 'global'
208
+ ? 'https://aiplatform.googleapis.com/v1'
209
+ : `https://${region}-aiplatform.googleapis.com/v1`;
210
+ url = `${baseUrl}/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}`;
211
+ headers['Authorization'] = authHeader;
212
+ } else {
213
+ // Proxy mode: use Authorization header
214
+ const apiUrl = trimTrailingSlash(request.body.reverse_proxy || API_VERTEX_AI);
215
+ baseUrl = `${apiUrl}/v1`;
216
+ url = `${baseUrl}/publishers/google/models/${model}:${endpoint}`;
217
+ headers['Authorization'] = authHeader;
218
+ }
219
+ } else {
220
+ // Google AI Studio
221
+ const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE);
222
+ const apiUrl = trimTrailingSlash(request.body.reverse_proxy || API_MAKERSUITE);
223
+ const apiVersion = getConfigValue('gemini.apiVersion', 'v1beta');
224
+ baseUrl = `${apiUrl}/${apiVersion}`;
225
+ url = `${baseUrl}/models/${model}:${endpoint}`;
226
+ headers['x-goog-api-key'] = apiKey;
227
+ }
228
+
229
+ return { url, headers, apiName, baseUrl, safetySettings };
230
+ }
231
+
232
+ export const router = express.Router();
233
+
234
+ router.post('/caption-image', async (request, response) => {
235
+ try {
236
+ const mimeType = request.body.image.split(';')[0].split(':')[1];
237
+ const base64Data = request.body.image.split(',')[1];
238
+ const model = request.body.model || 'gemini-2.0-flash';
239
+ const { url, headers, apiName, safetySettings } = await getGoogleApiConfig(request, model);
240
+
241
+ const body = {
242
+ contents: [{
243
+ role: 'user',
244
+ parts: [
245
+ { text: request.body.prompt },
246
+ {
247
+ inlineData: {
248
+ mimeType: mimeType,
249
+ data: base64Data,
250
+ },
251
+ }],
252
+ }],
253
+ safetySettings: safetySettings,
254
+ };
255
+
256
+ console.debug(`${apiName} captioning request`, model, body);
257
+
258
+ const result = await fetch(url, {
259
+ body: JSON.stringify(body),
260
+ method: 'POST',
261
+ headers: headers,
262
+ });
263
+
264
+ if (!result.ok) {
265
+ const error = await result.json();
266
+ console.error(`${apiName} API returned error: ${result.status} ${result.statusText}`, error);
267
+ return response.status(500).send({ error: true });
268
+ }
269
+
270
+ /** @type {any} */
271
+ const data = await result.json();
272
+ console.info(`${apiName} captioning response`, data);
273
+
274
+ const candidates = data?.candidates;
275
+ if (!candidates) {
276
+ return response.status(500).send('No candidates found, image was most likely filtered.');
277
+ }
278
+
279
+ const caption = candidates[0].content.parts[0].text;
280
+ if (!caption) {
281
+ return response.status(500).send('No caption found');
282
+ }
283
+
284
+ return response.json({ caption });
285
+ } catch (error) {
286
+ console.error(error);
287
+ response.status(500).send('Internal server error');
288
+ }
289
+ });
290
+
291
+ router.post('/list-voices', (_, response) => {
292
+ return response.json(languages);
293
+ });
294
+
295
+ router.post('/generate-voice', async (request, response) => {
296
+ try {
297
+ const text = request.body.text;
298
+ const voice = request.body.voice ?? 'en';
299
+
300
+ const result = await speak(text, { to: voice, forceBatch: false });
301
+ const buffer = Array.isArray(result)
302
+ ? Buffer.concat(result.map(x => new Uint8Array(Buffer.from(x.toString(), 'base64'))))
303
+ : Buffer.from(result.toString(), 'base64');
304
+
305
+ response.setHeader('Content-Type', 'audio/mpeg');
306
+ return response.send(buffer);
307
+ } catch (error) {
308
+ console.error('Google Translate TTS generation failed', error);
309
+ response.status(500).send('Internal server error');
310
+ }
311
+ });
312
+
313
+ router.post('/list-native-voices', async (_, response) => {
314
+ try {
315
+ // Hardcoded Gemini native TTS voices from official documentation
316
+ // Source: https://ai.google.dev/gemini-api/docs/speech-generation#voices
317
+ const voices = [
318
+ { name: 'Zephyr', voice_id: 'Zephyr', lang: 'en-US', description: 'Bright' },
319
+ { name: 'Puck', voice_id: 'Puck', lang: 'en-US', description: 'Upbeat' },
320
+ { name: 'Charon', voice_id: 'Charon', lang: 'en-US', description: 'Informative' },
321
+ { name: 'Kore', voice_id: 'Kore', lang: 'en-US', description: 'Firm' },
322
+ { name: 'Fenrir', voice_id: 'Fenrir', lang: 'en-US', description: 'Excitable' },
323
+ { name: 'Leda', voice_id: 'Leda', lang: 'en-US', description: 'Youthful' },
324
+ { name: 'Orus', voice_id: 'Orus', lang: 'en-US', description: 'Firm' },
325
+ { name: 'Aoede', voice_id: 'Aoede', lang: 'en-US', description: 'Breezy' },
326
+ { name: 'Callirhoe', voice_id: 'Callirhoe', lang: 'en-US', description: 'Easy-going' },
327
+ { name: 'Autonoe', voice_id: 'Autonoe', lang: 'en-US', description: 'Bright' },
328
+ { name: 'Enceladus', voice_id: 'Enceladus', lang: 'en-US', description: 'Breathy' },
329
+ { name: 'Iapetus', voice_id: 'Iapetus', lang: 'en-US', description: 'Clear' },
330
+ { name: 'Umbriel', voice_id: 'Umbriel', lang: 'en-US', description: 'Easy-going' },
331
+ { name: 'Algieba', voice_id: 'Algieba', lang: 'en-US', description: 'Smooth' },
332
+ { name: 'Despina', voice_id: 'Despina', lang: 'en-US', description: 'Smooth' },
333
+ { name: 'Erinome', voice_id: 'Erinome', lang: 'en-US', description: 'Clear' },
334
+ { name: 'Algenib', voice_id: 'Algenib', lang: 'en-US', description: 'Gravelly' },
335
+ { name: 'Rasalgethi', voice_id: 'Rasalgethi', lang: 'en-US', description: 'Informative' },
336
+ { name: 'Laomedeia', voice_id: 'Laomedeia', lang: 'en-US', description: 'Upbeat' },
337
+ { name: 'Achernar', voice_id: 'Achernar', lang: 'en-US', description: 'Soft' },
338
+ { name: 'Alnilam', voice_id: 'Alnilam', lang: 'en-US', description: 'Firm' },
339
+ { name: 'Schedar', voice_id: 'Schedar', lang: 'en-US', description: 'Even' },
340
+ { name: 'Gacrux', voice_id: 'Gacrux', lang: 'en-US', description: 'Mature' },
341
+ { name: 'Pulcherrima', voice_id: 'Pulcherrima', lang: 'en-US', description: 'Forward' },
342
+ { name: 'Achird', voice_id: 'Achird', lang: 'en-US', description: 'Friendly' },
343
+ { name: 'Zubenelgenubi', voice_id: 'Zubenelgenubi', lang: 'en-US', description: 'Casual' },
344
+ { name: 'Vindemiatrix', voice_id: 'Vindemiatrix', lang: 'en-US', description: 'Gentle' },
345
+ { name: 'Sadachbia', voice_id: 'Sadachbia', lang: 'en-US', description: 'Lively' },
346
+ { name: 'Sadaltager', voice_id: 'Sadaltager', lang: 'en-US', description: 'Knowledgeable' },
347
+ { name: 'Sulafat', voice_id: 'Sulafat', lang: 'en-US', description: 'Warm' },
348
+ ];
349
+ return response.json({ voices });
350
+ } catch (error) {
351
+ console.error('Failed to return Google TTS voices:', error);
352
+ response.sendStatus(500);
353
+ }
354
+ });
355
+
356
+ router.post('/generate-native-tts', async (request, response) => {
357
+ try {
358
+ const { text, voice, model } = request.body;
359
+ const { url, headers, apiName, safetySettings } = await getGoogleApiConfig(request, model);
360
+
361
+ console.debug(`${apiName} TTS request`, { model, text, voice });
362
+
363
+ const requestBody = {
364
+ contents: [{
365
+ role: 'user',
366
+ parts: [{ text: text }],
367
+ }],
368
+ generationConfig: {
369
+ responseModalities: ['AUDIO'],
370
+ speechConfig: {
371
+ voiceConfig: {
372
+ prebuiltVoiceConfig: {
373
+ voiceName: voice,
374
+ },
375
+ },
376
+ },
377
+ },
378
+ safetySettings: safetySettings,
379
+ };
380
+
381
+ const result = await fetch(url, {
382
+ method: 'POST',
383
+ headers: headers,
384
+ body: JSON.stringify(requestBody),
385
+ });
386
+
387
+ if (!result.ok) {
388
+ const errorText = await result.text();
389
+ console.error(`${apiName} TTS API error: ${result.status} ${result.statusText}`, errorText);
390
+ const errorMessage = JSON.parse(errorText).error?.message || 'TTS generation failed.';
391
+ return response.status(result.status).json({ error: errorMessage });
392
+ }
393
+
394
+ /** @type {any} */
395
+ const data = await result.json();
396
+ const audioPart = data?.candidates?.[0]?.content?.parts?.[0];
397
+ const audioData = audioPart?.inlineData?.data;
398
+ const mimeType = audioPart?.inlineData?.mimeType;
399
+
400
+ if (!audioData) {
401
+ return response.status(500).json({ error: 'No audio data found in response' });
402
+ }
403
+
404
+ const audioBuffer = Buffer.from(audioData, 'base64');
405
+
406
+ //If the audio is raw PCM, wrap it in a WAV header and send it.
407
+ if (mimeType && mimeType.toLowerCase().includes('audio/l16')) {
408
+ const rateMatch = mimeType.match(/rate=(\d+)/);
409
+ const sampleRate = rateMatch ? parseInt(rateMatch[1], 10) : 24000;
410
+ const pcmData = audioBuffer;
411
+
412
+ // Create a complete, playable WAV file buffer.
413
+ const wavBuffer = createCompleteWavFile(pcmData, sampleRate);
414
+
415
+ // Send the WAV file directly to the browser. This is much faster.
416
+ response.setHeader('Content-Type', 'audio/wav');
417
+ return response.send(wavBuffer);
418
+ }
419
+
420
+ // Fallback for any other audio format Google might send in the future.
421
+ response.setHeader('Content-Type', mimeType || 'application/octet-stream');
422
+ response.send(audioBuffer);
423
+ } catch (error) {
424
+ console.error('Google TTS generation failed:', error);
425
+ if (!response.headersSent) {
426
+ return response.status(500).json({ error: 'Internal server error during TTS generation' });
427
+ }
428
+ return response.end();
429
+ }
430
+ });
431
+
432
+ router.post('/generate-image', async (request, response) => {
433
+ try {
434
+ const model = request.body.model || 'imagen-3.0-generate-002';
435
+ const { url, headers, apiName } = await getGoogleApiConfig(request, model, 'predict');
436
+
437
+ // AI Studio is stricter than Vertex AI.
438
+ const isVertex = request.body.api === 'vertexai';
439
+ // Is it even worth it?
440
+ const isDeprecated = model.startsWith('imagegeneration');
441
+ // Get person generation setting from config
442
+ const personGeneration = getConfigValue('gemini.image.personGeneration', 'allow_adult');
443
+
444
+ const requestBody = {
445
+ instances: [{
446
+ prompt: request.body.prompt || '',
447
+ }],
448
+ parameters: {
449
+ sampleCount: 1,
450
+ seed: isVertex ? Number(request.body.seed ?? Math.floor(Math.random() * 1000000)) : undefined,
451
+ enhancePrompt: isVertex ? Boolean(request.body.enhance ?? false) : undefined,
452
+ negativePrompt: isVertex ? (request.body.negative_prompt || undefined) : undefined,
453
+ aspectRatio: String(request.body.aspect_ratio || '1:1'),
454
+ personGeneration: !isDeprecated && personGeneration ? personGeneration : undefined,
455
+ language: isVertex ? 'auto' : undefined,
456
+ safetySetting: !isDeprecated ? (isVertex ? 'block_only_high' : 'block_low_and_above') : undefined,
457
+ addWatermark: isVertex ? false : undefined,
458
+ outputOptions: {
459
+ mimeType: 'image/jpeg',
460
+ compressionQuality: 100,
461
+ },
462
+ },
463
+ };
464
+
465
+ console.debug(`${apiName} image generation request:`, model, requestBody);
466
+
467
+ const result = await fetch(url, {
468
+ method: 'POST',
469
+ headers: headers,
470
+ body: JSON.stringify(requestBody),
471
+ });
472
+
473
+ if (!result.ok) {
474
+ const errorText = await result.text();
475
+ console.warn(`${apiName} image generation error: ${result.status} ${result.statusText}`, errorText);
476
+ return response.status(500).send('Image generation request failed');
477
+ }
478
+
479
+ /** @type {any} */
480
+ const data = await result.json();
481
+ const imagePart = data?.predictions?.[0]?.bytesBase64Encoded;
482
+
483
+ if (!imagePart) {
484
+ console.warn(`${apiName} image generation error: No image data found in response`);
485
+ return response.status(500).send('No image data found in response');
486
+ }
487
+
488
+ return response.send({ image: imagePart });
489
+ } catch (error) {
490
+ console.error('Google Image generation failed:', error);
491
+ if (!response.headersSent) {
492
+ return response.sendStatus(500);
493
+ }
494
+ return response.end();
495
+ }
496
+ });
497
+
498
+ router.post('/generate-video', async (request, response) => {
499
+ try {
500
+ const controller = new AbortController();
501
+ request.socket.removeAllListeners('close');
502
+ request.socket.on('close', function () {
503
+ controller.abort();
504
+ });
505
+
506
+ const model = request.body.model || 'veo-3.1-generate-preview';
507
+ const { url, headers, apiName, baseUrl } = await getGoogleApiConfig(request, model, 'predictLongRunning');
508
+ const useVertexAi = request.body.api === 'vertexai';
509
+
510
+ const isVeo3 = /veo-3/.test(model);
511
+ const lowerBound = isVeo3 ? 4 : 5;
512
+ const upperBound = isVeo3 ? 8 : 8;
513
+
514
+ const requestBody = {
515
+ instances: [{
516
+ prompt: String(request.body.prompt || ''),
517
+ }],
518
+ parameters: {
519
+ negativePrompt: String(request.body.negative_prompt || ''),
520
+ durationSeconds: lodash.clamp(Number(request.body.seconds || 6), lowerBound, upperBound),
521
+ aspectRatio: String(request.body.aspect_ratio || '16:9'),
522
+ personGeneration: 'allow_all',
523
+ seed: isVeo3 ? Number(request.body.seed ?? Math.floor(Math.random() * 1000000)) : undefined,
524
+ },
525
+ };
526
+
527
+ console.debug(`${apiName} video generation request:`, model, requestBody);
528
+ const videoJobResponse = await fetch(url, {
529
+ method: 'POST',
530
+ headers: headers,
531
+ body: JSON.stringify(requestBody),
532
+ });
533
+
534
+ if (!videoJobResponse.ok) {
535
+ const errorText = await videoJobResponse.text();
536
+ console.warn(`${apiName} video generation error: ${videoJobResponse.status} ${videoJobResponse.statusText}`, errorText);
537
+ return response.status(500).send('Video generation request failed');
538
+ }
539
+
540
+ /** @type {any} */
541
+ const videoJobData = await videoJobResponse.json();
542
+ const videoJobName = videoJobData?.name;
543
+
544
+ if (!videoJobName) {
545
+ console.warn(`${apiName} video generation error: No job name found in response`);
546
+ return response.status(500).send('No video job name found in response');
547
+ }
548
+
549
+ console.debug(`${apiName} video job name:`, videoJobName);
550
+
551
+ for (let attempt = 0; attempt < 30; attempt++) {
552
+ if (controller.signal.aborted) {
553
+ console.info(`${apiName} video generation aborted by client`);
554
+ return response.status(500).send('Video generation aborted by client');
555
+ }
556
+
557
+ await delay(5000 + attempt * 1000);
558
+
559
+ if (useVertexAi) {
560
+ const { url: pollUrl, headers: pollHeaders } = await getGoogleApiConfig(request, model, 'fetchPredictOperation');
561
+
562
+ const pollResponse = await fetch(pollUrl, {
563
+ method: 'POST',
564
+ headers: pollHeaders,
565
+ body: JSON.stringify({ operationName: videoJobName }),
566
+ });
567
+
568
+ if (!pollResponse.ok) {
569
+ const errorText = await pollResponse.text();
570
+ console.warn(`${apiName} video job status error: ${pollResponse.status} ${pollResponse.statusText}`, errorText);
571
+ return response.status(500).send('Video job status request failed');
572
+ }
573
+
574
+ /** @type {any} */
575
+ const pollData = await pollResponse.json();
576
+ const jobDone = pollData?.done;
577
+ console.debug(`${apiName} video job status attempt ${attempt + 1}: ${jobDone ? 'done' : 'running'}`);
578
+
579
+ if (jobDone) {
580
+ const videoData = pollData?.response?.videos?.[0]?.bytesBase64Encoded;
581
+ if (!videoData) {
582
+ const pollDataLog = util.inspect(pollData, { depth: 5, colors: true, maxStringLength: 500 });
583
+ console.warn(`${apiName} video generation error: No video data found in response`, pollDataLog);
584
+ return response.status(500).send('No video data found in response');
585
+ }
586
+
587
+ return response.send({ video: videoData });
588
+ }
589
+ } else {
590
+ const pollUrl = urlJoin(baseUrl, videoJobName);
591
+ const pollResponse = await fetch(pollUrl, {
592
+ method: 'GET',
593
+ headers: headers,
594
+ });
595
+
596
+ if (!pollResponse.ok) {
597
+ const errorText = await pollResponse.text();
598
+ console.warn(`${apiName} video job status error: ${pollResponse.status} ${pollResponse.statusText}`, errorText);
599
+ return response.status(500).send('Video job status request failed');
600
+ }
601
+
602
+ /** @type {any} */
603
+ const pollData = await pollResponse.json();
604
+ const jobDone = pollData?.done;
605
+ console.debug(`${apiName} video job status attempt ${attempt + 1}: ${jobDone ? 'done' : 'running'}`);
606
+
607
+ if (jobDone) {
608
+ const videoUri = pollData?.response?.generateVideoResponse?.generatedSamples?.[0]?.video?.uri;
609
+ console.debug(`${apiName} video URI:`, videoUri);
610
+
611
+ if (!videoUri) {
612
+ const pollDataLog = util.inspect(pollData, { depth: 5, colors: true, maxStringLength: 500 });
613
+ console.warn(`${apiName} video generation error: No video URI found in response`, pollDataLog);
614
+ return response.status(500).send('No video URI found in response');
615
+ }
616
+
617
+ const videoResponse = await fetch(videoUri, {
618
+ method: 'GET',
619
+ headers: headers,
620
+ });
621
+
622
+ if (!videoResponse.ok) {
623
+ console.warn(`${apiName} video fetch error: ${videoResponse.status} ${videoResponse.statusText}`);
624
+ return response.status(500).send('Video fetch request failed');
625
+ }
626
+
627
+ const videoData = await videoResponse.arrayBuffer();
628
+ const videoBase64 = Buffer.from(videoData).toString('base64');
629
+
630
+ return response.send({ video: videoBase64 });
631
+ }
632
+ }
633
+ }
634
+
635
+ console.warn(`${apiName} video generation error: Job timed out after multiple attempts`);
636
+ return response.status(500).send('Video generation timed out');
637
+ } catch (error) {
638
+ console.error('Google Video generation failed:', error);
639
+ return response.sendStatus(500);
640
+ }
641
+ });
src/endpoints/groups.js ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs';
2
+ import { promises as fsPromises } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import express from 'express';
6
+ import sanitize from 'sanitize-filename';
7
+ import { sync as writeFileAtomicSync, default as writeFileAtomic } from 'write-file-atomic';
8
+
9
+ import { color, tryParse } from '../util.js';
10
+ import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
11
+
12
+ export const router = express.Router();
13
+
14
+ /**
15
+ * Warns if group data contains deprecated metadata keys and removes them.
16
+ * @param {object} groupData Group data object
17
+ */
18
+ function warnOnGroupMetadata(groupData) {
19
+ if (typeof groupData !== 'object' || groupData === null) {
20
+ return;
21
+ }
22
+ ['chat_metadata', 'past_metadata'].forEach(key => {
23
+ if (Object.hasOwn(groupData, key)) {
24
+ console.warn(color.yellow(`Group JSON data for "${groupData.id}" contains deprecated key "${key}".`));
25
+ delete groupData[key];
26
+ }
27
+ });
28
+ }
29
+
30
+ /**
31
+ * Migrates group metadata to include chat metadata for each group chat instead of the group itself.
32
+ * @param {import('../users.js').UserDirectoryList[]} userDirectories Listing of all users' directories
33
+ */
34
+ export async function migrateGroupChatsMetadataFormat(userDirectories) {
35
+ for (const userDirs of userDirectories) {
36
+ try {
37
+ let anyDataMigrated = false;
38
+ const backupPath = path.join(userDirs.backups, '_group_metadata_update');
39
+ const groupFiles = await fsPromises.readdir(userDirs.groups, { withFileTypes: true });
40
+ const groupChatFiles = await fsPromises.readdir(userDirs.groupChats, { withFileTypes: true });
41
+ for (const groupFile of groupFiles) {
42
+ try {
43
+ const isJsonFile = groupFile.isFile() && path.extname(groupFile.name) === '.json';
44
+ if (!isJsonFile) {
45
+ continue;
46
+ }
47
+ const groupFilePath = path.join(userDirs.groups, groupFile.name);
48
+ const groupDataRaw = await fsPromises.readFile(groupFilePath, 'utf8');
49
+ const groupData = tryParse(groupDataRaw) || {};
50
+ const needsMigration = ['chat_metadata', 'past_metadata'].some(key => Object.hasOwn(groupData, key));
51
+ if (!needsMigration) {
52
+ continue;
53
+ }
54
+ if (!fs.existsSync(backupPath)){
55
+ await fsPromises.mkdir(backupPath, { recursive: true });
56
+ }
57
+ await fsPromises.copyFile(groupFilePath, path.join(backupPath, groupFile.name));
58
+ const allMetadata = {
59
+ ...(groupData.past_metadata || {}),
60
+ [groupData.chat_id]: (groupData.chat_metadata || {}),
61
+ };
62
+ if (!Array.isArray(groupData.chats)) {
63
+ console.warn(color.yellow(`Group ${groupFile.name} has no chats array, skipping migration.`));
64
+ continue;
65
+ }
66
+ for (const chatId of groupData.chats) {
67
+ try {
68
+ const chatFileName = sanitize(`${chatId}.jsonl`);
69
+ const chatFileDirent = groupChatFiles.find(f => f.isFile() && f.name === chatFileName);
70
+ if (!chatFileDirent) {
71
+ console.warn(color.yellow(`Group chat file ${chatId} not found, skipping migration.`));
72
+ continue;
73
+ }
74
+ const chatFilePath = path.join(userDirs.groupChats, chatFileName);
75
+ const chatMetadata = allMetadata[chatId] || {};
76
+ const chatDataRaw = await fsPromises.readFile(chatFilePath, 'utf8');
77
+ const chatData = chatDataRaw.split('\n').filter(line => line.trim()).map(line => tryParse(line)).filter(Boolean);
78
+ const alreadyHasMetadata = chatData.length > 0 && Object.hasOwn(chatData[0], 'chat_metadata');
79
+ if (alreadyHasMetadata) {
80
+ console.log(color.yellow(`Group chat ${chatId} already has chat metadata, skipping update.`));
81
+ continue;
82
+ }
83
+ await fsPromises.copyFile(chatFilePath, path.join(backupPath, chatFileName));
84
+ const chatHeader = { chat_metadata: chatMetadata, user_name: 'unused', character_name: 'unused' };
85
+ const newChatData = [chatHeader, ...chatData];
86
+ const newChatDataRaw = newChatData.map(entry => JSON.stringify(entry)).join('\n');
87
+ await writeFileAtomic(chatFilePath, newChatDataRaw, 'utf8');
88
+ console.log(`Updated group chat data format for ${chatId}`);
89
+ anyDataMigrated = true;
90
+ } catch (chatError) {
91
+ console.error(color.red(`Could not update existing chat data for ${chatId}`), chatError);
92
+ }
93
+ }
94
+ delete groupData.chat_metadata;
95
+ delete groupData.past_metadata;
96
+ await writeFileAtomic(groupFilePath, JSON.stringify(groupData, null, 4), 'utf8');
97
+ console.log(`Migrated group chats metadata for group: ${groupData.id}`);
98
+ anyDataMigrated = true;
99
+ } catch (groupError) {
100
+ console.error(color.red(`Could not process group file ${groupFile.name}`), groupError);
101
+ }
102
+ }
103
+ if (anyDataMigrated) {
104
+ console.log(color.green(`Completed migration of group chats metadata for user at ${userDirs.root}`));
105
+ console.log(color.cyan(`Backups of modified files are located at ${backupPath}`));
106
+ }
107
+ } catch (directoryError) {
108
+ console.error(color.red(`Error migrating group chats metadata for user at ${userDirs.root}`), directoryError);
109
+ }
110
+ }
111
+ }
112
+
113
+ router.post('/all', (request, response) => {
114
+ const groups = [];
115
+
116
+ if (!fs.existsSync(request.user.directories.groups)) {
117
+ fs.mkdirSync(request.user.directories.groups);
118
+ }
119
+
120
+ const files = fs.readdirSync(request.user.directories.groups).filter(x => path.extname(x) === '.json');
121
+ const chats = fs.readdirSync(request.user.directories.groupChats).filter(x => path.extname(x) === '.jsonl');
122
+
123
+ files.forEach(function (file) {
124
+ try {
125
+ const filePath = path.join(request.user.directories.groups, file);
126
+ const fileContents = fs.readFileSync(filePath, 'utf8');
127
+ const group = JSON.parse(fileContents);
128
+ const groupStat = fs.statSync(filePath);
129
+ group['date_added'] = groupStat.birthtimeMs;
130
+ group['create_date'] = new Date(groupStat.birthtimeMs).toISOString();
131
+
132
+ let chat_size = 0;
133
+ let date_last_chat = 0;
134
+
135
+ if (Array.isArray(group.chats) && Array.isArray(chats)) {
136
+ for (const chat of chats) {
137
+ if (group.chats.includes(path.parse(chat).name)) {
138
+ const chatStat = fs.statSync(path.join(request.user.directories.groupChats, chat));
139
+ chat_size += chatStat.size;
140
+ date_last_chat = Math.max(date_last_chat, chatStat.mtimeMs);
141
+ }
142
+ }
143
+ }
144
+
145
+ group['date_last_chat'] = date_last_chat;
146
+ group['chat_size'] = chat_size;
147
+ groups.push(group);
148
+ }
149
+ catch (error) {
150
+ console.error(error);
151
+ }
152
+ });
153
+
154
+ return response.send(groups);
155
+ });
156
+
157
+ router.post('/create', (request, response) => {
158
+ if (!request.body) {
159
+ return response.sendStatus(400);
160
+ }
161
+
162
+ warnOnGroupMetadata(request.body);
163
+ const id = String(Date.now());
164
+ const groupMetadata = {
165
+ id: id,
166
+ name: request.body.name ?? 'New Group',
167
+ members: request.body.members ?? [],
168
+ avatar_url: request.body.avatar_url,
169
+ allow_self_responses: !!request.body.allow_self_responses,
170
+ activation_strategy: request.body.activation_strategy ?? 0,
171
+ generation_mode: request.body.generation_mode ?? 0,
172
+ disabled_members: request.body.disabled_members ?? [],
173
+ fav: request.body.fav,
174
+ chat_id: request.body.chat_id ?? id,
175
+ chats: request.body.chats ?? [id],
176
+ auto_mode_delay: request.body.auto_mode_delay ?? 5,
177
+ generation_mode_join_prefix: request.body.generation_mode_join_prefix ?? '',
178
+ generation_mode_join_suffix: request.body.generation_mode_join_suffix ?? '',
179
+ };
180
+ const pathToFile = path.join(request.user.directories.groups, sanitize(`${id}.json`));
181
+ const fileData = JSON.stringify(groupMetadata, null, 4);
182
+
183
+ if (!fs.existsSync(request.user.directories.groups)) {
184
+ fs.mkdirSync(request.user.directories.groups);
185
+ }
186
+
187
+ writeFileAtomicSync(pathToFile, fileData);
188
+ return response.send(groupMetadata);
189
+ });
190
+
191
+ router.post('/edit', getFileNameValidationFunction('id'), (request, response) => {
192
+ if (!request.body || !request.body.id) {
193
+ return response.sendStatus(400);
194
+ }
195
+ warnOnGroupMetadata(request.body);
196
+ const id = request.body.id;
197
+ const pathToFile = path.join(request.user.directories.groups, sanitize(`${id}.json`));
198
+ const fileData = JSON.stringify(request.body, null, 4);
199
+
200
+ writeFileAtomicSync(pathToFile, fileData);
201
+ return response.send({ ok: true });
202
+ });
203
+
204
+ router.post('/delete', getFileNameValidationFunction('id'), async (request, response) => {
205
+ if (!request.body || !request.body.id) {
206
+ return response.sendStatus(400);
207
+ }
208
+
209
+ const id = request.body.id;
210
+ const pathToGroup = path.join(request.user.directories.groups, sanitize(`${id}.json`));
211
+
212
+ try {
213
+ // Delete group chats
214
+ const group = JSON.parse(fs.readFileSync(pathToGroup, 'utf8'));
215
+
216
+ if (group && Array.isArray(group.chats)) {
217
+ for (const chat of group.chats) {
218
+ console.info('Deleting group chat', chat);
219
+ const pathToFile = path.join(request.user.directories.groupChats, sanitize(`${chat}.jsonl`));
220
+
221
+ if (fs.existsSync(pathToFile)) {
222
+ fs.unlinkSync(pathToFile);
223
+ }
224
+ }
225
+ }
226
+ } catch (error) {
227
+ console.error('Could not delete group chats. Clean them up manually.', error);
228
+ }
229
+
230
+ if (fs.existsSync(pathToGroup)) {
231
+ fs.unlinkSync(pathToGroup);
232
+ }
233
+
234
+ return response.send({ ok: true });
235
+ });
src/endpoints/horde.js ADDED
@@ -0,0 +1,411 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fetch from 'node-fetch';
2
+ import express from 'express';
3
+ import { AIHorde, ModelGenerationInputStableSamplers, ModelInterrogationFormTypes, HordeAsyncRequestStates } from '@zeldafan0225/ai_horde';
4
+ import { getVersion, delay, Cache } from '../util.js';
5
+ import { readSecret, SECRET_KEYS } from './secrets.js';
6
+
7
+ const ANONYMOUS_KEY = '0000000000';
8
+ const HORDE_TEXT_MODEL_METADATA_URL = 'https://raw.githubusercontent.com/db0/AI-Horde-text-model-reference/main/db.json';
9
+ const cache = new Cache(60 * 1000);
10
+ export 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 || 'TavernIntern:UNKNOWN:Cohee#1207';
19
+ }
20
+
21
+ /**
22
+ * Returns the AIHorde client.
23
+ * @returns {Promise<AIHorde>} AIHorde client
24
+ */
25
+ async function getHordeClient() {
26
+ return new AIHorde({
27
+ client_agent: await getClientAgent(),
28
+ });
29
+ }
30
+
31
+ /**
32
+ * Removes dirty no-no words from the prompt.
33
+ * Taken verbatim from KAI Lite's implementation (AGPLv3).
34
+ * https://github.com/LostRuins/lite.koboldai.net/blob/main/index.html#L7786C2-L7811C1
35
+ * @param {string} prompt Prompt to sanitize
36
+ * @returns {string} Sanitized prompt
37
+ */
38
+ function sanitizeHordeImagePrompt(prompt) {
39
+ if (!prompt) {
40
+ return '';
41
+ }
42
+
43
+ //to avoid flagging from some image models, always swap these words
44
+ prompt = prompt.replace(/\b(girl)\b/gmi, 'woman');
45
+ prompt = prompt.replace(/\b(boy)\b/gmi, 'man');
46
+ prompt = prompt.replace(/\b(girls)\b/gmi, 'women');
47
+ prompt = prompt.replace(/\b(boys)\b/gmi, 'men');
48
+ //always remove these high risk words from prompt, as they add little value to image gen while increasing the risk the prompt gets flagged
49
+ 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, '');
50
+ //replace risky subject nouns with person
51
+ prompt = prompt.replace(/\b(youngster|infant|baby|toddler|child|teen|kid|kiddie|kiddo|teenager|student|preteen|pre.teen)\b/gmi, 'person');
52
+ //remove risky adjectives and related words
53
+ 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, '');
54
+
55
+ return prompt;
56
+ }
57
+
58
+ router.post('/text-workers', async (request, response) => {
59
+ try {
60
+ const cachedWorkers = cache.get('workers');
61
+
62
+ if (cachedWorkers && !request.body.force) {
63
+ return response.send(cachedWorkers);
64
+ }
65
+
66
+ const agent = await getClientAgent();
67
+ const fetchResult = await fetch('https://aihorde.net/api/v2/workers?type=text', {
68
+ headers: {
69
+ 'Client-Agent': agent,
70
+ },
71
+ });
72
+ const data = await fetchResult.json();
73
+ cache.set('workers', data);
74
+ return response.send(data);
75
+ } catch (error) {
76
+ console.error(error);
77
+ response.sendStatus(500);
78
+ }
79
+ });
80
+
81
+ async function getHordeTextModelMetadata() {
82
+ const response = await fetch(HORDE_TEXT_MODEL_METADATA_URL);
83
+ return await response.json();
84
+ }
85
+
86
+ async function mergeModelsAndMetadata(models, metadata) {
87
+ return models.map(model => {
88
+ const metadataModel = metadata[model.name];
89
+ if (!metadataModel) {
90
+ return { ...model, is_whitelisted: false };
91
+ }
92
+ return { ...model, ...metadataModel, is_whitelisted: true };
93
+ });
94
+ }
95
+
96
+ router.post('/text-models', async (request, response) => {
97
+ try {
98
+ const cachedModels = cache.get('models');
99
+ if (cachedModels && !request.body.force) {
100
+ return response.send(cachedModels);
101
+ }
102
+
103
+ const agent = await getClientAgent();
104
+ const fetchResult = await fetch('https://aihorde.net/api/v2/status/models?type=text', {
105
+ headers: {
106
+ 'Client-Agent': agent,
107
+ },
108
+ });
109
+
110
+ let data = await fetchResult.json();
111
+
112
+ // attempt to fetch and merge models metadata
113
+ try {
114
+ const metadata = await getHordeTextModelMetadata();
115
+ data = await mergeModelsAndMetadata(data, metadata);
116
+ }
117
+ catch (error) {
118
+ console.error('Failed to fetch metadata:', error);
119
+ }
120
+
121
+ cache.set('models', data);
122
+ return response.send(data);
123
+ } catch (error) {
124
+ console.error(error);
125
+ response.sendStatus(500);
126
+ }
127
+ });
128
+
129
+ router.post('/status', async (_, response) => {
130
+ try {
131
+ const agent = await getClientAgent();
132
+ const fetchResult = await fetch('https://aihorde.net/api/v2/status/heartbeat', {
133
+ headers: {
134
+ 'Client-Agent': agent,
135
+ },
136
+ });
137
+
138
+ return response.send({ ok: fetchResult.ok });
139
+ } catch (error) {
140
+ console.error(error);
141
+ response.sendStatus(500);
142
+ }
143
+ });
144
+
145
+ router.post('/cancel-task', async (request, response) => {
146
+ try {
147
+ const taskId = request.body.taskId;
148
+ const agent = await getClientAgent();
149
+ const fetchResult = await fetch(`https://aihorde.net/api/v2/generate/text/status/${taskId}`, {
150
+ method: 'DELETE',
151
+ headers: {
152
+ 'Client-Agent': agent,
153
+ },
154
+ });
155
+
156
+ const data = await fetchResult.json();
157
+ console.info(`Cancelled Horde task ${taskId}`);
158
+ return response.send(data);
159
+ } catch (error) {
160
+ console.error(error);
161
+ response.sendStatus(500);
162
+ }
163
+ });
164
+
165
+ router.post('/task-status', async (request, response) => {
166
+ try {
167
+ const taskId = request.body.taskId;
168
+ const agent = await getClientAgent();
169
+ const fetchResult = await fetch(`https://aihorde.net/api/v2/generate/text/status/${taskId}`, {
170
+ headers: {
171
+ 'Client-Agent': agent,
172
+ },
173
+ });
174
+
175
+ const data = await fetchResult.json();
176
+ console.info(`Horde task ${taskId} status:`, data);
177
+ return response.send(data);
178
+ } catch (error) {
179
+ console.error(error);
180
+ response.sendStatus(500);
181
+ }
182
+ });
183
+
184
+ router.post('/generate-text', async (request, response) => {
185
+ const apiKey = readSecret(request.user.directories, SECRET_KEYS.HORDE) || ANONYMOUS_KEY;
186
+ const url = 'https://aihorde.net/api/v2/generate/text/async';
187
+ const agent = await getClientAgent();
188
+
189
+ console.debug(request.body);
190
+ try {
191
+ const result = await fetch(url, {
192
+ method: 'POST',
193
+ body: JSON.stringify(request.body),
194
+ headers: {
195
+ 'Content-Type': 'application/json',
196
+ 'apikey': apiKey,
197
+ 'Client-Agent': agent,
198
+ },
199
+ });
200
+
201
+ if (!result.ok) {
202
+ const message = await result.text();
203
+ console.error('Horde returned an error:', message);
204
+ return response.send({ error: { message } });
205
+ }
206
+
207
+ const data = await result.json();
208
+ return response.send(data);
209
+ } catch (error) {
210
+ console.error(error);
211
+ return response.send({ error: true });
212
+ }
213
+ });
214
+
215
+ router.post('/sd-samplers', async (_, response) => {
216
+ try {
217
+ const samplers = Object.values(ModelGenerationInputStableSamplers);
218
+ response.send(samplers);
219
+ } catch (error) {
220
+ console.error(error);
221
+ response.sendStatus(500);
222
+ }
223
+ });
224
+
225
+ router.post('/sd-models', async (_, response) => {
226
+ try {
227
+ const ai_horde = await getHordeClient();
228
+ const models = await ai_horde.getModels();
229
+ response.send(models);
230
+ } catch (error) {
231
+ console.error(error);
232
+ response.sendStatus(500);
233
+ }
234
+ });
235
+
236
+ router.post('/caption-image', async (request, response) => {
237
+ try {
238
+ const api_key_horde = readSecret(request.user.directories, SECRET_KEYS.HORDE) || ANONYMOUS_KEY;
239
+ const ai_horde = await getHordeClient();
240
+ const result = await ai_horde.postAsyncInterrogate({
241
+ source_image: request.body.image,
242
+ forms: [{ name: ModelInterrogationFormTypes.caption }],
243
+ }, { token: api_key_horde });
244
+
245
+ if (!result.id) {
246
+ console.error('Image interrogation request is not satisfyable:', result.message || 'unknown error');
247
+ return response.sendStatus(400);
248
+ }
249
+
250
+ const MAX_ATTEMPTS = 200;
251
+ const CHECK_INTERVAL = 3000;
252
+
253
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
254
+ await delay(CHECK_INTERVAL);
255
+ const status = await ai_horde.getInterrogationStatus(result.id);
256
+ console.info(status);
257
+
258
+ if (status.state === HordeAsyncRequestStates.done) {
259
+
260
+ if (status.forms === undefined) {
261
+ console.error('Image interrogation request failed: no forms found.');
262
+ return response.sendStatus(500);
263
+ }
264
+
265
+ console.debug('Image interrogation result:', status);
266
+ const caption = status?.forms[0]?.result?.caption || '';
267
+
268
+ if (!caption) {
269
+ console.error('Image interrogation request failed: no caption found.');
270
+ return response.sendStatus(500);
271
+ }
272
+
273
+ return response.send({ caption });
274
+ }
275
+
276
+ if (status.state === HordeAsyncRequestStates.faulted || status.state === HordeAsyncRequestStates.cancelled) {
277
+ console.error('Image interrogation request is not successful.');
278
+ return response.sendStatus(503);
279
+ }
280
+ }
281
+
282
+ } catch (error) {
283
+ console.error(error);
284
+ response.sendStatus(500);
285
+ }
286
+ });
287
+
288
+ router.post('/user-info', async (request, response) => {
289
+ const api_key_horde = readSecret(request.user.directories, SECRET_KEYS.HORDE);
290
+
291
+ if (!api_key_horde) {
292
+ return response.send({ anonymous: true });
293
+ }
294
+
295
+ try {
296
+ const ai_horde = await getHordeClient();
297
+ const sharedKey = await (async () => {
298
+ try {
299
+ return await ai_horde.getSharedKey(api_key_horde);
300
+ } catch {
301
+ return null;
302
+ }
303
+ })();
304
+ const user = await ai_horde.findUser({ token: api_key_horde });
305
+ return response.send({ user, sharedKey, anonymous: false });
306
+ } catch (error) {
307
+ console.error(error);
308
+ return response.sendStatus(500);
309
+ }
310
+ });
311
+
312
+ router.post('/generate-image', async (request, response) => {
313
+ if (!request.body.prompt) {
314
+ return response.sendStatus(400);
315
+ }
316
+
317
+ const MAX_ATTEMPTS = 200;
318
+ const CHECK_INTERVAL = 3000;
319
+ const PROMPT_THRESHOLD = 5000;
320
+
321
+ try {
322
+ const maxLength = PROMPT_THRESHOLD - String(request.body.negative_prompt).length - 5;
323
+ if (String(request.body.prompt).length > maxLength) {
324
+ console.warn('Stable Horde prompt is too long, truncating...');
325
+ request.body.prompt = String(request.body.prompt).substring(0, maxLength);
326
+ }
327
+
328
+ // Sanitize prompt if requested
329
+ if (request.body.sanitize) {
330
+ const sanitized = sanitizeHordeImagePrompt(request.body.prompt);
331
+
332
+ if (request.body.prompt !== sanitized) {
333
+ console.info('Stable Horde prompt was sanitized.');
334
+ }
335
+
336
+ request.body.prompt = sanitized;
337
+ }
338
+
339
+ const api_key_horde = readSecret(request.user.directories, SECRET_KEYS.HORDE) || ANONYMOUS_KEY;
340
+ console.debug('Stable Horde request:', request.body);
341
+
342
+ const ai_horde = await getHordeClient();
343
+ // noinspection JSCheckFunctionSignatures -- see @ts-ignore - use_gfpgan
344
+ const generation = await ai_horde.postAsyncImageGenerate(
345
+ {
346
+ prompt: `${request.body.prompt} ### ${request.body.negative_prompt}`,
347
+ params:
348
+ {
349
+ sampler_name: request.body.sampler,
350
+ hires_fix: request.body.enable_hr,
351
+ // @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
352
+ use_gfpgan: request.body.restore_faces,
353
+ cfg_scale: request.body.scale,
354
+ steps: request.body.steps,
355
+ width: request.body.width,
356
+ height: request.body.height,
357
+ karras: Boolean(request.body.karras),
358
+ clip_skip: request.body.clip_skip,
359
+ seed: request.body.seed >= 0 ? String(request.body.seed) : undefined,
360
+ n: 1,
361
+ },
362
+ r2: false,
363
+ nsfw: request.body.nfsw,
364
+ models: [request.body.model],
365
+ },
366
+ { token: api_key_horde });
367
+
368
+ if (!generation.id) {
369
+ console.warn('Image generation request is not satisfyable:', generation.message || 'unknown error');
370
+ return response.sendStatus(400);
371
+ }
372
+
373
+ console.info('Horde image generation request:', generation);
374
+
375
+ const controller = new AbortController();
376
+ request.socket.removeAllListeners('close');
377
+ request.socket.on('close', function () {
378
+ console.warn('Horde image generation request aborted.');
379
+ controller.abort();
380
+ if (generation.id) ai_horde.deleteImageGenerationRequest(generation.id);
381
+ });
382
+
383
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
384
+ controller.signal.throwIfAborted();
385
+ await delay(CHECK_INTERVAL);
386
+ const check = await ai_horde.getImageGenerationCheck(generation.id);
387
+ console.info(check);
388
+
389
+ if (check.done) {
390
+ const result = await ai_horde.getImageGenerationStatus(generation.id);
391
+ if (result.generations === undefined) return response.sendStatus(500);
392
+ return response.send(result.generations[0].img);
393
+ }
394
+
395
+ /*
396
+ if (!check.is_possible) {
397
+ return response.sendStatus(503);
398
+ }
399
+ */
400
+
401
+ if (check.faulted) {
402
+ return response.sendStatus(500);
403
+ }
404
+ }
405
+
406
+ return response.sendStatus(504);
407
+ } catch (error) {
408
+ console.error(error);
409
+ return response.sendStatus(500);
410
+ }
411
+ });
src/endpoints/images.js ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Buffer } from 'node:buffer';
4
+
5
+ import express from 'express';
6
+ import sanitize from 'sanitize-filename';
7
+
8
+ import { clientRelativePath, removeFileExtension, getImages, isPathUnderParent } from '../util.js';
9
+ import { MEDIA_EXTENSIONS, MEDIA_REQUEST_TYPE } from '../constants.js';
10
+
11
+ /**
12
+ * Ensure the directory for the provided file path exists.
13
+ * If not, it will recursively create the directory.
14
+ *
15
+ * @param {string} filePath - The full path of the file for which the directory should be ensured.
16
+ */
17
+ function ensureDirectoryExistence(filePath) {
18
+ const dirname = path.dirname(filePath);
19
+ if (fs.existsSync(dirname)) {
20
+ return true;
21
+ }
22
+ ensureDirectoryExistence(dirname);
23
+ fs.mkdirSync(dirname);
24
+ }
25
+
26
+ export const router = express.Router();
27
+
28
+ /**
29
+ * Endpoint to handle image uploads.
30
+ * The image should be provided in the request body in base64 format.
31
+ * Optionally, a character name can be provided to save the image in a sub-folder.
32
+ *
33
+ * @route POST /api/images/upload
34
+ * @param {Object} request.body - The request payload.
35
+ * @param {string} request.body.image - The base64 encoded image data.
36
+ * @param {string} [request.body.ch_name] - Optional character name to determine the sub-directory.
37
+ * @returns {Object} response - The response object containing the path where the image was saved.
38
+ */
39
+ router.post('/upload', async (request, response) => {
40
+ try {
41
+ if (!request.body) {
42
+ return response.status(400).send({ error: 'No data provided' });
43
+ }
44
+
45
+ const { image, format } = request.body;
46
+
47
+ if (!image) {
48
+ return response.status(400).send({ error: 'No image data provided' });
49
+ }
50
+
51
+ const validFormat = MEDIA_EXTENSIONS.includes(format);
52
+ if (!validFormat) {
53
+ return response.status(400).send({ error: 'Invalid image format' });
54
+ }
55
+
56
+ // Constructing filename and path
57
+ let filename;
58
+ if (request.body.filename) {
59
+ filename = `${removeFileExtension(request.body.filename)}.${format}`;
60
+ } else {
61
+ filename = `${Date.now()}.${format}`;
62
+ }
63
+
64
+ // if character is defined, save to a sub folder for that character
65
+ let pathToNewFile = path.join(request.user.directories.userImages, sanitize(filename));
66
+ if (request.body.ch_name) {
67
+ pathToNewFile = path.join(request.user.directories.userImages, sanitize(request.body.ch_name), sanitize(filename));
68
+ }
69
+
70
+ ensureDirectoryExistence(pathToNewFile);
71
+ const imageBuffer = Buffer.from(image, 'base64');
72
+ await fs.promises.writeFile(pathToNewFile, new Uint8Array(imageBuffer));
73
+ response.send({ path: clientRelativePath(request.user.directories.root, pathToNewFile) });
74
+ } catch (error) {
75
+ console.error(error);
76
+ response.status(500).send({ error: 'Failed to save the image' });
77
+ }
78
+ });
79
+
80
+ router.post('/list/:folder?', (request, response) => {
81
+ try {
82
+ if (request.params.folder) {
83
+ if (request.body.folder) {
84
+ return response.status(400).send({ error: 'Folder specified in both URL and body' });
85
+ }
86
+
87
+ console.warn('Deprecated: Use POST /api/images/list with folder in request body');
88
+ request.body.folder = request.params.folder;
89
+ }
90
+
91
+ if (!request.body.folder) {
92
+ return response.status(400).send({ error: 'No folder specified' });
93
+ }
94
+
95
+ const directoryPath = path.join(request.user.directories.userImages, sanitize(request.body.folder));
96
+ const type = Number(request.body.type ?? MEDIA_REQUEST_TYPE.IMAGE);
97
+ const sort = request.body.sortField || 'date';
98
+ const order = request.body.sortOrder || 'asc';
99
+
100
+ if (!fs.existsSync(directoryPath)) {
101
+ fs.mkdirSync(directoryPath, { recursive: true });
102
+ }
103
+
104
+ const images = getImages(directoryPath, sort, type);
105
+ if (order === 'desc') {
106
+ images.reverse();
107
+ }
108
+ return response.send(images);
109
+ } catch (error) {
110
+ console.error(error);
111
+ return response.status(500).send({ error: 'Unable to retrieve files' });
112
+ }
113
+ });
114
+
115
+ router.post('/folders', (request, response) => {
116
+ try {
117
+ const directoryPath = request.user.directories.userImages;
118
+ if (!fs.existsSync(directoryPath)) {
119
+ fs.mkdirSync(directoryPath, { recursive: true });
120
+ }
121
+
122
+ const folders = fs.readdirSync(directoryPath, { withFileTypes: true })
123
+ .filter(dirent => dirent.isDirectory())
124
+ .map(dirent => dirent.name);
125
+
126
+ return response.send(folders);
127
+ } catch (error) {
128
+ console.error(error);
129
+ return response.status(500).send({ error: 'Unable to retrieve folders' });
130
+ }
131
+ });
132
+
133
+ router.post('/delete', async (request, response) => {
134
+ try {
135
+ if (!request.body.path) {
136
+ return response.status(400).send('No path specified');
137
+ }
138
+
139
+ const pathToDelete = path.join(request.user.directories.root, request.body.path);
140
+ if (!isPathUnderParent(request.user.directories.userImages, pathToDelete)) {
141
+ return response.status(400).send('Invalid path');
142
+ }
143
+
144
+ if (!fs.existsSync(pathToDelete)) {
145
+ return response.status(404).send('File not found');
146
+ }
147
+
148
+ fs.unlinkSync(pathToDelete);
149
+ console.info(`Deleted image: ${request.body.path} from ${request.user.profile.handle}`);
150
+ return response.sendStatus(200);
151
+ } catch (error) {
152
+ console.error(error);
153
+ return response.sendStatus(500);
154
+ }
155
+ });
src/endpoints/minimax.js ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import express from 'express';
2
+ import fetch from 'node-fetch';
3
+ import { readSecret, SECRET_KEYS } from './secrets.js';
4
+
5
+ export const router = express.Router();
6
+
7
+ // Audio format MIME type mapping
8
+ const getAudioMimeType = (format) => {
9
+ const mimeTypes = {
10
+ 'mp3': 'audio/mpeg',
11
+ 'wav': 'audio/wav',
12
+ 'pcm': 'audio/pcm',
13
+ 'flac': 'audio/flac',
14
+ 'aac': 'audio/aac',
15
+ };
16
+ return mimeTypes[format] || 'audio/mpeg';
17
+ };
18
+
19
+ router.post('/generate-voice', async (request, response) => {
20
+ try {
21
+ const {
22
+ text,
23
+ voiceId,
24
+ apiHost = 'https://api.minimax.io',
25
+ model = 'speech-02-hd',
26
+ speed = 1.0,
27
+ volume = 1.0,
28
+ pitch = 1.0,
29
+ audioSampleRate = 32000,
30
+ bitrate = 128000,
31
+ format = 'mp3',
32
+ language,
33
+ } = request.body;
34
+
35
+ const apiKey = readSecret(request.user.directories, SECRET_KEYS.MINIMAX);
36
+ const groupId = readSecret(request.user.directories, SECRET_KEYS.MINIMAX_GROUP_ID);
37
+
38
+ // Validate required parameters
39
+ if (!text || !voiceId || !apiKey || !groupId) {
40
+ console.warn('MiniMax TTS: Missing required parameters');
41
+ return response.status(400).json({ error: 'Missing required parameters: text, voiceId, apiKey, and groupId are required' });
42
+ }
43
+
44
+ const requestBody = {
45
+ model: model,
46
+ text: text,
47
+ stream: false,
48
+ voice_setting: {
49
+ voice_id: voiceId,
50
+ speed: Number(speed),
51
+ vol: Number(volume),
52
+ pitch: Number(pitch),
53
+ },
54
+ audio_setting: {
55
+ sample_rate: Number(audioSampleRate),
56
+ bitrate: Number(bitrate),
57
+ format: format,
58
+ channel: 1,
59
+ },
60
+ };
61
+
62
+ // Add language parameter if provided
63
+ if (language) {
64
+ requestBody.lang = language;
65
+ }
66
+
67
+ const apiUrl = `${apiHost}/v1/t2a_v2?GroupId=${groupId}`;
68
+
69
+ console.debug('MiniMax TTS Request:', {
70
+ url: apiUrl,
71
+ body: { ...requestBody, voice_setting: { ...requestBody.voice_setting, voice_id: '[REDACTED]' } },
72
+ });
73
+
74
+ const apiResponse = await fetch(apiUrl, {
75
+ method: 'POST',
76
+ headers: {
77
+ 'Authorization': `Bearer ${apiKey}`,
78
+ 'Content-Type': 'application/json',
79
+ 'MM-API-Source': 'TavernIntern-TTS',
80
+ },
81
+ body: JSON.stringify(requestBody),
82
+ });
83
+
84
+ if (!apiResponse.ok) {
85
+ let errorMessage = `HTTP ${apiResponse.status}`;
86
+
87
+ try {
88
+ // Try to parse JSON error response
89
+ /** @type {any} */
90
+ const errorData = await apiResponse.json();
91
+ console.error('MiniMax TTS API error (JSON):', errorData);
92
+
93
+ // Check for MiniMax specific error format
94
+ const baseResp = errorData?.base_resp;
95
+ if (baseResp && baseResp.status_code !== 0) {
96
+ if (baseResp.status_code === 1004) {
97
+ errorMessage = 'Authentication failed - Please check your API key and API host';
98
+ } else {
99
+ errorMessage = `API Error: ${baseResp.status_msg}`;
100
+ }
101
+ } else {
102
+ errorMessage = errorData.error?.message || errorData.message || errorData.detail || `HTTP ${apiResponse.status}`;
103
+ }
104
+ } catch (jsonError) {
105
+ // If not JSON, try to read text
106
+ try {
107
+ const errorText = await apiResponse.text();
108
+ console.error('MiniMax TTS API error (Text):', errorText);
109
+ if (errorText && errorText.length > 500) {
110
+ errorMessage = `HTTP ${apiResponse.status}: Response too large (${errorText.length} characters)`;
111
+ } else {
112
+ errorMessage = errorText || `HTTP ${apiResponse.status}`;
113
+ }
114
+ } catch (textError) {
115
+ console.error('MiniMax TTS: Failed to read error response:', textError);
116
+ errorMessage = `HTTP ${apiResponse.status}: Unable to read error details`;
117
+ }
118
+ }
119
+
120
+ console.error('MiniMax TTS API request failed:', errorMessage);
121
+ return response.status(500).json({ error: errorMessage });
122
+ }
123
+
124
+ // Parse the response
125
+ /** @type {any} */
126
+ let responseData;
127
+ try {
128
+ responseData = await apiResponse.json();
129
+ console.debug('MiniMax TTS Response received');
130
+ } catch (jsonError) {
131
+ console.error('MiniMax TTS: Failed to parse response as JSON:', jsonError);
132
+ return response.status(500).json({ error: 'Invalid response format from MiniMax API' });
133
+ }
134
+
135
+ // Check for API error codes in response data
136
+ const baseResp = responseData?.base_resp;
137
+ if (baseResp && baseResp.status_code !== 0) {
138
+ let errorMessage;
139
+ if (baseResp.status_code === 1004) {
140
+ errorMessage = 'Authentication failed - Please check your API key and API host';
141
+ } else {
142
+ errorMessage = `API Error: ${baseResp.status_msg}`;
143
+ }
144
+ console.error('MiniMax TTS API error:', baseResp);
145
+ return response.status(500).json({ error: errorMessage });
146
+ }
147
+
148
+ // Process the audio data
149
+ if (responseData.data && responseData.data.audio) {
150
+ // Process hex-encoded audio data
151
+ const hexAudio = responseData.data.audio;
152
+
153
+ if (!hexAudio || typeof hexAudio !== 'string') {
154
+ console.error('MiniMax TTS: Invalid audio data format');
155
+ return response.status(500).json({ error: 'Invalid audio data format' });
156
+ }
157
+
158
+ // Remove possible prefix and spaces
159
+ const cleanHex = hexAudio.replace(/^0x/, '').replace(/\s/g, '');
160
+
161
+ // Validate hex string format
162
+ if (!/^[0-9a-fA-F]*$/.test(cleanHex)) {
163
+ console.error('MiniMax TTS: Invalid hex string format');
164
+ return response.status(500).json({ error: 'Invalid audio data format' });
165
+ }
166
+
167
+ // Ensure hex string length is even
168
+ const paddedHex = cleanHex.length % 2 === 0 ? cleanHex : '0' + cleanHex;
169
+
170
+ try {
171
+ // Convert hex string to byte array
172
+ const hexMatches = paddedHex.match(/.{1,2}/g);
173
+ if (!hexMatches) {
174
+ console.error('MiniMax TTS: Failed to parse hex string');
175
+ return response.status(500).json({ error: 'Invalid hex string format' });
176
+ }
177
+ const audioBytes = new Uint8Array(hexMatches.map(byte => parseInt(byte, 16)));
178
+
179
+ if (audioBytes.length === 0) {
180
+ console.error('MiniMax TTS: Audio conversion resulted in empty array');
181
+ return response.status(500).json({ error: 'Audio data conversion failed' });
182
+ }
183
+
184
+ console.debug(`MiniMax TTS: Converted ${paddedHex.length} hex characters to ${audioBytes.length} bytes`);
185
+
186
+ // Set appropriate headers and send audio data
187
+ const mimeType = getAudioMimeType(format);
188
+ response.setHeader('Content-Type', mimeType);
189
+ response.setHeader('Content-Length', audioBytes.length);
190
+
191
+ return response.send(Buffer.from(audioBytes));
192
+
193
+ } catch (conversionError) {
194
+ console.error('MiniMax TTS: Audio conversion error:', conversionError);
195
+ return response.status(500).json({ error: `Audio data conversion failed: ${conversionError.message}` });
196
+ }
197
+ } else if (responseData.data && responseData.data.url) {
198
+ // Handle URL-based audio response
199
+ console.debug('MiniMax TTS: Received audio URL:', responseData.data.url);
200
+
201
+ try {
202
+ const audioResponse = await fetch(responseData.data.url);
203
+ if (!audioResponse.ok) {
204
+ console.error('MiniMax TTS: Failed to fetch audio from URL:', audioResponse.status);
205
+ return response.status(500).json({ error: `Failed to fetch audio from URL: ${audioResponse.status}` });
206
+ }
207
+
208
+ const audioBuffer = await audioResponse.arrayBuffer();
209
+ const mimeType = getAudioMimeType(format);
210
+
211
+ response.setHeader('Content-Type', mimeType);
212
+ response.setHeader('Content-Length', audioBuffer.byteLength);
213
+
214
+ return response.send(Buffer.from(audioBuffer));
215
+ } catch (urlError) {
216
+ console.error('MiniMax TTS: Error fetching audio from URL:', urlError);
217
+ return response.status(500).json({ error: `Failed to fetch audio: ${urlError.message}` });
218
+ }
219
+ } else {
220
+ // Handle error response
221
+ const errorMessage = responseData.base_resp?.status_msg || responseData.error?.message || 'Unknown error';
222
+ console.error('MiniMax TTS: No valid audio data in response:', responseData);
223
+ return response.status(500).json({ error: `API Error: ${errorMessage}` });
224
+ }
225
+
226
+ } catch (error) {
227
+ console.error('MiniMax TTS generation failed:', error);
228
+ return response.status(500).json({ error: 'Internal server error' });
229
+ }
230
+ });
src/endpoints/moving-ui.js ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from 'node:path';
2
+ import express from 'express';
3
+ import sanitize from 'sanitize-filename';
4
+ import { sync as writeFileAtomicSync } from 'write-file-atomic';
5
+
6
+ export const router = express.Router();
7
+
8
+ router.post('/save', (request, response) => {
9
+ if (!request.body || !request.body.name) {
10
+ return response.sendStatus(400);
11
+ }
12
+
13
+ const filename = path.join(request.user.directories.movingUI, sanitize(`${request.body.name}.json`));
14
+ writeFileAtomicSync(filename, JSON.stringify(request.body, null, 4), 'utf8');
15
+
16
+ return response.sendStatus(200);
17
+ });
src/endpoints/novelai.js ADDED
@@ -0,0 +1,484 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import util from 'node:util';
2
+ import { Buffer } from 'node:buffer';
3
+
4
+ import fetch from 'node-fetch';
5
+ import express from 'express';
6
+
7
+ import { readSecret, SECRET_KEYS } from './secrets.js';
8
+ import { readAllChunks, extractFileFromZipBuffer, forwardFetchResponse } from '../util.js';
9
+
10
+ const API_NOVELAI = 'https://api.novelai.net';
11
+ const TEXT_NOVELAI = 'https://text.novelai.net';
12
+ const IMAGE_NOVELAI = 'https://image.novelai.net';
13
+
14
+ // Constants for skip_cfg_above_sigma (Variety+) calculation
15
+ const REFERENCE_PIXEL_COUNT = 1011712; // 832 * 1216 reference image size
16
+ const SIGMA_MAGIC_NUMBER = 19; // Base sigma multiplier for V3 and V4 models
17
+ const SIGMA_MAGIC_NUMBER_V4_5 = 58; // Base sigma multiplier for V4.5 models
18
+
19
+ // Ban bracket generation, plus defaults
20
+ const badWordsList = [
21
+ [3], [49356], [1431], [31715], [34387], [20765], [30702], [10691], [49333], [1266],
22
+ [19438], [43145], [26523], [41471], [2936], [85, 85], [49332], [7286], [1115], [24],
23
+ ];
24
+
25
+ const eratoBadWordsList = [
26
+ [16067], [933, 11144], [25106, 11144], [58, 106901, 16073, 33710, 25, 109933],
27
+ [933, 58, 11144], [128030], [58, 30591, 33503, 17663, 100204, 25, 11144],
28
+ ];
29
+
30
+ const hypeBotBadWordsList = [
31
+ [58], [60], [90], [92], [685], [1391], [1782], [2361], [3693], [4083], [4357], [4895],
32
+ [5512], [5974], [7131], [8183], [8351], [8762], [8964], [8973], [9063], [11208],
33
+ [11709], [11907], [11919], [12878], [12962], [13018], [13412], [14631], [14692],
34
+ [14980], [15090], [15437], [16151], [16410], [16589], [17241], [17414], [17635],
35
+ [17816], [17912], [18083], [18161], [18477], [19629], [19779], [19953], [20520],
36
+ [20598], [20662], [20740], [21476], [21737], [22133], [22241], [22345], [22935],
37
+ [23330], [23785], [23834], [23884], [25295], [25597], [25719], [25787], [25915],
38
+ [26076], [26358], [26398], [26894], [26933], [27007], [27422], [28013], [29164],
39
+ [29225], [29342], [29565], [29795], [30072], [30109], [30138], [30866], [31161],
40
+ [31478], [32092], [32239], [32509], [33116], [33250], [33761], [34171], [34758],
41
+ [34949], [35944], [36338], [36463], [36563], [36786], [36796], [36937], [37250],
42
+ [37913], [37981], [38165], [38362], [38381], [38430], [38892], [39850], [39893],
43
+ [41832], [41888], [42535], [42669], [42785], [42924], [43839], [44438], [44587],
44
+ [44926], [45144], [45297], [46110], [46570], [46581], [46956], [47175], [47182],
45
+ [47527], [47715], [48600], [48683], [48688], [48874], [48999], [49074], [49082],
46
+ [49146], [49946], [10221], [4841], [1427], [2602, 834], [29343], [37405], [35780], [2602], [50256],
47
+ ];
48
+
49
+ // Used for phrase repetition penalty
50
+ const repPenaltyAllowList = [
51
+ [49256, 49264, 49231, 49230, 49287, 85, 49255, 49399, 49262, 336, 333, 432, 363, 468, 492, 745, 401, 426, 623, 794,
52
+ 1096, 2919, 2072, 7379, 1259, 2110, 620, 526, 487, 16562, 603, 805, 761, 2681, 942, 8917, 653, 3513, 506, 5301,
53
+ 562, 5010, 614, 10942, 539, 2976, 462, 5189, 567, 2032, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 588,
54
+ 803, 1040, 49209, 4, 5, 6, 7, 8, 9, 10, 11, 12],
55
+ ];
56
+
57
+ const eratoRepPenWhitelist = [
58
+ 6, 1, 11, 13, 25, 198, 12, 9, 8, 279, 264, 459, 323, 477, 539, 912, 374, 574, 1051, 1550, 1587, 4536, 5828, 15058,
59
+ 3287, 3250, 1461, 1077, 813, 11074, 872, 1202, 1436, 7846, 1288, 13434, 1053, 8434, 617, 9167, 1047, 19117, 706,
60
+ 12775, 649, 4250, 527, 7784, 690, 2834, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 1210, 1359, 608, 220, 596, 956,
61
+ 3077, 44886, 4265, 3358, 2351, 2846, 311, 389, 315, 304, 520, 505, 430,
62
+ ];
63
+
64
+ // Ban the dinkus and asterism
65
+ const logitBiasExp = [
66
+ { 'sequence': [23], 'bias': -0.08, 'ensure_sequence_finish': false, 'generate_once': false },
67
+ { 'sequence': [21], 'bias': -0.08, 'ensure_sequence_finish': false, 'generate_once': false },
68
+ ];
69
+
70
+ const eratoLogitBiasExp = [
71
+ { 'sequence': [12488], 'bias': -0.08, 'ensure_sequence_finish': false, 'generate_once': false },
72
+ { 'sequence': [128041], 'bias': -0.08, 'ensure_sequence_finish': false, 'generate_once': false },
73
+ ];
74
+
75
+ function getBadWordsList(model) {
76
+ let list = [];
77
+
78
+ if (model.includes('hypebot')) {
79
+ list = hypeBotBadWordsList;
80
+ }
81
+
82
+ if (model.includes('clio') || model.includes('kayra')) {
83
+ list = badWordsList;
84
+ }
85
+
86
+ if (model.includes('erato')) {
87
+ list = eratoBadWordsList;
88
+ }
89
+
90
+ // Clone the list so we don't modify the original
91
+ return list.slice();
92
+ }
93
+
94
+ function getLogitBiasList(model) {
95
+ let list = [];
96
+
97
+ if (model.includes('erato')) {
98
+ list = eratoLogitBiasExp;
99
+ }
100
+
101
+ if (model.includes('clio') || model.includes('kayra')) {
102
+ list = logitBiasExp;
103
+ }
104
+
105
+ return list.slice();
106
+ }
107
+
108
+ function getRepPenaltyWhitelist(model) {
109
+ if (model.includes('clio') || model.includes('kayra')) {
110
+ return repPenaltyAllowList.flat();
111
+ }
112
+
113
+ if (model.includes('erato')) {
114
+ return eratoRepPenWhitelist.flat();
115
+ }
116
+
117
+ return null;
118
+ }
119
+
120
+ function calculateSkipCfgAboveSigma(width, height, modelName) {
121
+ const magicConstant = modelName?.includes('nai-diffusion-4-5')
122
+ ? SIGMA_MAGIC_NUMBER_V4_5
123
+ : SIGMA_MAGIC_NUMBER;
124
+
125
+ const pixelCount = width * height;
126
+ const ratio = pixelCount / REFERENCE_PIXEL_COUNT;
127
+
128
+ return Math.pow(ratio, 0.5) * magicConstant;
129
+ }
130
+
131
+ export const router = express.Router();
132
+
133
+ router.post('/status', async function (req, res) {
134
+ if (!req.body) return res.sendStatus(400);
135
+ const api_key_novel = readSecret(req.user.directories, SECRET_KEYS.NOVEL);
136
+
137
+ if (!api_key_novel) {
138
+ console.warn('NovelAI Access Token is missing.');
139
+ return res.sendStatus(400);
140
+ }
141
+
142
+ try {
143
+ const response = await fetch(API_NOVELAI + '/user/subscription', {
144
+ method: 'GET',
145
+ headers: {
146
+ 'Content-Type': 'application/json',
147
+ 'Authorization': 'Bearer ' + api_key_novel,
148
+ },
149
+ });
150
+
151
+ if (response.ok) {
152
+ const data = await response.json();
153
+ return res.send(data);
154
+ } else if (response.status == 401) {
155
+ console.error('NovelAI Access Token is incorrect.');
156
+ return res.send({ error: true });
157
+ }
158
+ else {
159
+ console.warn('NovelAI returned an error:', response.statusText);
160
+ return res.send({ error: true });
161
+ }
162
+ } catch (error) {
163
+ console.error(error);
164
+ return res.send({ error: true });
165
+ }
166
+ });
167
+
168
+ router.post('/generate', async function (req, res) {
169
+ if (!req.body) return res.sendStatus(400);
170
+
171
+ const api_key_novel = readSecret(req.user.directories, SECRET_KEYS.NOVEL);
172
+
173
+ if (!api_key_novel) {
174
+ console.warn('NovelAI Access Token is missing.');
175
+ return res.sendStatus(400);
176
+ }
177
+
178
+ const controller = new AbortController();
179
+ req.socket.removeAllListeners('close');
180
+ req.socket.on('close', function () {
181
+ controller.abort();
182
+ });
183
+
184
+ // Add customized bad words for Clio, Kayra, and Erato
185
+ const badWordsList = getBadWordsList(req.body.model);
186
+
187
+ if (Array.isArray(badWordsList) && Array.isArray(req.body.bad_words_ids)) {
188
+ for (const badWord of req.body.bad_words_ids) {
189
+ if (Array.isArray(badWord) && badWord.every(x => Number.isInteger(x))) {
190
+ badWordsList.push(badWord);
191
+ }
192
+ }
193
+ }
194
+
195
+ // Remove empty arrays from bad words list
196
+ for (const badWord of badWordsList) {
197
+ if (badWord.length === 0) {
198
+ badWordsList.splice(badWordsList.indexOf(badWord), 1);
199
+ }
200
+ }
201
+
202
+ // Add default biases for dinkus and asterism
203
+ const logitBiasList = getLogitBiasList(req.body.model);
204
+
205
+ if (Array.isArray(logitBiasList) && Array.isArray(req.body.logit_bias_exp)) {
206
+ logitBiasList.push(...req.body.logit_bias_exp);
207
+ }
208
+
209
+ const repPenWhitelist = getRepPenaltyWhitelist(req.body.model);
210
+
211
+ const data = {
212
+ 'input': req.body.input,
213
+ 'model': req.body.model,
214
+ 'parameters': {
215
+ 'use_string': req.body.use_string ?? true,
216
+ 'temperature': req.body.temperature,
217
+ 'max_length': req.body.max_length,
218
+ 'min_length': req.body.min_length,
219
+ 'tail_free_sampling': req.body.tail_free_sampling,
220
+ 'repetition_penalty': req.body.repetition_penalty,
221
+ 'repetition_penalty_range': req.body.repetition_penalty_range,
222
+ 'repetition_penalty_slope': req.body.repetition_penalty_slope,
223
+ 'repetition_penalty_frequency': req.body.repetition_penalty_frequency,
224
+ 'repetition_penalty_presence': req.body.repetition_penalty_presence,
225
+ 'repetition_penalty_whitelist': repPenWhitelist,
226
+ 'top_a': req.body.top_a,
227
+ 'top_p': req.body.top_p,
228
+ 'top_k': req.body.top_k,
229
+ 'typical_p': req.body.typical_p,
230
+ 'mirostat_lr': req.body.mirostat_lr,
231
+ 'mirostat_tau': req.body.mirostat_tau,
232
+ 'phrase_rep_pen': req.body.phrase_rep_pen,
233
+ 'stop_sequences': req.body.stop_sequences,
234
+ 'bad_words_ids': badWordsList.length ? badWordsList : null,
235
+ 'logit_bias_exp': logitBiasList,
236
+ 'generate_until_sentence': req.body.generate_until_sentence,
237
+ 'use_cache': req.body.use_cache,
238
+ 'return_full_text': req.body.return_full_text,
239
+ 'prefix': req.body.prefix,
240
+ 'order': req.body.order,
241
+ 'num_logprobs': req.body.num_logprobs,
242
+ 'min_p': req.body.min_p,
243
+ 'math1_temp': req.body.math1_temp,
244
+ 'math1_quad': req.body.math1_quad,
245
+ 'math1_quad_entropy_scale': req.body.math1_quad_entropy_scale,
246
+ },
247
+ };
248
+
249
+ // Tells the model to stop generation at '>'
250
+ if ('theme_textadventure' === req.body.prefix) {
251
+ if (req.body.model.includes('clio') || req.body.model.includes('kayra')) {
252
+ data.parameters.eos_token_id = 49405;
253
+ }
254
+ if (req.body.model.includes('erato')) {
255
+ data.parameters.eos_token_id = 29;
256
+ }
257
+ }
258
+
259
+ console.debug(util.inspect(data, { depth: 4 }));
260
+
261
+ const args = {
262
+ body: JSON.stringify(data),
263
+ headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + api_key_novel },
264
+ signal: controller.signal,
265
+ };
266
+
267
+ try {
268
+ const baseURL = (req.body.model.includes('kayra') || req.body.model.includes('erato')) ? TEXT_NOVELAI : API_NOVELAI;
269
+ const url = req.body.streaming ? `${baseURL}/ai/generate-stream` : `${baseURL}/ai/generate`;
270
+ const response = await fetch(url, { method: 'POST', ...args });
271
+
272
+ if (req.body.streaming) {
273
+ // Pipe remote SSE stream to Express response
274
+ forwardFetchResponse(response, res);
275
+ } else {
276
+ if (!response.ok) {
277
+ const text = await response.text();
278
+ let message = text;
279
+ console.warn(`Novel API returned error: ${response.status} ${response.statusText} ${text}`);
280
+
281
+ try {
282
+ const data = JSON.parse(text);
283
+ message = data.message;
284
+ }
285
+ catch {
286
+ // ignore
287
+ }
288
+
289
+ return res.status(500).send({ error: { message } });
290
+ }
291
+
292
+ /** @type {any} */
293
+ const data = await response.json();
294
+ console.info('NovelAI Output', data?.output);
295
+ return res.send(data);
296
+ }
297
+ } catch (error) {
298
+ return res.send({ error: true });
299
+ }
300
+ });
301
+
302
+ router.post('/generate-image', async (request, response) => {
303
+ if (!request.body) {
304
+ return response.sendStatus(400);
305
+ }
306
+
307
+ const key = readSecret(request.user.directories, SECRET_KEYS.NOVEL);
308
+
309
+ if (!key) {
310
+ console.warn('NovelAI Access Token is missing.');
311
+ return response.sendStatus(400);
312
+ }
313
+
314
+ try {
315
+ console.debug('NAI Diffusion request:', request.body);
316
+ const generateUrl = `${IMAGE_NOVELAI}/ai/generate-image`;
317
+ const generateResult = await fetch(generateUrl, {
318
+ method: 'POST',
319
+ headers: {
320
+ 'Authorization': `Bearer ${key}`,
321
+ 'Content-Type': 'application/json',
322
+ },
323
+ body: JSON.stringify({
324
+ action: 'generate',
325
+ input: request.body.prompt ?? '',
326
+ model: request.body.model ?? 'nai-diffusion',
327
+ parameters: {
328
+ params_version: 3,
329
+ prefer_brownian: true,
330
+ negative_prompt: request.body.negative_prompt ?? '',
331
+ height: request.body.height ?? 512,
332
+ width: request.body.width ?? 512,
333
+ scale: request.body.scale ?? 9,
334
+ seed: request.body.seed >= 0 ? request.body.seed : Math.floor(Math.random() * 9999999999),
335
+ sampler: request.body.sampler ?? 'k_dpmpp_2m',
336
+ noise_schedule: request.body.scheduler ?? 'karras',
337
+ steps: request.body.steps ?? 28,
338
+ n_samples: 1,
339
+ // NAI handholding for prompts
340
+ ucPreset: 0,
341
+ qualityToggle: false,
342
+ add_original_image: false,
343
+ controlnet_strength: 1,
344
+ deliberate_euler_ancestral_bug: false,
345
+ dynamic_thresholding: request.body.decrisper ?? false,
346
+ legacy: false,
347
+ legacy_v3_extend: false,
348
+ sm: request.body.sm ?? false,
349
+ sm_dyn: request.body.sm_dyn ?? false,
350
+ uncond_scale: 1,
351
+ skip_cfg_above_sigma: request.body.variety_boost
352
+ ? calculateSkipCfgAboveSigma(
353
+ request.body.width ?? 512,
354
+ request.body.height ?? 512,
355
+ request.body.model ?? 'nai-diffusion',
356
+ )
357
+ : null,
358
+ use_coords: false,
359
+ characterPrompts: [],
360
+ reference_image_multiple: [],
361
+ reference_information_extracted_multiple: [],
362
+ reference_strength_multiple: [],
363
+ v4_negative_prompt: {
364
+ caption: {
365
+ base_caption: request.body.negative_prompt ?? '',
366
+ char_captions: [],
367
+ },
368
+ },
369
+ v4_prompt: {
370
+ caption: {
371
+ base_caption: request.body.prompt ?? '',
372
+ char_captions: [],
373
+ },
374
+ use_coords: false,
375
+ use_order: true,
376
+ },
377
+ },
378
+ }),
379
+ });
380
+
381
+ if (!generateResult.ok) {
382
+ const text = await generateResult.text();
383
+ console.warn('NovelAI returned an error.', generateResult.statusText, text);
384
+ return response.sendStatus(500);
385
+ }
386
+
387
+ const archiveBuffer = await generateResult.arrayBuffer();
388
+ const imageBuffer = await extractFileFromZipBuffer(archiveBuffer, '.png');
389
+
390
+ if (!imageBuffer) {
391
+ console.error('NovelAI generated an image, but the PNG file was not found.');
392
+ return response.sendStatus(500);
393
+ }
394
+
395
+ const originalBase64 = imageBuffer.toString('base64');
396
+
397
+ // No upscaling
398
+ if (isNaN(request.body.upscale_ratio) || request.body.upscale_ratio <= 1) {
399
+ return response.send(originalBase64);
400
+ }
401
+
402
+ try {
403
+ console.info('Upscaling image...');
404
+ const upscaleUrl = `${API_NOVELAI}/ai/upscale`;
405
+ const upscaleResult = await fetch(upscaleUrl, {
406
+ method: 'POST',
407
+ headers: {
408
+ 'Authorization': `Bearer ${key}`,
409
+ 'Content-Type': 'application/json',
410
+ },
411
+ body: JSON.stringify({
412
+ image: originalBase64,
413
+ height: request.body.height,
414
+ width: request.body.width,
415
+ scale: request.body.upscale_ratio,
416
+ }),
417
+ });
418
+
419
+ if (!upscaleResult.ok) {
420
+ const text = await upscaleResult.text();
421
+ throw new Error('NovelAI returned an error.', { cause: text });
422
+ }
423
+
424
+ const upscaledArchiveBuffer = await upscaleResult.arrayBuffer();
425
+ const upscaledImageBuffer = await extractFileFromZipBuffer(upscaledArchiveBuffer, '.png');
426
+
427
+ if (!upscaledImageBuffer) {
428
+ throw new Error('NovelAI upscaled an image, but the PNG file was not found.');
429
+ }
430
+
431
+ const upscaledBase64 = upscaledImageBuffer.toString('base64');
432
+
433
+ return response.send(upscaledBase64);
434
+ } catch (error) {
435
+ console.warn('NovelAI generated an image, but upscaling failed. Returning original image.', error);
436
+ return response.send(originalBase64);
437
+ }
438
+ } catch (error) {
439
+ console.error(error);
440
+ return response.sendStatus(500);
441
+ }
442
+ });
443
+
444
+ router.post('/generate-voice', async (request, response) => {
445
+ const token = readSecret(request.user.directories, SECRET_KEYS.NOVEL);
446
+
447
+ if (!token) {
448
+ console.error('NovelAI Access Token is missing.');
449
+ return response.sendStatus(400);
450
+ }
451
+
452
+ const text = request.body.text;
453
+ const voice = request.body.voice;
454
+
455
+ if (!text || !voice) {
456
+ return response.sendStatus(400);
457
+ }
458
+
459
+ try {
460
+ const url = `${API_NOVELAI}/ai/generate-voice?text=${encodeURIComponent(text)}&voice=-1&seed=${encodeURIComponent(voice)}&opus=false&version=v2`;
461
+ const result = await fetch(url, {
462
+ method: 'GET',
463
+ headers: {
464
+ 'Authorization': `Bearer ${token}`,
465
+ 'Accept': 'audio/mpeg',
466
+ },
467
+ });
468
+
469
+ if (!result.ok) {
470
+ const errorText = await result.text();
471
+ console.error('NovelAI returned an error.', result.statusText, errorText);
472
+ return response.sendStatus(500);
473
+ }
474
+
475
+ const chunks = await readAllChunks(result.body);
476
+ const buffer = Buffer.concat(chunks.map(chunk => new Uint8Array(chunk)));
477
+ response.setHeader('Content-Type', 'audio/mpeg');
478
+ return response.send(buffer);
479
+ }
480
+ catch (error) {
481
+ console.error(error);
482
+ return response.sendStatus(500);
483
+ }
484
+ });
src/endpoints/openai.js ADDED
@@ -0,0 +1,799 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs';
2
+ import { Buffer } from 'node:buffer';
3
+
4
+ import fetch from 'node-fetch';
5
+ import FormData from 'form-data';
6
+ import express from 'express';
7
+
8
+ import { getConfigValue, mergeObjectWithYaml, excludeKeysByYaml, trimV1, delay } from '../util.js';
9
+ import { setAdditionalHeaders } from '../additional-headers.js';
10
+ import { readSecret, SECRET_KEYS } from './secrets.js';
11
+ import { AIMLAPI_HEADERS, OPENROUTER_HEADERS, ZAI_ENDPOINT } from '../constants.js';
12
+
13
+ export const router = express.Router();
14
+
15
+ router.post('/caption-image', async (request, response) => {
16
+ try {
17
+ let key = '';
18
+ let headers = {};
19
+ let bodyParams = {};
20
+
21
+ if (request.body.api === 'openai' && !request.body.reverse_proxy) {
22
+ key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);
23
+ }
24
+
25
+ if (request.body.api === 'xai' && !request.body.reverse_proxy) {
26
+ key = readSecret(request.user.directories, SECRET_KEYS.XAI);
27
+ }
28
+
29
+ if (request.body.api === 'mistral' && !request.body.reverse_proxy) {
30
+ key = readSecret(request.user.directories, SECRET_KEYS.MISTRALAI);
31
+ }
32
+
33
+ if (request.body.reverse_proxy && request.body.proxy_password) {
34
+ key = request.body.proxy_password;
35
+ }
36
+
37
+ if (request.body.api === 'custom') {
38
+ key = readSecret(request.user.directories, SECRET_KEYS.CUSTOM);
39
+ mergeObjectWithYaml(bodyParams, request.body.custom_include_body);
40
+ mergeObjectWithYaml(headers, request.body.custom_include_headers);
41
+ }
42
+
43
+ if (request.body.api === 'openrouter') {
44
+ key = readSecret(request.user.directories, SECRET_KEYS.OPENROUTER);
45
+ }
46
+
47
+ if (request.body.api === 'ooba') {
48
+ key = readSecret(request.user.directories, SECRET_KEYS.OOBA);
49
+ bodyParams.temperature = 0.1;
50
+ }
51
+
52
+ if (request.body.api === 'koboldcpp') {
53
+ key = readSecret(request.user.directories, SECRET_KEYS.KOBOLDCPP);
54
+ }
55
+
56
+ if (request.body.api === 'llamacpp') {
57
+ key = readSecret(request.user.directories, SECRET_KEYS.LLAMACPP);
58
+ }
59
+
60
+ if (request.body.api === 'vllm') {
61
+ key = readSecret(request.user.directories, SECRET_KEYS.VLLM);
62
+ }
63
+
64
+ if (request.body.api === 'aimlapi') {
65
+ key = readSecret(request.user.directories, SECRET_KEYS.AIMLAPI);
66
+ }
67
+
68
+ if (request.body.api === 'groq') {
69
+ key = readSecret(request.user.directories, SECRET_KEYS.GROQ);
70
+ }
71
+
72
+ if (request.body.api === 'cohere') {
73
+ key = readSecret(request.user.directories, SECRET_KEYS.COHERE);
74
+ }
75
+
76
+ if (request.body.api === 'moonshot') {
77
+ key = readSecret(request.user.directories, SECRET_KEYS.MOONSHOT);
78
+ }
79
+
80
+ if (request.body.api === 'nanogpt') {
81
+ key = readSecret(request.user.directories, SECRET_KEYS.NANOGPT);
82
+ }
83
+
84
+ if (request.body.api === 'chutes') {
85
+ key = readSecret(request.user.directories, SECRET_KEYS.CHUTES);
86
+ }
87
+
88
+ if (request.body.api === 'electronhub') {
89
+ key = readSecret(request.user.directories, SECRET_KEYS.ELECTRONHUB);
90
+ }
91
+
92
+ if (request.body.api === 'zai') {
93
+ key = readSecret(request.user.directories, SECRET_KEYS.ZAI);
94
+ bodyParams.max_tokens = 4096; // default is 1024
95
+ }
96
+
97
+ const noKeyTypes = ['custom', 'ooba', 'koboldcpp', 'vllm', 'llamacpp', 'pollinations'];
98
+ if (!key && !request.body.reverse_proxy && !noKeyTypes.includes(request.body.api)) {
99
+ console.warn('No key found for API', request.body.api);
100
+ return response.sendStatus(400);
101
+ }
102
+
103
+ const body = {
104
+ model: request.body.model,
105
+ messages: [
106
+ {
107
+ role: 'user',
108
+ content: [
109
+ { type: 'text', text: request.body.prompt },
110
+ { type: 'image_url', image_url: { 'url': request.body.image } },
111
+ ],
112
+ },
113
+ ],
114
+ ...bodyParams,
115
+ };
116
+
117
+ const captionSystemPrompt = getConfigValue('openai.captionSystemPrompt');
118
+ if (captionSystemPrompt) {
119
+ body.messages.unshift({
120
+ role: 'system',
121
+ content: captionSystemPrompt,
122
+ });
123
+ }
124
+
125
+ if (request.body.api === 'custom') {
126
+ excludeKeysByYaml(body, request.body.custom_exclude_body);
127
+ }
128
+
129
+ let apiUrl = '';
130
+
131
+ if (request.body.api === 'openrouter') {
132
+ apiUrl = 'https://openrouter.ai/api/v1/chat/completions';
133
+ Object.assign(headers, OPENROUTER_HEADERS);
134
+ }
135
+
136
+ if (request.body.api === 'openai') {
137
+ apiUrl = 'https://api.openai.com/v1/chat/completions';
138
+ }
139
+
140
+ if (request.body.reverse_proxy) {
141
+ apiUrl = `${request.body.reverse_proxy}/chat/completions`;
142
+ }
143
+
144
+ if (request.body.api === 'custom') {
145
+ apiUrl = `${request.body.server_url}/chat/completions`;
146
+ }
147
+
148
+ if (request.body.api === 'aimlapi') {
149
+ apiUrl = 'https://api.aimlapi.com/v1/chat/completions';
150
+ Object.assign(headers, AIMLAPI_HEADERS);
151
+ }
152
+
153
+ if (request.body.api === 'groq') {
154
+ apiUrl = 'https://api.groq.com/openai/v1/chat/completions';
155
+ if (body.messages?.[0]?.role === 'system') {
156
+ body.messages[0].role = 'user';
157
+ }
158
+ }
159
+
160
+ if (request.body.api === 'mistral') {
161
+ apiUrl = 'https://api.mistral.ai/v1/chat/completions';
162
+ }
163
+
164
+ if (request.body.api === 'cohere') {
165
+ apiUrl = 'https://api.cohere.ai/v2/chat';
166
+ }
167
+
168
+ if (request.body.api === 'xai') {
169
+ apiUrl = 'https://api.x.ai/v1/chat/completions';
170
+ }
171
+
172
+ if (request.body.api === 'pollinations') {
173
+ headers = { Authorization: '' };
174
+ apiUrl = 'https://text.pollinations.ai/openai/chat/completions';
175
+ }
176
+
177
+ if (request.body.api === 'moonshot') {
178
+ apiUrl = 'https://api.moonshot.ai/v1/chat/completions';
179
+ }
180
+
181
+ if (request.body.api === 'nanogpt') {
182
+ apiUrl = 'https://nano-gpt.com/api/v1/chat/completions';
183
+ }
184
+
185
+ if (request.body.api === 'chutes') {
186
+ apiUrl = 'https://llm.chutes.ai/v1/chat/completions';
187
+ }
188
+
189
+ if (request.body.api === 'electronhub') {
190
+ apiUrl = 'https://api.electronhub.ai/v1/chat/completions';
191
+ }
192
+
193
+ if (request.body.api === 'zai') {
194
+ apiUrl = request.body.zai_endpoint === ZAI_ENDPOINT.CODING
195
+ ? 'https://api.z.ai/api/coding/paas/v4/chat/completions'
196
+ : 'https://api.z.ai/api/paas/v4/chat/completions';
197
+
198
+ // Handle video inlining for Z.AI
199
+ if (/data:video\/\w+;base64,/.test(request.body.image)) {
200
+ const message = body.messages.find(msg => Array.isArray(msg.content));
201
+ if (message) {
202
+ const imgContent = message.content.find(c => c.type === 'image_url');
203
+ if (imgContent) {
204
+ imgContent.type = 'video_url';
205
+ imgContent.video_url = imgContent.image_url;
206
+ delete imgContent.image_url;
207
+ }
208
+ }
209
+ }
210
+ }
211
+
212
+ if (['koboldcpp', 'vllm', 'llamacpp', 'ooba'].includes(request.body.api)) {
213
+ apiUrl = `${trimV1(request.body.server_url)}/v1/chat/completions`;
214
+ }
215
+
216
+ if (request.body.api === 'ooba') {
217
+ const imgMessage = body.messages.pop();
218
+ body.messages.push({
219
+ role: 'user',
220
+ content: imgMessage?.content?.[0]?.text,
221
+ });
222
+ body.messages.push({
223
+ role: 'user',
224
+ content: [],
225
+ image_url: imgMessage?.content?.[1]?.image_url?.url,
226
+ });
227
+ }
228
+
229
+ setAdditionalHeaders(request, { headers }, apiUrl);
230
+ console.debug('Multimodal captioning request', body);
231
+
232
+ const result = await fetch(apiUrl, {
233
+ method: 'POST',
234
+ headers: {
235
+ 'Content-Type': 'application/json',
236
+ Authorization: `Bearer ${key}`,
237
+ ...headers,
238
+ },
239
+ body: JSON.stringify(body),
240
+ });
241
+
242
+ if (!result.ok) {
243
+ const text = await result.text();
244
+ console.warn('Multimodal captioning request failed', result.statusText, text);
245
+ return response.status(500).send(text);
246
+ }
247
+
248
+ /** @type {any} */
249
+ const data = await result.json();
250
+ console.info('Multimodal captioning response', data);
251
+ const caption = data?.choices?.[0]?.message?.content ?? data?.message?.content?.[0]?.text;
252
+
253
+ if (!caption) {
254
+ return response.status(500).send('No caption found');
255
+ }
256
+
257
+ return response.json({ caption });
258
+ }
259
+ catch (error) {
260
+ console.error(error);
261
+ response.status(500).send('Internal server error');
262
+ }
263
+ });
264
+
265
+ router.post('/generate-voice', async (request, response) => {
266
+ try {
267
+ const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);
268
+
269
+ if (!key) {
270
+ console.warn('No OpenAI key found');
271
+ return response.sendStatus(400);
272
+ }
273
+
274
+ const requestBody = {
275
+ input: request.body.text,
276
+ response_format: 'mp3',
277
+ voice: request.body.voice ?? 'alloy',
278
+ speed: request.body.speed ?? 1,
279
+ model: request.body.model ?? 'tts-1',
280
+ };
281
+
282
+ if (request.body.instructions) {
283
+ requestBody.instructions = request.body.instructions;
284
+ }
285
+
286
+ console.debug('OpenAI TTS request', requestBody);
287
+
288
+ const result = await fetch('https://api.openai.com/v1/audio/speech', {
289
+ method: 'POST',
290
+ headers: {
291
+ 'Content-Type': 'application/json',
292
+ Authorization: `Bearer ${key}`,
293
+ },
294
+ body: JSON.stringify(requestBody),
295
+ });
296
+
297
+ if (!result.ok) {
298
+ const text = await result.text();
299
+ console.warn('OpenAI request failed', result.statusText, text);
300
+ return response.status(500).send(text);
301
+ }
302
+
303
+ const buffer = await result.arrayBuffer();
304
+ response.setHeader('Content-Type', 'audio/mpeg');
305
+ return response.send(Buffer.from(buffer));
306
+ } catch (error) {
307
+ console.error('OpenAI TTS generation failed', error);
308
+ response.status(500).send('Internal server error');
309
+ }
310
+ });
311
+
312
+ // ElectronHub TTS proxy
313
+ router.post('/electronhub/generate-voice', async (request, response) => {
314
+ try {
315
+ const key = readSecret(request.user.directories, SECRET_KEYS.ELECTRONHUB);
316
+
317
+ if (!key) {
318
+ console.warn('No ElectronHub key found');
319
+ return response.sendStatus(400);
320
+ }
321
+
322
+ const requestBody = {
323
+ input: request.body.input,
324
+ voice: request.body.voice,
325
+ speed: request.body.speed ?? 1,
326
+ temperature: request.body.temperature ?? undefined,
327
+ model: request.body.model || 'tts-1',
328
+ response_format: 'mp3',
329
+ };
330
+
331
+ // Optional provider-specific params
332
+ if (request.body.instructions) requestBody.instructions = request.body.instructions;
333
+ if (request.body.speaker_transcript) requestBody.speaker_transcript = request.body.speaker_transcript;
334
+ if (Number.isFinite(request.body.cfg_scale)) requestBody.cfg_scale = Number(request.body.cfg_scale);
335
+ if (Number.isFinite(request.body.cfg_filter_top_k)) requestBody.cfg_filter_top_k = Number(request.body.cfg_filter_top_k);
336
+ if (Number.isFinite(request.body.speech_rate)) requestBody.speech_rate = Number(request.body.speech_rate);
337
+ if (Number.isFinite(request.body.pitch_adjustment)) requestBody.pitch_adjustment = Number(request.body.pitch_adjustment);
338
+ if (request.body.emotional_style) requestBody.emotional_style = request.body.emotional_style;
339
+
340
+ // Handle dynamic parameters sent from the frontend
341
+ const knownParams = new Set(Object.keys(requestBody));
342
+ for (const key in request.body) {
343
+ if (!knownParams.has(key) && request.body[key] !== undefined) {
344
+ requestBody[key] = request.body[key];
345
+ }
346
+ }
347
+
348
+ // Clean undefineds
349
+ Object.keys(requestBody).forEach(k => requestBody[k] === undefined && delete requestBody[k]);
350
+
351
+ console.debug('ElectronHub TTS request', requestBody);
352
+
353
+ const result = await fetch('https://api.electronhub.ai/v1/audio/speech', {
354
+ method: 'POST',
355
+ headers: {
356
+ 'Content-Type': 'application/json',
357
+ Authorization: `Bearer ${key}`,
358
+ },
359
+ body: JSON.stringify(requestBody),
360
+ });
361
+
362
+ if (!result.ok) {
363
+ const text = await result.text();
364
+ console.warn('ElectronHub TTS request failed', result.statusText, text);
365
+ return response.status(500).send(text);
366
+ }
367
+
368
+ const contentType = result.headers.get('content-type') || 'audio/mpeg';
369
+ const buffer = await result.arrayBuffer();
370
+ response.setHeader('Content-Type', contentType);
371
+ return response.send(Buffer.from(buffer));
372
+ } catch (error) {
373
+ console.error('ElectronHub TTS generation failed', error);
374
+ response.status(500).send('Internal server error');
375
+ }
376
+ });
377
+
378
+ // ElectronHub model list
379
+ router.post('/electronhub/models', async (request, response) => {
380
+ try {
381
+ const key = readSecret(request.user.directories, SECRET_KEYS.ELECTRONHUB);
382
+
383
+ if (!key) {
384
+ console.warn('No ElectronHub key found');
385
+ return response.sendStatus(400);
386
+ }
387
+
388
+ const result = await fetch('https://api.electronhub.ai/v1/models', {
389
+ method: 'GET',
390
+ headers: {
391
+ Authorization: `Bearer ${key}`,
392
+ },
393
+ });
394
+
395
+ if (!result.ok) {
396
+ const text = await result.text();
397
+ console.warn('ElectronHub models request failed', result.statusText, text);
398
+ return response.status(500).send(text);
399
+ }
400
+
401
+ const data = await result.json();
402
+ const models = data && Array.isArray(data['data']) ? data['data'] : [];
403
+ return response.json(models);
404
+ } catch (error) {
405
+ console.error('ElectronHub models fetch failed', error);
406
+ response.status(500).send('Internal server error');
407
+ }
408
+ });
409
+
410
+ // Chutes TTS
411
+ router.post('/chutes/generate-voice', async (request, response) => {
412
+ try {
413
+ const key = readSecret(request.user.directories, SECRET_KEYS.CHUTES);
414
+
415
+ if (!key) {
416
+ console.warn('No Chutes key found');
417
+ return response.sendStatus(400);
418
+ }
419
+
420
+ const requestBody = {
421
+ text: request.body.input,
422
+ voice: request.body.voice || 'af_heart',
423
+ speed: request.body.speed || 1,
424
+ };
425
+
426
+ console.debug('Chutes TTS request', requestBody);
427
+
428
+ const result = await fetch('https://chutes-kokoro.chutes.ai/speak', {
429
+ method: 'POST',
430
+ headers: {
431
+ 'Content-Type': 'application/json',
432
+ Authorization: `Bearer ${key}`,
433
+ },
434
+ body: JSON.stringify(requestBody),
435
+ });
436
+
437
+ if (!result.ok) {
438
+ const text = await result.text();
439
+ console.warn('Chutes TTS request failed', result.statusText, text);
440
+ return response.status(500).send(text);
441
+ }
442
+
443
+ const contentType = result.headers.get('content-type') || 'audio/mpeg';
444
+ const buffer = await result.arrayBuffer();
445
+ response.setHeader('Content-Type', contentType);
446
+ return response.send(Buffer.from(buffer));
447
+ } catch (error) {
448
+ console.error('Chutes TTS generation failed', error);
449
+ response.status(500).send('Internal server error');
450
+ }
451
+ });
452
+
453
+ router.post('/chutes/models/embedding', async (request, response) => {
454
+ try {
455
+ const key = readSecret(request.user.directories, SECRET_KEYS.CHUTES);
456
+
457
+ if (!key) {
458
+ console.warn('No Chutes key found');
459
+ return response.sendStatus(400);
460
+ }
461
+
462
+ const result = await fetch('https://api.chutes.ai/chutes/?template=embedding&include_public=true&limit=999', {
463
+ method: 'GET',
464
+ headers: {
465
+ Authorization: `Bearer ${key}`,
466
+ },
467
+ });
468
+
469
+ if (!result.ok) {
470
+ const text = await result.text();
471
+ console.warn('Chutes embedding models request failed', result.statusText, text);
472
+ return response.status(500).send(text);
473
+ }
474
+
475
+ /** @type {any} */
476
+ const data = await result.json();
477
+
478
+ if (!Array.isArray(data?.items)) {
479
+ console.warn('Chutes embedding models response invalid', data);
480
+ return response.sendStatus(500);
481
+ }
482
+ return response.json(data.items);
483
+ } catch (error) {
484
+ console.error('Chutes embedding models fetch failed', error);
485
+ response.sendStatus(500);
486
+ }
487
+ });
488
+
489
+ router.post('/generate-image', async (request, response) => {
490
+ try {
491
+ const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);
492
+
493
+ if (!key) {
494
+ console.warn('No OpenAI key found');
495
+ return response.sendStatus(400);
496
+ }
497
+
498
+ console.debug('OpenAI request', request.body);
499
+
500
+ const result = await fetch('https://api.openai.com/v1/images/generations', {
501
+ method: 'POST',
502
+ headers: {
503
+ 'Content-Type': 'application/json',
504
+ Authorization: `Bearer ${key}`,
505
+ },
506
+ body: JSON.stringify(request.body),
507
+ });
508
+
509
+ if (!result.ok) {
510
+ const text = await result.text();
511
+ console.warn('OpenAI request failed', result.statusText, text);
512
+ return response.status(500).send(text);
513
+ }
514
+
515
+ const data = await result.json();
516
+ return response.json(data);
517
+ } catch (error) {
518
+ console.error(error);
519
+ response.status(500).send('Internal server error');
520
+ }
521
+ });
522
+
523
+ router.post('/generate-video', async (request, response) => {
524
+ try {
525
+ const controller = new AbortController();
526
+ request.socket.removeAllListeners('close');
527
+ request.socket.on('close', function () {
528
+ controller.abort();
529
+ });
530
+
531
+ const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);
532
+
533
+ if (!key) {
534
+ console.warn('No OpenAI key found');
535
+ return response.sendStatus(400);
536
+ }
537
+
538
+ console.debug('OpenAI video generation request', request.body);
539
+
540
+ const videoJobResponse = await fetch('https://api.openai.com/v1/videos', {
541
+ method: 'POST',
542
+ headers: {
543
+ 'Content-Type': 'application/json',
544
+ 'Authorization': `Bearer ${key}`,
545
+ },
546
+ body: JSON.stringify({
547
+ prompt: request.body.prompt,
548
+ model: request.body.model || 'sora-2',
549
+ size: request.body.size || '720x1280',
550
+ seconds: request.body.seconds || '8',
551
+ }),
552
+ });
553
+
554
+ if (!videoJobResponse.ok) {
555
+ const text = await videoJobResponse.text();
556
+ console.warn('OpenAI video generation request failed', videoJobResponse.statusText, text);
557
+ return response.status(500).send(text);
558
+ }
559
+
560
+ /** @type {any} */
561
+ const videoJob = await videoJobResponse.json();
562
+
563
+ if (!videoJob || !videoJob.id) {
564
+ console.warn('OpenAI video generation returned no job ID', videoJob);
565
+ return response.status(500).send('No video job ID returned');
566
+ }
567
+
568
+ // Poll for video generation completion
569
+ for (let attempt = 0; attempt < 30; attempt++) {
570
+ if (controller.signal.aborted) {
571
+ console.info('OpenAI video generation aborted by client');
572
+ return response.status(500).send('Video generation aborted by client');
573
+ }
574
+
575
+ await delay(5000 + attempt * 1000);
576
+ console.debug(`Polling OpenAI video job ${videoJob.id}, attempt ${attempt + 1}`);
577
+
578
+ const pollResponse = await fetch(`https://api.openai.com/v1/videos/${videoJob.id}`, {
579
+ method: 'GET',
580
+ headers: {
581
+ 'Authorization': `Bearer ${key}`,
582
+ },
583
+ });
584
+
585
+ if (!pollResponse.ok) {
586
+ const text = await pollResponse.text();
587
+ console.warn('OpenAI video job polling failed', pollResponse.statusText, text);
588
+ return response.status(500).send(text);
589
+ }
590
+
591
+ /** @type {any} */
592
+ const pollResult = await pollResponse.json();
593
+ console.debug(`OpenAI video job status: ${pollResult.status}, progress: ${pollResult.progress}`);
594
+
595
+ if (pollResult.status === 'failed') {
596
+ console.warn('OpenAI video generation failed', pollResult);
597
+ return response.status(500).send('Video generation failed');
598
+ }
599
+
600
+ if (pollResult.status === 'completed') {
601
+ const contentResponse = await fetch(`https://api.openai.com/v1/videos/${videoJob.id}/content`, {
602
+ method: 'GET',
603
+ headers: {
604
+ 'Authorization': `Bearer ${key}`,
605
+ },
606
+ });
607
+
608
+ if (!contentResponse.ok) {
609
+ const text = await contentResponse.text();
610
+ console.warn('OpenAI video content fetch failed', contentResponse.statusText, text);
611
+ return response.status(500).send(text);
612
+ }
613
+
614
+ const contentBuffer = await contentResponse.arrayBuffer();
615
+ return response.send({ format: 'mp4', data: Buffer.from(contentBuffer).toString('base64') });
616
+ }
617
+ }
618
+ } catch (error) {
619
+ console.error('OpenAI video generation failed', error);
620
+ response.status(500).send('Internal server error');
621
+ }
622
+ });
623
+
624
+ const custom = express.Router();
625
+
626
+ custom.post('/generate-voice', async (request, response) => {
627
+ try {
628
+ const key = readSecret(request.user.directories, SECRET_KEYS.CUSTOM_OPENAI_TTS);
629
+ const { input, provider_endpoint, response_format, voice, speed, model } = request.body;
630
+
631
+ if (!provider_endpoint) {
632
+ console.warn('No OpenAI-compatible TTS provider endpoint provided');
633
+ return response.sendStatus(400);
634
+ }
635
+
636
+ const result = await fetch(provider_endpoint, {
637
+ method: 'POST',
638
+ headers: {
639
+ 'Content-Type': 'application/json',
640
+ Authorization: `Bearer ${key ?? ''}`,
641
+ },
642
+ body: JSON.stringify({
643
+ input: input ?? '',
644
+ response_format: response_format ?? 'mp3',
645
+ voice: voice ?? 'alloy',
646
+ speed: speed ?? 1,
647
+ model: model ?? 'tts-1',
648
+ }),
649
+ });
650
+
651
+ if (!result.ok) {
652
+ const text = await result.text();
653
+ console.warn('OpenAI request failed', result.statusText, text);
654
+ return response.status(500).send(text);
655
+ }
656
+
657
+ const buffer = await result.arrayBuffer();
658
+ response.setHeader('Content-Type', 'audio/mpeg');
659
+ return response.send(Buffer.from(buffer));
660
+ } catch (error) {
661
+ console.error('OpenAI TTS generation failed', error);
662
+ response.status(500).send('Internal server error');
663
+ }
664
+ });
665
+
666
+ router.use('/custom', custom);
667
+
668
+ /**
669
+ * Creates a transcribe-audio endpoint handler for a given provider.
670
+ * @param {object} config - Provider configuration
671
+ * @param {string} config.secretKey - The SECRET_KEYS enum value for the provider
672
+ * @param {string} config.apiUrl - The transcription API endpoint URL
673
+ * @param {string} config.providerName - Display name for logging
674
+ * @returns {import('express').RequestHandler} Express request handler
675
+ */
676
+ function createTranscribeHandler({ secretKey, apiUrl, providerName }) {
677
+ return async (request, response) => {
678
+ try {
679
+ const key = readSecret(request.user.directories, secretKey);
680
+
681
+ if (!key) {
682
+ console.warn(`No ${providerName} key found`);
683
+ return response.sendStatus(400);
684
+ }
685
+
686
+ if (!request.file) {
687
+ console.warn('No audio file found');
688
+ return response.sendStatus(400);
689
+ }
690
+
691
+ console.info(`Processing audio file with ${providerName}`, request.file.path);
692
+ const formData = new FormData();
693
+ formData.append('file', fs.createReadStream(request.file.path), { filename: 'audio.wav', contentType: 'audio/wav' });
694
+ formData.append('model', request.body.model);
695
+
696
+ if (request.body.language) {
697
+ formData.append('language', request.body.language);
698
+ }
699
+
700
+ const result = await fetch(apiUrl, {
701
+ method: 'POST',
702
+ headers: {
703
+ 'Authorization': `Bearer ${key}`,
704
+ ...formData.getHeaders(),
705
+ },
706
+ body: formData,
707
+ });
708
+
709
+ if (!result.ok) {
710
+ const text = await result.text();
711
+ console.warn(`${providerName} request failed`, result.statusText, text);
712
+ return response.status(500).send(text);
713
+ }
714
+
715
+ fs.unlinkSync(request.file.path);
716
+ const data = await result.json();
717
+ console.debug(`${providerName} transcription response`, data);
718
+ return response.json(data);
719
+ } catch (error) {
720
+ console.error(`${providerName} transcription failed`, error);
721
+ response.status(500).send('Internal server error');
722
+ }
723
+ };
724
+ }
725
+
726
+ router.post('/transcribe-audio', createTranscribeHandler({
727
+ secretKey: SECRET_KEYS.OPENAI,
728
+ apiUrl: 'https://api.openai.com/v1/audio/transcriptions',
729
+ providerName: 'OpenAI',
730
+ }));
731
+
732
+ router.post('/groq/transcribe-audio', createTranscribeHandler({
733
+ secretKey: SECRET_KEYS.GROQ,
734
+ apiUrl: 'https://api.groq.com/openai/v1/audio/transcriptions',
735
+ providerName: 'Groq',
736
+ }));
737
+
738
+ router.post('/mistral/transcribe-audio', createTranscribeHandler({
739
+ secretKey: SECRET_KEYS.MISTRALAI,
740
+ apiUrl: 'https://api.mistral.ai/v1/audio/transcriptions',
741
+ providerName: 'MistralAI',
742
+ }));
743
+
744
+ router.post('/zai/transcribe-audio', createTranscribeHandler({
745
+ secretKey: SECRET_KEYS.ZAI,
746
+ apiUrl: 'https://api.z.ai/api/paas/v4/audio/transcriptions',
747
+ providerName: 'Z.AI',
748
+ }));
749
+
750
+ router.post('/chutes/transcribe-audio', async (request, response) => {
751
+ try {
752
+ const key = readSecret(request.user.directories, SECRET_KEYS.CHUTES);
753
+
754
+ if (!key) {
755
+ console.warn('No Chutes key found');
756
+ return response.sendStatus(400);
757
+ }
758
+
759
+ if (!request.file) {
760
+ console.warn('No audio file found');
761
+ return response.sendStatus(400);
762
+ }
763
+
764
+ console.info('Processing audio file with Chutes', request.file.path);
765
+ const audioBase64 = fs.readFileSync(request.file.path).toString('base64');
766
+
767
+ const result = await fetch(`https://${request.body.model}.chutes.ai/transcribe`, {
768
+ method: 'POST',
769
+ headers: {
770
+ 'Authorization': `Bearer ${key}`,
771
+ 'Content-Type': 'application/json',
772
+ },
773
+ body: JSON.stringify({
774
+ audio_b64: audioBase64,
775
+ }),
776
+ });
777
+
778
+ if (!result.ok) {
779
+ const text = await result.text();
780
+ console.warn('Chutes request failed', result.statusText, text);
781
+ return response.status(500).send(text);
782
+ }
783
+
784
+ fs.unlinkSync(request.file.path);
785
+ const data = await result.json();
786
+ console.debug('Chutes transcription response', data);
787
+
788
+ if (!Array.isArray(data)) {
789
+ console.warn('Chutes transcription response invalid', data);
790
+ return response.sendStatus(500);
791
+ }
792
+
793
+ const fullText = data.map(chunk => chunk.text || '').join('').trim();
794
+ return response.json({ text: fullText });
795
+ } catch (error) {
796
+ console.error('Chutes transcription failed', error);
797
+ response.status(500).send('Internal server error');
798
+ }
799
+ });
src/endpoints/openrouter.js ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import express from 'express';
2
+ import fetch from 'node-fetch';
3
+ import mime from 'mime-types';
4
+ import { readSecret, SECRET_KEYS } from './secrets.js';
5
+
6
+ export const router = express.Router();
7
+ const API_OPENROUTER = 'https://openrouter.ai/api/v1';
8
+
9
+ router.post('/models/providers', async (req, res) => {
10
+ try {
11
+ const { model } = req.body;
12
+ const response = await fetch(`${API_OPENROUTER}/models/${model}/endpoints`, {
13
+ method: 'GET',
14
+ headers: {
15
+ 'Accept': 'application/json',
16
+ },
17
+ });
18
+
19
+ if (!response.ok) {
20
+ return res.json([]);
21
+ }
22
+
23
+ /** @type {any} */
24
+ const data = await response.json();
25
+ const endpoints = data?.data?.endpoints || [];
26
+ const providerNames = endpoints.map(e => e.provider_name);
27
+
28
+ return res.json(providerNames);
29
+ } catch (error) {
30
+ console.error(error);
31
+ return res.sendStatus(500);
32
+ }
33
+ });
34
+
35
+ /**
36
+ * Fetches and filters models from OpenRouter API based on modality criteria.
37
+ * @param {string} endpoint - The API endpoint to fetch from
38
+ * @param {string} inputModality - Required input modality
39
+ * @param {string} outputModality - Required output modality
40
+ * @param {boolean} [idsOnly=false] - Whether to return only model IDs
41
+ * @returns {Promise<any[]>} Filtered models or model IDs
42
+ */
43
+ async function fetchModelsByModality(endpoint, inputModality, outputModality, idsOnly = false) {
44
+ const response = await fetch(`${API_OPENROUTER}${endpoint}`, {
45
+ method: 'GET',
46
+ headers: { 'Accept': 'application/json' },
47
+ });
48
+
49
+ if (!response.ok) {
50
+ console.warn('OpenRouter API request failed', response.statusText);
51
+ return [];
52
+ }
53
+
54
+ /** @type {any} */
55
+ const data = await response.json();
56
+
57
+ if (!Array.isArray(data?.data)) {
58
+ console.warn('OpenRouter API response was not an array');
59
+ return [];
60
+ }
61
+
62
+ const filtered = data.data
63
+ .filter(m => Array.isArray(m?.architecture?.input_modalities))
64
+ .filter(m => m.architecture.input_modalities.includes(inputModality))
65
+ .filter(m => Array.isArray(m?.architecture?.output_modalities))
66
+ .filter(m => m.architecture.output_modalities.includes(outputModality))
67
+ .sort((a, b) => a?.id && b?.id ? a.id.localeCompare(b.id) : 0);
68
+
69
+ return idsOnly ? filtered.map(m => m.id) : filtered;
70
+ }
71
+
72
+ router.post('/models/multimodal', async (_req, res) => {
73
+ try {
74
+ const models = await fetchModelsByModality('/models', 'image', 'text', true);
75
+ return res.json(models);
76
+ } catch (error) {
77
+ console.error(error);
78
+ return res.sendStatus(500);
79
+ }
80
+ });
81
+
82
+ router.post('/models/embedding', async (_req, res) => {
83
+ try {
84
+ const models = await fetchModelsByModality('/embeddings/models', 'text', 'embeddings');
85
+ return res.json(models);
86
+ } catch (error) {
87
+ console.error(error);
88
+ return res.sendStatus(500);
89
+ }
90
+ });
91
+
92
+ router.post('/models/image', async (_req, res) => {
93
+ try {
94
+ const models = await fetchModelsByModality('/models', 'text', 'image');
95
+ return res.json(models.map(m => ({ value: m.id, text: m.name || m.id })));
96
+ } catch (error) {
97
+ console.error(error);
98
+ return res.sendStatus(500);
99
+ }
100
+ });
101
+
102
+ router.post('/image/generate', async (req, res) => {
103
+ try {
104
+ const key = readSecret(req.user.directories, SECRET_KEYS.OPENROUTER);
105
+
106
+ if (!key) {
107
+ console.warn('OpenRouter API key not found');
108
+ return res.status(400).json({ error: 'OpenRouter API key not found' });
109
+ }
110
+
111
+ console.debug('OpenRouter image generation request', req.body);
112
+
113
+ const { model, prompt } = req.body;
114
+
115
+ if (!model || !prompt) {
116
+ return res.status(400).json({ error: 'Model and prompt are required' });
117
+ }
118
+
119
+ const response = await fetch(`${API_OPENROUTER}/chat/completions`, {
120
+ method: 'POST',
121
+ headers: {
122
+ 'Content-Type': 'application/json',
123
+ 'Authorization': `Bearer ${key}`,
124
+ },
125
+ body: JSON.stringify({
126
+ model: model,
127
+ messages: [
128
+ {
129
+ role: 'user',
130
+ content: prompt,
131
+ },
132
+ ],
133
+ modalities: ['image', 'text'],
134
+ image_config: {
135
+ aspect_ratio: req.body.aspect_ratio || '1:1',
136
+ },
137
+ }),
138
+ });
139
+
140
+ if (!response.ok) {
141
+ console.warn('OpenRouter image generation failed', await response.text());
142
+ return res.sendStatus(500);
143
+ }
144
+
145
+ /** @type {any} */
146
+ const data = await response.json();
147
+
148
+ const imageUrl = data?.choices?.[0]?.message?.images?.[0]?.image_url?.url;
149
+
150
+ if (!imageUrl) {
151
+ console.warn('No image URL found in OpenRouter response', data);
152
+ return res.sendStatus(500);
153
+ }
154
+
155
+ const [mimeType, base64Data] = /^data:(.*);base64,(.*)$/.exec(imageUrl)?.slice(1) || [];
156
+
157
+ if (!mimeType || !base64Data) {
158
+ console.warn('Invalid image data format', imageUrl);
159
+ return res.sendStatus(500);
160
+ }
161
+
162
+ const result = {
163
+ format: mime.extension(mimeType) || 'png',
164
+ image: base64Data,
165
+ };
166
+
167
+ return res.json(result);
168
+ } catch (error) {
169
+ console.error(error);
170
+ return res.sendStatus(500);
171
+ }
172
+ });
src/endpoints/presets.js ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import express from 'express';
5
+ import sanitize from 'sanitize-filename';
6
+ import { sync as writeFileAtomicSync } from 'write-file-atomic';
7
+
8
+ import { getDefaultPresetFile, getDefaultPresets } from './content-manager.js';
9
+
10
+ /**
11
+ * Gets the folder and extension for the preset settings based on the API source ID.
12
+ * @param {string} apiId API source ID
13
+ * @param {import('../users.js').UserDirectoryList} directories User directories
14
+ * @returns {{folder: string?, extension: string?}} Object containing the folder and extension for the preset settings
15
+ */
16
+ function getPresetSettingsByAPI(apiId, directories) {
17
+ switch (apiId) {
18
+ case 'kobold':
19
+ case 'koboldhorde':
20
+ return { folder: directories.koboldAI_Settings, extension: '.json' };
21
+ case 'novel':
22
+ return { folder: directories.novelAI_Settings, extension: '.json' };
23
+ case 'textgenerationwebui':
24
+ return { folder: directories.textGen_Settings, extension: '.json' };
25
+ case 'openai':
26
+ return { folder: directories.openAI_Settings, extension: '.json' };
27
+ case 'instruct':
28
+ return { folder: directories.instruct, extension: '.json' };
29
+ case 'context':
30
+ return { folder: directories.context, extension: '.json' };
31
+ case 'sysprompt':
32
+ return { folder: directories.sysprompt, extension: '.json' };
33
+ case 'reasoning':
34
+ return { folder: directories.reasoning, extension: '.json' };
35
+ default:
36
+ return { folder: null, extension: null };
37
+ }
38
+ }
39
+
40
+ export const router = express.Router();
41
+
42
+ router.post('/save', function (request, response) {
43
+ const name = sanitize(request.body.name);
44
+ if (!request.body.preset || !name) {
45
+ return response.sendStatus(400);
46
+ }
47
+
48
+ const settings = getPresetSettingsByAPI(request.body.apiId, request.user.directories);
49
+ const filename = name + settings.extension;
50
+
51
+ if (!settings.folder) {
52
+ return response.sendStatus(400);
53
+ }
54
+
55
+ const fullpath = path.join(settings.folder, filename);
56
+ writeFileAtomicSync(fullpath, JSON.stringify(request.body.preset, null, 4), 'utf-8');
57
+ return response.send({ name });
58
+ });
59
+
60
+ router.post('/delete', function (request, response) {
61
+ const name = sanitize(request.body.name);
62
+ if (!name) {
63
+ return response.sendStatus(400);
64
+ }
65
+
66
+ const settings = getPresetSettingsByAPI(request.body.apiId, request.user.directories);
67
+ const filename = name + settings.extension;
68
+
69
+ if (!settings.folder) {
70
+ return response.sendStatus(400);
71
+ }
72
+
73
+ const fullpath = path.join(settings.folder, filename);
74
+
75
+ if (fs.existsSync(fullpath)) {
76
+ fs.unlinkSync(fullpath);
77
+ return response.sendStatus(200);
78
+ } else {
79
+ return response.sendStatus(404);
80
+ }
81
+ });
82
+
83
+ router.post('/restore', function (request, response) {
84
+ try {
85
+ const settings = getPresetSettingsByAPI(request.body.apiId, request.user.directories);
86
+ const name = sanitize(request.body.name);
87
+ const defaultPresets = getDefaultPresets(request.user.directories);
88
+
89
+ const defaultPreset = defaultPresets.find(p => p.name === name && p.folder === settings.folder);
90
+
91
+ const result = { isDefault: false, preset: {} };
92
+
93
+ if (defaultPreset) {
94
+ result.isDefault = true;
95
+ result.preset = getDefaultPresetFile(defaultPreset.filename) || {};
96
+ }
97
+
98
+ return response.send(result);
99
+ } catch (error) {
100
+ console.error(error);
101
+ return response.sendStatus(500);
102
+ }
103
+ });
src/endpoints/quick-replies.js ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import express from 'express';
5
+ import sanitize from 'sanitize-filename';
6
+ import { sync as writeFileAtomicSync } from 'write-file-atomic';
7
+
8
+ export const router = express.Router();
9
+
10
+ router.post('/save', (request, response) => {
11
+ if (!request.body || !request.body.name) {
12
+ return response.sendStatus(400);
13
+ }
14
+
15
+ const filename = path.join(request.user.directories.quickreplies, 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', (request, response) => {
22
+ if (!request.body || !request.body.name) {
23
+ return response.sendStatus(400);
24
+ }
25
+
26
+ const filename = path.join(request.user.directories.quickreplies, sanitize(`${request.body.name}.json`));
27
+ if (fs.existsSync(filename)) {
28
+ fs.unlinkSync(filename);
29
+ }
30
+
31
+ return response.sendStatus(200);
32
+ });
src/endpoints/search.js ADDED
@@ -0,0 +1,455 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fetch from 'node-fetch';
2
+ import express from 'express';
3
+
4
+ import { decode } from 'html-entities';
5
+ import { readSecret, SECRET_KEYS } from './secrets.js';
6
+ import { trimV1 } from '../util.js';
7
+ import { setAdditionalHeaders } from '../additional-headers.js';
8
+
9
+ export const router = express.Router();
10
+
11
+ // Cosplay as Chrome
12
+ const visitHeaders = {
13
+ 'Accept': 'text/html',
14
+ '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',
15
+ 'Accept-Language': 'en-US,en;q=0.5',
16
+ 'Accept-Encoding': 'gzip, deflate, br',
17
+ 'Connection': 'keep-alive',
18
+ 'Cache-Control': 'no-cache',
19
+ 'Pragma': 'no-cache',
20
+ 'TE': 'trailers',
21
+ 'DNT': '1',
22
+ 'Sec-Fetch-Dest': 'document',
23
+ 'Sec-Fetch-Mode': 'navigate',
24
+ 'Sec-Fetch-Site': 'none',
25
+ 'Sec-Fetch-User': '?1',
26
+ };
27
+
28
+ /**
29
+ * Extract the transcript of a YouTube video
30
+ * @param {string} videoPageBody HTML of the video page
31
+ * @param {string} lang Language code
32
+ * @returns {Promise<string>} Transcript text
33
+ */
34
+ async function extractTranscript(videoPageBody, lang) {
35
+ const RE_XML_TRANSCRIPT = /<text start="([^"]*)" dur="([^"]*)">([^<]*)<\/text>/g;
36
+ const splittedHTML = videoPageBody.split('"captions":');
37
+
38
+ if (splittedHTML.length <= 1) {
39
+ if (videoPageBody.includes('class="g-recaptcha"')) {
40
+ throw new Error('Too many requests');
41
+ }
42
+ if (!videoPageBody.includes('"playabilityStatus":')) {
43
+ throw new Error('Video is not available');
44
+ }
45
+ throw new Error('Transcript not available');
46
+ }
47
+
48
+ const captions = (() => {
49
+ try {
50
+ return JSON.parse(splittedHTML[1].split(',"videoDetails')[0].replace('\n', ''));
51
+ } catch (e) {
52
+ return undefined;
53
+ }
54
+ })()?.['playerCaptionsTracklistRenderer'];
55
+
56
+ if (!captions) {
57
+ throw new Error('Transcript disabled');
58
+ }
59
+
60
+ if (!('captionTracks' in captions)) {
61
+ throw new Error('Transcript not available');
62
+ }
63
+
64
+ if (lang && !captions.captionTracks.some(track => track.languageCode === lang)) {
65
+ throw new Error('Transcript not available in this language');
66
+ }
67
+
68
+ const transcriptURL = (lang ? captions.captionTracks.find(track => track.languageCode === lang) : captions.captionTracks[0]).baseUrl;
69
+ const transcriptResponse = await fetch(transcriptURL, {
70
+ headers: {
71
+ ...(lang && { 'Accept-Language': lang }),
72
+ 'User-Agent': visitHeaders['User-Agent'],
73
+ },
74
+ });
75
+
76
+ if (!transcriptResponse.ok) {
77
+ throw new Error('Transcript request failed');
78
+ }
79
+
80
+ const transcriptBody = await transcriptResponse.text();
81
+ const results = [...transcriptBody.matchAll(RE_XML_TRANSCRIPT)];
82
+ const transcript = results.map((result) => ({
83
+ text: result[3],
84
+ duration: parseFloat(result[2]),
85
+ offset: parseFloat(result[1]),
86
+ lang: lang ?? captions.captionTracks[0].languageCode,
87
+ }));
88
+ // The text is double-encoded
89
+ const transcriptText = transcript.map((line) => decode(decode(line.text))).join(' ');
90
+ return transcriptText;
91
+ }
92
+
93
+ router.post('/serpapi', async (request, response) => {
94
+ try {
95
+ const key = readSecret(request.user.directories, SECRET_KEYS.SERPAPI);
96
+
97
+ if (!key) {
98
+ console.error('No SerpApi key found');
99
+ return response.sendStatus(400);
100
+ }
101
+
102
+ const { query } = request.body;
103
+ const result = await fetch(`https://serpapi.com/search.json?q=${encodeURIComponent(query)}&api_key=${key}`);
104
+
105
+ console.debug('SerpApi query', query);
106
+
107
+ if (!result.ok) {
108
+ const text = await result.text();
109
+ console.error('SerpApi request failed', result.statusText, text);
110
+ return response.status(500).send(text);
111
+ }
112
+
113
+ const data = await result.json();
114
+ console.debug('SerpApi response', data);
115
+ return response.json(data);
116
+ } catch (error) {
117
+ console.error(error);
118
+ return response.sendStatus(500);
119
+ }
120
+ });
121
+
122
+ /**
123
+ * Get the transcript of a YouTube video
124
+ * @copyright https://github.com/Kakulukian/youtube-transcript (MIT License)
125
+ */
126
+ router.post('/transcript', async (request, response) => {
127
+ try {
128
+ const id = request.body.id;
129
+ const lang = request.body.lang;
130
+ const json = request.body.json;
131
+
132
+ if (!id) {
133
+ console.error('Id is required for /transcript');
134
+ return response.sendStatus(400);
135
+ }
136
+
137
+ const videoPageResponse = await fetch(`https://www.youtube.com/watch?v=${id}`, {
138
+ headers: {
139
+ ...(lang && { 'Accept-Language': lang }),
140
+ 'User-Agent': visitHeaders['User-Agent'],
141
+ },
142
+ });
143
+
144
+ const videoPageBody = await videoPageResponse.text();
145
+
146
+ try {
147
+ const transcriptText = await extractTranscript(videoPageBody, lang);
148
+ return json
149
+ ? response.json({ transcript: transcriptText, html: videoPageBody })
150
+ : response.send(transcriptText);
151
+ } catch (error) {
152
+ if (json) {
153
+ return response.json({ html: videoPageBody, transcript: '' });
154
+ }
155
+ throw error;
156
+ }
157
+ } catch (error) {
158
+ console.error(error);
159
+ return response.sendStatus(500);
160
+ }
161
+ });
162
+
163
+ router.post('/searxng', async (request, response) => {
164
+ try {
165
+ const { baseUrl, query, preferences, categories } = request.body;
166
+
167
+ if (!baseUrl || !query) {
168
+ console.error('Missing required parameters for /searxng');
169
+ return response.sendStatus(400);
170
+ }
171
+
172
+ console.debug('SearXNG query', baseUrl, query);
173
+
174
+ const mainPageUrl = new URL(baseUrl);
175
+ const mainPageRequest = await fetch(mainPageUrl, { headers: visitHeaders });
176
+
177
+ if (!mainPageRequest.ok) {
178
+ console.error('SearXNG request failed', mainPageRequest.statusText);
179
+ return response.sendStatus(500);
180
+ }
181
+
182
+ const mainPageText = await mainPageRequest.text();
183
+ const clientHref = mainPageText.match(/href="(\/client.+\.css)"/)?.[1];
184
+
185
+ if (clientHref) {
186
+ const clientUrl = new URL(clientHref, baseUrl);
187
+ await fetch(clientUrl, { headers: visitHeaders });
188
+ }
189
+
190
+ const searchUrl = new URL('/search', baseUrl);
191
+ const searchParams = new URLSearchParams();
192
+ searchParams.append('q', query);
193
+ if (preferences) {
194
+ searchParams.append('preferences', preferences);
195
+ }
196
+ if (categories) {
197
+ searchParams.append('categories', categories);
198
+ }
199
+ searchUrl.search = searchParams.toString();
200
+
201
+ const searchResult = await fetch(searchUrl, { headers: visitHeaders });
202
+
203
+ if (!searchResult.ok) {
204
+ const text = await searchResult.text();
205
+ console.error('SearXNG request failed', searchResult.statusText, text);
206
+ return response.sendStatus(500);
207
+ }
208
+
209
+ const data = await searchResult.text();
210
+ return response.send(data);
211
+ } catch (error) {
212
+ console.error('SearXNG request failed', error);
213
+ return response.sendStatus(500);
214
+ }
215
+ });
216
+
217
+ router.post('/tavily', async (request, response) => {
218
+ try {
219
+ const apiKey = readSecret(request.user.directories, SECRET_KEYS.TAVILY);
220
+
221
+ if (!apiKey) {
222
+ console.error('No Tavily key found');
223
+ return response.sendStatus(400);
224
+ }
225
+
226
+ const { query, include_images } = request.body;
227
+
228
+ const body = {
229
+ query: query,
230
+ api_key: apiKey,
231
+ search_depth: 'basic',
232
+ topic: 'general',
233
+ include_answer: true,
234
+ include_raw_content: false,
235
+ include_images: !!include_images,
236
+ include_image_descriptions: false,
237
+ include_domains: [],
238
+ max_results: 10,
239
+ };
240
+
241
+ const result = await fetch('https://api.tavily.com/search', {
242
+ method: 'POST',
243
+ headers: {
244
+ 'Content-Type': 'application/json',
245
+ },
246
+ body: JSON.stringify(body),
247
+ });
248
+
249
+ console.debug('Tavily query', query);
250
+
251
+ if (!result.ok) {
252
+ const text = await result.text();
253
+ console.error('Tavily request failed', result.statusText, text);
254
+ return response.status(500).send(text);
255
+ }
256
+
257
+ const data = await result.json();
258
+ console.debug('Tavily response', data);
259
+ return response.json(data);
260
+ } catch (error) {
261
+ console.error(error);
262
+ return response.sendStatus(500);
263
+ }
264
+ });
265
+
266
+ router.post('/koboldcpp', async (request, response) => {
267
+ try {
268
+ const { query, url } = request.body;
269
+
270
+ if (!url) {
271
+ console.error('No URL provided for KoboldCpp search');
272
+ return response.sendStatus(400);
273
+ }
274
+
275
+ console.debug('KoboldCpp search query', query);
276
+
277
+ const baseUrl = trimV1(url);
278
+ const args = {
279
+ method: 'POST',
280
+ headers: {},
281
+ body: JSON.stringify({ q: query }),
282
+ };
283
+
284
+ setAdditionalHeaders(request, args, baseUrl);
285
+ const result = await fetch(`${baseUrl}/api/extra/websearch`, args);
286
+
287
+ if (!result.ok) {
288
+ const text = await result.text();
289
+ console.error('KoboldCpp request failed', result.statusText, text);
290
+ return response.status(500).send(text);
291
+ }
292
+
293
+ const data = await result.json();
294
+ console.debug('KoboldCpp search response', data);
295
+ return response.json(data);
296
+ } catch (error) {
297
+ console.error(error);
298
+ return response.sendStatus(500);
299
+ }
300
+ });
301
+
302
+ router.post('/serper', async (request, response) => {
303
+ try {
304
+ const key = readSecret(request.user.directories, SECRET_KEYS.SERPER);
305
+
306
+ if (!key) {
307
+ console.error('No Serper key found');
308
+ return response.sendStatus(400);
309
+ }
310
+
311
+ const { query, images } = request.body;
312
+
313
+ const url = images
314
+ ? 'https://google.serper.dev/images'
315
+ : 'https://google.serper.dev/search';
316
+
317
+ const result = await fetch(url, {
318
+ method: 'POST',
319
+ headers: {
320
+ 'X-API-KEY': key,
321
+ 'Content-Type': 'application/json',
322
+ },
323
+ redirect: 'follow',
324
+ body: JSON.stringify({ q: query }),
325
+ });
326
+
327
+ console.debug('Serper query', query);
328
+
329
+ if (!result.ok) {
330
+ const text = await result.text();
331
+ console.warn('Serper request failed', result.statusText, text);
332
+ return response.status(500).send(text);
333
+ }
334
+
335
+ const data = await result.json();
336
+ console.debug('Serper response', data);
337
+ return response.json(data);
338
+ } catch (error) {
339
+ console.error(error);
340
+ return response.sendStatus(500);
341
+ }
342
+ });
343
+
344
+ router.post('/zai', async (request, response) => {
345
+ try {
346
+ const key = readSecret(request.user.directories, SECRET_KEYS.ZAI);
347
+
348
+ if (!key) {
349
+ console.error('No Z.AI key found');
350
+ return response.sendStatus(400);
351
+ }
352
+
353
+ const { query } = request.body;
354
+
355
+ if (!query) {
356
+ console.error('No query provided for /zai');
357
+ return response.sendStatus(400);
358
+ }
359
+
360
+ console.debug('Z.AI web search query', query);
361
+
362
+ const result = await fetch('https://api.z.ai/api/paas/v4/web_search', {
363
+ method: 'POST',
364
+ headers: {
365
+ 'Content-Type': 'application/json',
366
+ 'Authorization': `Bearer ${key}`,
367
+ },
368
+ body: JSON.stringify({
369
+ // TODO: There's only one engine option for now
370
+ search_engine: 'search-prime',
371
+ search_query: query,
372
+ }),
373
+ });
374
+
375
+ if (!result.ok) {
376
+ const text = await result.text();
377
+ console.error('Z.AI request failed', result.statusText, text);
378
+ return response.status(500).send(text);
379
+ }
380
+
381
+ const data = await result.json();
382
+ console.debug('Z.AI web search response', data);
383
+ return response.json(data);
384
+ } catch (error) {
385
+ console.error(error);
386
+ return response.sendStatus(500);
387
+ }
388
+ });
389
+
390
+ router.post('/visit', async (request, response) => {
391
+ try {
392
+ const url = request.body.url;
393
+ const html = Boolean(request.body.html ?? true);
394
+
395
+ if (!url) {
396
+ console.error('No url provided for /visit');
397
+ return response.sendStatus(400);
398
+ }
399
+
400
+ try {
401
+ const urlObj = new URL(url);
402
+
403
+ // Reject relative URLs
404
+ if (urlObj.protocol === null || urlObj.host === null) {
405
+ throw new Error('Invalid URL format');
406
+ }
407
+
408
+ // Reject non-HTTP URLs
409
+ if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') {
410
+ throw new Error('Invalid protocol');
411
+ }
412
+
413
+ // Reject URLs with a non-standard port
414
+ if (urlObj.port !== '') {
415
+ throw new Error('Invalid port');
416
+ }
417
+
418
+ // Reject IP addresses
419
+ if (urlObj.hostname.match(/^\d+\.\d+\.\d+\.\d+$/)) {
420
+ throw new Error('Invalid hostname');
421
+ }
422
+ } catch (error) {
423
+ console.error('Invalid url provided for /visit', url);
424
+ return response.sendStatus(400);
425
+ }
426
+
427
+ console.info('Visiting web URL', url);
428
+
429
+ const result = await fetch(url, { headers: visitHeaders });
430
+
431
+ if (!result.ok) {
432
+ console.error(`Visit failed ${result.status} ${result.statusText}`);
433
+ return response.sendStatus(500);
434
+ }
435
+
436
+ const contentType = String(result.headers.get('content-type'));
437
+
438
+ if (html) {
439
+ if (!contentType.includes('text/html')) {
440
+ console.error(`Visit failed, content-type is ${contentType}, expected text/html`);
441
+ return response.sendStatus(500);
442
+ }
443
+
444
+ const text = await result.text();
445
+ return response.send(text);
446
+ }
447
+
448
+ response.setHeader('Content-Type', contentType);
449
+ const buffer = await result.arrayBuffer();
450
+ return response.send(Buffer.from(buffer));
451
+ } catch (error) {
452
+ console.error(error);
453
+ return response.sendStatus(500);
454
+ }
455
+ });
src/endpoints/secrets.js ADDED
@@ -0,0 +1,635 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import express from 'express';
5
+ import { sync as writeFileAtomicSync } from 'write-file-atomic';
6
+ import { color, getConfigValue, uuidv4 } from '../util.js';
7
+
8
+ export const SECRETS_FILE = 'secrets.json';
9
+ export const SECRET_KEYS = {
10
+ _MIGRATED: '_migrated',
11
+ HORDE: 'api_key_horde',
12
+ MANCER: 'api_key_mancer',
13
+ VLLM: 'api_key_vllm',
14
+ APHRODITE: 'api_key_aphrodite',
15
+ TABBY: 'api_key_tabby',
16
+ OPENAI: 'api_key_openai',
17
+ NOVEL: 'api_key_novel',
18
+ CLAUDE: 'api_key_claude',
19
+ DEEPL: 'deepl',
20
+ LIBRE: 'libre',
21
+ LIBRE_URL: 'libre_url',
22
+ LINGVA_URL: 'lingva_url',
23
+ OPENROUTER: 'api_key_openrouter',
24
+ AI21: 'api_key_ai21',
25
+ ONERING_URL: 'oneringtranslator_url',
26
+ DEEPLX_URL: 'deeplx_url',
27
+ MAKERSUITE: 'api_key_makersuite',
28
+ VERTEXAI: 'api_key_vertexai',
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
+ HUGGINGFACE: 'api_key_huggingface',
45
+ STABILITY: 'api_key_stability',
46
+ CUSTOM_OPENAI_TTS: 'api_key_custom_openai_tts',
47
+ TAVILY: 'api_key_tavily',
48
+ CHUTES: 'api_key_chutes',
49
+ ELECTRONHUB: 'api_key_electronhub',
50
+ NANOGPT: 'api_key_nanogpt',
51
+ BFL: 'api_key_bfl',
52
+ COMFY_RUNPOD: 'api_key_comfy_runpod',
53
+ FALAI: 'api_key_falai',
54
+ GENERIC: 'api_key_generic',
55
+ DEEPSEEK: 'api_key_deepseek',
56
+ SERPER: 'api_key_serper',
57
+ AIMLAPI: 'api_key_aimlapi',
58
+ XAI: 'api_key_xai',
59
+ FIREWORKS: 'api_key_fireworks',
60
+ VERTEXAI_SERVICE_ACCOUNT: 'vertexai_service_account_json',
61
+ MINIMAX: 'api_key_minimax',
62
+ MINIMAX_GROUP_ID: 'minimax_group_id',
63
+ MOONSHOT: 'api_key_moonshot',
64
+ COMETAPI: 'api_key_cometapi',
65
+ AZURE_OPENAI: 'api_key_azure_openai',
66
+ ZAI: 'api_key_zai',
67
+ SILICONFLOW: 'api_key_siliconflow',
68
+ ELEVENLABS: 'api_key_elevenlabs',
69
+ };
70
+
71
+ /**
72
+ * @typedef {object} SecretValue
73
+ * @property {string} id The unique identifier for the secret
74
+ * @property {string} value The secret value
75
+ * @property {string} label The label for the secret
76
+ * @property {boolean} active Whether the secret is currently active
77
+ */
78
+
79
+ /**
80
+ * @typedef {object} SecretState
81
+ * @property {string} id The unique identifier for the secret
82
+ * @property {string} value The secret value, masked for security
83
+ * @property {string} label The label for the secret
84
+ * @property {boolean} active Whether the secret is currently active
85
+ */
86
+
87
+ /**
88
+ * @typedef {Record<string, SecretState[]|null>} SecretStateMap
89
+ */
90
+
91
+ /**
92
+ * @typedef {{[key: string]: SecretValue[]}} SecretKeys
93
+ * @typedef {{[key: string]: string}} FlatSecretKeys
94
+ */
95
+
96
+ // These are the keys that are safe to expose, even if allowKeysExposure is false
97
+ const EXPORTABLE_KEYS = [
98
+ SECRET_KEYS.LIBRE_URL,
99
+ SECRET_KEYS.LINGVA_URL,
100
+ SECRET_KEYS.ONERING_URL,
101
+ SECRET_KEYS.DEEPLX_URL,
102
+ ];
103
+
104
+ const allowKeysExposure = !!getConfigValue('allowKeysExposure', false, 'boolean');
105
+
106
+ /**
107
+ * SecretManager class to handle all secret operations
108
+ */
109
+ export class SecretManager {
110
+ /**
111
+ * @param {import('../users.js').UserDirectoryList} directories
112
+ */
113
+ constructor(directories) {
114
+ this.directories = directories;
115
+ this.filePath = path.join(directories.root, SECRETS_FILE);
116
+ this.defaultSecrets = {};
117
+ }
118
+
119
+ /**
120
+ * Ensures the secrets file exists, creating an empty one if necessary
121
+ * @private
122
+ */
123
+ _ensureSecretsFile() {
124
+ if (!fs.existsSync(this.filePath)) {
125
+ writeFileAtomicSync(this.filePath, JSON.stringify(this.defaultSecrets), 'utf-8');
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Reads and parses the secrets file
131
+ * @private
132
+ * @returns {SecretKeys}
133
+ */
134
+ _readSecretsFile() {
135
+ this._ensureSecretsFile();
136
+ const fileContents = fs.readFileSync(this.filePath, 'utf-8');
137
+ return /** @type {SecretKeys} */ (JSON.parse(fileContents));
138
+ }
139
+
140
+ /**
141
+ * Writes secrets to the file atomically
142
+ * @private
143
+ * @param {SecretKeys} secrets
144
+ */
145
+ _writeSecretsFile(secrets) {
146
+ writeFileAtomicSync(this.filePath, JSON.stringify(secrets, null, 4), 'utf-8');
147
+ }
148
+
149
+ /**
150
+ * Deactivates all secrets for a given key
151
+ * @private
152
+ * @param {SecretValue[]} secretArray
153
+ */
154
+ _deactivateAllSecrets(secretArray) {
155
+ secretArray.forEach(secret => {
156
+ secret.active = false;
157
+ });
158
+ }
159
+
160
+ /**
161
+ * Validates that the secret key exists and has valid structure
162
+ * @private
163
+ * @param {SecretKeys} secrets
164
+ * @param {string} key
165
+ * @returns {boolean}
166
+ */
167
+ _validateSecretKey(secrets, key) {
168
+ return Object.hasOwn(secrets, key) && Array.isArray(secrets[key]);
169
+ }
170
+
171
+ /**
172
+ * Masks a secret value with asterisks in the middle
173
+ * @param {string} value The secret value to mask
174
+ * @param {string} key The secret key
175
+ * @returns {string} A masked version of the value for peeking
176
+ */
177
+ getMaskedValue(value, key) {
178
+ // No masking if exposure is allowed
179
+ if (allowKeysExposure || EXPORTABLE_KEYS.includes(key)) {
180
+ return value;
181
+ }
182
+ const threshold = 10;
183
+ const exposedChars = 3;
184
+ const placeholder = '*';
185
+ if (value.length <= threshold) {
186
+ return placeholder.repeat(threshold);
187
+ }
188
+ const visibleEnd = value.slice(-exposedChars);
189
+ const maskedMiddle = placeholder.repeat(threshold - exposedChars);
190
+ return `${maskedMiddle}${visibleEnd}`;
191
+ }
192
+
193
+ /**
194
+ * Writes a secret to the secrets file
195
+ * @param {string} key Secret key
196
+ * @param {string} value Secret value
197
+ * @param {string} label Label for the secret
198
+ * @returns {string} The ID of the newly created secret
199
+ */
200
+ writeSecret(key, value, label = 'Unlabeled') {
201
+ const secrets = this._readSecretsFile();
202
+
203
+ if (!Array.isArray(secrets[key])) {
204
+ secrets[key] = [];
205
+ }
206
+
207
+ this._deactivateAllSecrets(secrets[key]);
208
+
209
+ const secret = {
210
+ id: uuidv4(),
211
+ value: value,
212
+ label: label,
213
+ active: true,
214
+ };
215
+ secrets[key].push(secret);
216
+
217
+ this._writeSecretsFile(secrets);
218
+ return secret.id;
219
+ }
220
+
221
+ /**
222
+ * Deletes a secret from the secrets file by its ID
223
+ * @param {string} key Secret key
224
+ * @param {string?} id Secret ID to delete
225
+ */
226
+ deleteSecret(key, id) {
227
+ if (!fs.existsSync(this.filePath)) {
228
+ return;
229
+ }
230
+
231
+ const secrets = this._readSecretsFile();
232
+
233
+ if (!this._validateSecretKey(secrets, key)) {
234
+ return;
235
+ }
236
+
237
+ const secretArray = secrets[key];
238
+ const targetIndex = secretArray.findIndex(s => id ? s.id === id : s.active);
239
+
240
+ // Delete the secret if found
241
+ if (targetIndex !== -1) {
242
+ secretArray.splice(targetIndex, 1);
243
+ }
244
+
245
+ // Reactivate the first secret if none are active
246
+ if (secretArray.length && !secretArray.some(s => s.active)) {
247
+ secretArray[0].active = true;
248
+ }
249
+
250
+ // Remove the key if no secrets left
251
+ if (secretArray.length === 0) {
252
+ delete secrets[key];
253
+ }
254
+
255
+ this._writeSecretsFile(secrets);
256
+ }
257
+
258
+ /**
259
+ * Reads the active secret value for a given key
260
+ * @param {string} key Secret key
261
+ * @param {string?} id ID of the secret to read (optional)
262
+ * @returns {string} Secret value or empty string if not found
263
+ */
264
+ readSecret(key, id) {
265
+ if (!fs.existsSync(this.filePath)) {
266
+ return '';
267
+ }
268
+
269
+ const secrets = this._readSecretsFile();
270
+ const secretArray = secrets[key];
271
+
272
+ if (Array.isArray(secretArray) && secretArray.length > 0) {
273
+ const activeSecret = secretArray.find(s => id ? s.id === id : s.active);
274
+ return activeSecret?.value || '';
275
+ }
276
+
277
+ return '';
278
+ }
279
+
280
+ /**
281
+ * Activates a specific secret by ID for a given key
282
+ * @param {string} key Secret key to rotate
283
+ * @param {string} id ID of the secret to activate
284
+ */
285
+ rotateSecret(key, id) {
286
+ if (!fs.existsSync(this.filePath)) {
287
+ return;
288
+ }
289
+
290
+ const secrets = this._readSecretsFile();
291
+
292
+ if (!this._validateSecretKey(secrets, key)) {
293
+ return;
294
+ }
295
+
296
+ const secretArray = secrets[key];
297
+ const targetIndex = secretArray.findIndex(s => s.id === id);
298
+
299
+ if (targetIndex === -1) {
300
+ console.warn(`Secret with ID ${id} not found for key ${key}`);
301
+ return;
302
+ }
303
+
304
+ this._deactivateAllSecrets(secretArray);
305
+ secretArray[targetIndex].active = true;
306
+
307
+ this._writeSecretsFile(secrets);
308
+ }
309
+
310
+ /**
311
+ * Renames a secret by its ID
312
+ * @param {string} key Secret key to rename
313
+ * @param {string} id ID of the secret to rename
314
+ * @param {string} label New label for the secret
315
+ */
316
+ renameSecret(key, id, label) {
317
+ const secrets = this._readSecretsFile();
318
+
319
+ if (!this._validateSecretKey(secrets, key)) {
320
+ return;
321
+ }
322
+
323
+ const secretArray = secrets[key];
324
+ const targetIndex = secretArray.findIndex(s => s.id === id);
325
+
326
+ if (targetIndex === -1) {
327
+ console.warn(`Secret with ID ${id} not found for key ${key}`);
328
+ return;
329
+ }
330
+
331
+ secretArray[targetIndex].label = label;
332
+ this._writeSecretsFile(secrets);
333
+ }
334
+
335
+ /**
336
+ * Gets the state of all secrets (whether they exist or not)
337
+ * @returns {SecretStateMap} Secret state
338
+ */
339
+ getSecretState() {
340
+ const secrets = this._readSecretsFile();
341
+ /** @type {SecretStateMap} */
342
+ const state = {};
343
+
344
+ for (const key of Object.values(SECRET_KEYS)) {
345
+ // Skip migration marker
346
+ if (key === SECRET_KEYS._MIGRATED) {
347
+ continue;
348
+ }
349
+ const value = secrets[key];
350
+ if (value && Array.isArray(value) && value.length > 0) {
351
+ state[key] = value.map(secret => ({
352
+ id: secret.id,
353
+ value: this.getMaskedValue(secret.value, key),
354
+ label: secret.label,
355
+ active: secret.active,
356
+ }));
357
+ } else {
358
+ // No secrets for this key
359
+ state[key] = null;
360
+ }
361
+ }
362
+
363
+ return state;
364
+ }
365
+
366
+ /**
367
+ * Gets all secrets (for admin viewing)
368
+ * @returns {SecretKeys} All secrets
369
+ */
370
+ getAllSecrets() {
371
+ return this._readSecretsFile();
372
+ }
373
+
374
+ /**
375
+ * Migrates legacy flat secrets format to new format
376
+ */
377
+ migrateFlatSecrets() {
378
+ if (!fs.existsSync(this.filePath)) {
379
+ return;
380
+ }
381
+
382
+ const fileContents = fs.readFileSync(this.filePath, 'utf8');
383
+ const secrets = /** @type {FlatSecretKeys} */ (JSON.parse(fileContents));
384
+ const values = Object.values(secrets);
385
+
386
+ // Check if already migrated
387
+ if (secrets[SECRET_KEYS._MIGRATED] || values.length === 0 || values.some(v => Array.isArray(v))) {
388
+ return;
389
+ }
390
+
391
+ /** @type {SecretKeys} */
392
+ const migratedSecrets = {};
393
+
394
+ for (const [key, value] of Object.entries(secrets)) {
395
+ if (typeof value === 'string' && value.trim()) {
396
+ migratedSecrets[key] = [{
397
+ id: uuidv4(),
398
+ value: value,
399
+ label: key,
400
+ active: true,
401
+ }];
402
+ }
403
+ }
404
+
405
+ // Mark as migrated
406
+ migratedSecrets[SECRET_KEYS._MIGRATED] = [];
407
+
408
+ // Save backup of the old secrets file
409
+ const backupFilePath = path.join(this.directories.backups, `secrets_migration_${Date.now()}.json`);
410
+ fs.cpSync(this.filePath, backupFilePath);
411
+
412
+ this._writeSecretsFile(migratedSecrets);
413
+ console.info(color.green('Secrets migrated successfully, old secrets backed up to:'), backupFilePath);
414
+ }
415
+ }
416
+
417
+ //#region Backwards compatibility
418
+ /**
419
+ * Writes a secret to the secrets file
420
+ * @param {import('../users.js').UserDirectoryList} directories User directories
421
+ * @param {string} key Secret key
422
+ * @param {string} value Secret value
423
+ */
424
+ export function writeSecret(directories, key, value) {
425
+ return new SecretManager(directories).writeSecret(key, value);
426
+ }
427
+
428
+ /**
429
+ * Deletes a secret from the secrets file
430
+ * @param {import('../users.js').UserDirectoryList} directories User directories
431
+ * @param {string} key Secret key
432
+ */
433
+ export function deleteSecret(directories, key) {
434
+ return new SecretManager(directories).deleteSecret(key, null);
435
+ }
436
+
437
+ /**
438
+ * Reads a secret from the secrets file
439
+ * @param {import('../users.js').UserDirectoryList} directories User directories
440
+ * @param {string} key Secret key
441
+ * @returns {string} Secret value
442
+ */
443
+ export function readSecret(directories, key) {
444
+ return new SecretManager(directories).readSecret(key, null);
445
+ }
446
+
447
+ /**
448
+ * Reads the secret state from the secrets file
449
+ * @param {import('../users.js').UserDirectoryList} directories User directories
450
+ * @returns {Record<string, boolean>} Secret state
451
+ */
452
+ export function readSecretState(directories) {
453
+ const state = new SecretManager(directories).getSecretState();
454
+ const result = /** @type {Record<string, boolean>} */ ({});
455
+ for (const key of Object.values(SECRET_KEYS)) {
456
+ // Skip migration marker
457
+ if (key === SECRET_KEYS._MIGRATED) {
458
+ continue;
459
+ }
460
+ result[key] = Array.isArray(state[key]) && state[key].length > 0;
461
+ }
462
+ return result;
463
+ }
464
+
465
+ /**
466
+ * Reads all secrets from the secrets file
467
+ * @param {import('../users.js').UserDirectoryList} directories User directories
468
+ * @returns {Record<string, string>} Secrets
469
+ */
470
+ export function getAllSecrets(directories) {
471
+ const secrets = new SecretManager(directories).getAllSecrets();
472
+ const result = /** @type {Record<string, string>} */ ({});
473
+ for (const [key, values] of Object.entries(secrets)) {
474
+ // Skip migration marker
475
+ if (key === SECRET_KEYS._MIGRATED) {
476
+ continue;
477
+ }
478
+ if (Array.isArray(values) && values.length > 0) {
479
+ const activeSecret = values.find(secret => secret.active);
480
+ if (activeSecret) {
481
+ result[key] = activeSecret.value;
482
+ }
483
+ }
484
+ }
485
+ return result;
486
+ }
487
+ //#endregion
488
+
489
+ /**
490
+ * Migrates legacy flat secrets format to the new format for all user directories
491
+ * @param {import('../users.js').UserDirectoryList[]} directoriesList User directories
492
+ */
493
+ export function migrateFlatSecrets(directoriesList) {
494
+ for (const directories of directoriesList) {
495
+ try {
496
+ const manager = new SecretManager(directories);
497
+ manager.migrateFlatSecrets();
498
+ } catch (error) {
499
+ console.warn(color.red(`Failed to migrate secrets for ${directories.root}:`), error);
500
+ }
501
+ }
502
+ }
503
+
504
+ export const router = express.Router();
505
+
506
+ router.post('/write', (request, response) => {
507
+ try {
508
+ const { key, value, label } = request.body;
509
+
510
+ if (!key || typeof value !== 'string') {
511
+ return response.status(400).send('Invalid key or value');
512
+ }
513
+
514
+ const manager = new SecretManager(request.user.directories);
515
+ const id = manager.writeSecret(key, value, label);
516
+
517
+ return response.send({ id });
518
+ } catch (error) {
519
+ console.error('Error writing secret:', error);
520
+ return response.sendStatus(500);
521
+ }
522
+ });
523
+
524
+ router.post('/read', (request, response) => {
525
+ try {
526
+ const manager = new SecretManager(request.user.directories);
527
+ const state = manager.getSecretState();
528
+ return response.send(state);
529
+ } catch (error) {
530
+ console.error('Error reading secret state:', error);
531
+ return response.send({});
532
+ }
533
+ });
534
+
535
+ router.post('/view', (request, response) => {
536
+ try {
537
+ if (!allowKeysExposure) {
538
+ console.error('secrets.json could not be viewed unless allowKeysExposure in config.yaml is set to true');
539
+ return response.sendStatus(403);
540
+ }
541
+
542
+ const secrets = getAllSecrets(request.user.directories);
543
+
544
+ if (!secrets) {
545
+ return response.sendStatus(404);
546
+ }
547
+
548
+ return response.send(secrets);
549
+ } catch (error) {
550
+ console.error('Error viewing secrets:', error);
551
+ return response.sendStatus(500);
552
+ }
553
+ });
554
+
555
+ router.post('/find', (request, response) => {
556
+ try {
557
+ const { key, id } = request.body;
558
+
559
+ if (!key) {
560
+ return response.status(400).send('Key is required');
561
+ }
562
+
563
+ if (!allowKeysExposure && !EXPORTABLE_KEYS.includes(key)) {
564
+ console.error('Cannot fetch secrets unless allowKeysExposure in config.yaml is set to true');
565
+ return response.sendStatus(403);
566
+ }
567
+
568
+ const manager = new SecretManager(request.user.directories);
569
+ const state = manager.getSecretState();
570
+
571
+ if (!state[key]) {
572
+ return response.sendStatus(404);
573
+ }
574
+
575
+ const secretValue = manager.readSecret(key, id);
576
+ return response.send({ value: secretValue });
577
+ } catch (error) {
578
+ console.error('Error finding secret:', error);
579
+ return response.sendStatus(500);
580
+ }
581
+ });
582
+
583
+ router.post('/delete', (request, response) => {
584
+ try {
585
+ const { key, id } = request.body;
586
+
587
+ if (!key) {
588
+ return response.status(400).send('Key and ID are required');
589
+ }
590
+
591
+ const manager = new SecretManager(request.user.directories);
592
+ manager.deleteSecret(key, id);
593
+
594
+ return response.sendStatus(204);
595
+ } catch (error) {
596
+ console.error('Error deleting secret:', error);
597
+ return response.sendStatus(500);
598
+ }
599
+ });
600
+
601
+ router.post('/rotate', (request, response) => {
602
+ try {
603
+ const { key, id } = request.body;
604
+
605
+ if (!key || !id) {
606
+ return response.status(400).send('Key and ID are required');
607
+ }
608
+
609
+ const manager = new SecretManager(request.user.directories);
610
+ manager.rotateSecret(key, id);
611
+
612
+ return response.sendStatus(204);
613
+ } catch (error) {
614
+ console.error('Error rotating secret:', error);
615
+ return response.sendStatus(500);
616
+ }
617
+ });
618
+
619
+ router.post('/rename', (request, response) => {
620
+ try {
621
+ const { key, id, label } = request.body;
622
+
623
+ if (!key || !id || !label) {
624
+ return response.status(400).send('Key, ID, and label are required');
625
+ }
626
+
627
+ const manager = new SecretManager(request.user.directories);
628
+ manager.renameSecret(key, id, label);
629
+
630
+ return response.sendStatus(204);
631
+ } catch (error) {
632
+ console.error('Error renaming secret:', error);
633
+ return response.sendStatus(500);
634
+ }
635
+ });
src/endpoints/secure-generate.js ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import express from 'express';
2
+ import fetch from 'node-fetch';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { forwardFetchResponse } from '../util.js';
6
+
7
+ export const router = express.Router();
8
+
9
+ function getHiddenPrompts() {
10
+ const HIDDEN_PROMPTS_FILE = path.join(globalThis.DATA_ROOT || '', 'hidden_prompts.json');
11
+ try {
12
+ if (fs.existsSync(HIDDEN_PROMPTS_FILE)) {
13
+ return JSON.parse(fs.readFileSync(HIDDEN_PROMPTS_FILE, 'utf8'));
14
+ }
15
+ } catch (err) {
16
+ console.error('Error reading hidden prompts:', err);
17
+ }
18
+ return {};
19
+ }
20
+
21
+ router.post('/', async (req, res) => {
22
+ const { target_url, hidden_prompt_id, ...llm_params } = req.body;
23
+
24
+ if (!target_url) {
25
+ return res.status(400).send('Missing target_url');
26
+ }
27
+
28
+ const hiddenPrompts = getHiddenPrompts();
29
+ const hiddenPrompt = hiddenPrompts[hidden_prompt_id];
30
+
31
+ if (hiddenPrompt && llm_params.messages) {
32
+ console.log(`Injecting hidden prompt: ${hidden_prompt_id}`);
33
+ // Inject at the beginning of the messages array
34
+ llm_params.messages.unshift({
35
+ role: hiddenPrompt.role || 'system',
36
+ content: hiddenPrompt.prompt
37
+ });
38
+ }
39
+
40
+ try {
41
+ const headers = { ...req.headers };
42
+ // Remove host and other potentially problematic headers
43
+ delete headers.host;
44
+ delete headers['content-length'];
45
+ delete headers['x-csrf-token'];
46
+ delete headers.cookie;
47
+
48
+ const response = await fetch(target_url, {
49
+ method: 'POST',
50
+ headers: headers,
51
+ body: JSON.stringify(llm_params),
52
+ });
53
+
54
+ forwardFetchResponse(response, res);
55
+ } catch (error) {
56
+ console.error('Error in secure-generate proxy:', error);
57
+ res.status(500).send('Error in secure-generate proxy: ' + error.message);
58
+ }
59
+ });
60
+
61
+ router.get('/list', (req, res) => {
62
+ const hiddenPrompts = getHiddenPrompts();
63
+ const list = Object.entries(hiddenPrompts).map(([id, data]) => ({
64
+ id,
65
+ label: data.name || id
66
+ }));
67
+ res.json(list);
68
+ });
src/endpoints/settings.js ADDED
@@ -0,0 +1,371 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import express from 'express';
5
+ import _ from 'lodash';
6
+ import { sync as writeFileAtomicSync } from 'write-file-atomic';
7
+
8
+ import { SETTINGS_FILE } from '../constants.js';
9
+ import { getConfigValue, generateTimestamp, removeOldBackups } from '../util.js';
10
+ import { getAllUserHandles, getUserDirectories } from '../users.js';
11
+ import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
12
+
13
+ const ENABLE_EXTENSIONS = !!getConfigValue('extensions.enabled', true, 'boolean');
14
+ const ENABLE_EXTENSIONS_AUTO_UPDATE = !!getConfigValue('extensions.autoUpdate', true, 'boolean');
15
+ const ENABLE_ACCOUNTS = !!getConfigValue('enableUserAccounts', false, 'boolean');
16
+
17
+ // 10 minutes
18
+ const AUTOSAVE_INTERVAL = 10 * 60 * 1000;
19
+
20
+ /**
21
+ * Map of functions to trigger settings autosave for a user.
22
+ * @type {Map<string, function>}
23
+ */
24
+ const AUTOSAVE_FUNCTIONS = new Map();
25
+
26
+ /**
27
+ * Triggers autosave for a user every 10 minutes.
28
+ * @param {string} handle User handle
29
+ * @returns {void}
30
+ */
31
+ function triggerAutoSave(handle) {
32
+ if (!AUTOSAVE_FUNCTIONS.has(handle)) {
33
+ const throttledAutoSave = _.throttle(() => backupUserSettings(handle, true), AUTOSAVE_INTERVAL);
34
+ AUTOSAVE_FUNCTIONS.set(handle, throttledAutoSave);
35
+ }
36
+
37
+ const functionToCall = AUTOSAVE_FUNCTIONS.get(handle);
38
+ if (functionToCall && typeof functionToCall === 'function') {
39
+ functionToCall();
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Reads and parses files from a directory.
45
+ * @param {string} directoryPath Path to the directory
46
+ * @param {string} fileExtension File extension
47
+ * @returns {Array} Parsed files
48
+ */
49
+ function readAndParseFromDirectory(directoryPath, fileExtension = '.json') {
50
+ const files = fs
51
+ .readdirSync(directoryPath)
52
+ .filter(x => path.parse(x).ext == fileExtension)
53
+ .sort();
54
+
55
+ const parsedFiles = [];
56
+
57
+ files.forEach(item => {
58
+ try {
59
+ const file = fs.readFileSync(path.join(directoryPath, item), 'utf-8');
60
+ parsedFiles.push(fileExtension == '.json' ? JSON.parse(file) : file);
61
+ }
62
+ catch {
63
+ // skip
64
+ }
65
+ });
66
+
67
+ return parsedFiles;
68
+ }
69
+
70
+ /**
71
+ * Gets a sort function for sorting strings.
72
+ * @param {*} _
73
+ * @returns {(a: string, b: string) => number} Sort function
74
+ */
75
+ function sortByName(_) {
76
+ return (a, b) => a.localeCompare(b);
77
+ }
78
+
79
+ /**
80
+ * Gets backup file prefix for user settings.
81
+ * @param {string} handle User handle
82
+ * @returns {string} File prefix
83
+ */
84
+ export function getSettingsBackupFilePrefix(handle) {
85
+ return `settings_${handle}_`;
86
+ }
87
+
88
+ function readPresetsFromDirectory(directoryPath, options = {}) {
89
+ const {
90
+ sortFunction,
91
+ removeFileExtension = false,
92
+ fileExtension = '.json',
93
+ } = options;
94
+
95
+ const files = fs.readdirSync(directoryPath).sort(sortFunction).filter(x => path.parse(x).ext == fileExtension);
96
+ const fileContents = [];
97
+ const fileNames = [];
98
+
99
+ files.forEach(item => {
100
+ try {
101
+ const file = fs.readFileSync(path.join(directoryPath, item), 'utf8');
102
+ JSON.parse(file);
103
+ fileContents.push(file);
104
+ fileNames.push(removeFileExtension ? item.replace(/\.[^/.]+$/, '') : item);
105
+ } catch {
106
+ // skip
107
+ console.warn(`${item} is not a valid JSON`);
108
+ }
109
+ });
110
+
111
+ return { fileContents, fileNames };
112
+ }
113
+
114
+ async function backupSettings() {
115
+ try {
116
+ const userHandles = await getAllUserHandles();
117
+
118
+ for (const handle of userHandles) {
119
+ backupUserSettings(handle, true);
120
+ }
121
+ } catch (err) {
122
+ console.error('Could not backup settings file', err);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Makes a backup of the user's settings file.
128
+ * @param {string} handle User handle
129
+ * @param {boolean} preventDuplicates Prevent duplicate backups
130
+ * @returns {void}
131
+ */
132
+ function backupUserSettings(handle, preventDuplicates) {
133
+ const userDirectories = getUserDirectories(handle);
134
+
135
+ if (!fs.existsSync(userDirectories.root)) {
136
+ return;
137
+ }
138
+
139
+ const backupFile = path.join(userDirectories.backups, `${getSettingsBackupFilePrefix(handle)}${generateTimestamp()}.json`);
140
+ const sourceFile = path.join(userDirectories.root, SETTINGS_FILE);
141
+
142
+ if (preventDuplicates && isDuplicateBackup(handle, sourceFile)) {
143
+ return;
144
+ }
145
+
146
+ if (!fs.existsSync(sourceFile)) {
147
+ return;
148
+ }
149
+
150
+ fs.copyFileSync(sourceFile, backupFile);
151
+ removeOldBackups(userDirectories.backups, `settings_${handle}`);
152
+ }
153
+
154
+ /**
155
+ * Checks if the backup would be a duplicate.
156
+ * @param {string} handle User handle
157
+ * @param {string} sourceFile Source file path
158
+ * @returns {boolean} True if the backup is a duplicate
159
+ */
160
+ function isDuplicateBackup(handle, sourceFile) {
161
+ const latestBackup = getLatestBackup(handle);
162
+ if (!latestBackup) {
163
+ return false;
164
+ }
165
+ return areFilesEqual(latestBackup, sourceFile);
166
+ }
167
+
168
+ /**
169
+ * Returns true if the two files are equal.
170
+ * @param {string} file1 File path
171
+ * @param {string} file2 File path
172
+ */
173
+ function areFilesEqual(file1, file2) {
174
+ if (!fs.existsSync(file1) || !fs.existsSync(file2)) {
175
+ return false;
176
+ }
177
+
178
+ const content1 = fs.readFileSync(file1);
179
+ const content2 = fs.readFileSync(file2);
180
+ return content1.toString() === content2.toString();
181
+ }
182
+
183
+ /**
184
+ * Gets the latest backup file for a user.
185
+ * @param {string} handle User handle
186
+ * @returns {string|null} Latest backup file. Null if no backup exists.
187
+ */
188
+ function getLatestBackup(handle) {
189
+ const userDirectories = getUserDirectories(handle);
190
+ const backupFiles = fs.readdirSync(userDirectories.backups)
191
+ .filter(x => x.startsWith(getSettingsBackupFilePrefix(handle)))
192
+ .map(x => ({ name: x, ctime: fs.statSync(path.join(userDirectories.backups, x)).ctimeMs }));
193
+ const latestBackup = backupFiles.sort((a, b) => b.ctime - a.ctime)[0]?.name;
194
+ if (!latestBackup) {
195
+ return null;
196
+ }
197
+ return path.join(userDirectories.backups, latestBackup);
198
+ }
199
+
200
+ export const router = express.Router();
201
+
202
+ router.post('/save', function (request, response) {
203
+ try {
204
+ const pathToSettings = path.join(request.user.directories.root, SETTINGS_FILE);
205
+ writeFileAtomicSync(pathToSettings, JSON.stringify(request.body, null, 4), 'utf8');
206
+ triggerAutoSave(request.user.profile.handle);
207
+ response.send({ result: 'ok' });
208
+ } catch (err) {
209
+ console.error(err);
210
+ response.send(err);
211
+ }
212
+ });
213
+
214
+ // Wintermute's code
215
+ router.post('/get', (request, response) => {
216
+ let settings;
217
+ try {
218
+ const pathToSettings = path.join(request.user.directories.root, SETTINGS_FILE);
219
+ settings = fs.readFileSync(pathToSettings, 'utf8');
220
+ } catch (e) {
221
+ return response.sendStatus(500);
222
+ }
223
+
224
+ // NovelAI Settings
225
+ const { fileContents: novelai_settings, fileNames: novelai_setting_names }
226
+ = readPresetsFromDirectory(request.user.directories.novelAI_Settings, {
227
+ sortFunction: sortByName(request.user.directories.novelAI_Settings),
228
+ removeFileExtension: true,
229
+ });
230
+
231
+ // OpenAI Settings
232
+ const { fileContents: openai_settings, fileNames: openai_setting_names }
233
+ = readPresetsFromDirectory(request.user.directories.openAI_Settings, {
234
+ sortFunction: sortByName(request.user.directories.openAI_Settings), removeFileExtension: true,
235
+ });
236
+
237
+ // TextGenerationWebUI Settings
238
+ const { fileContents: textgenerationwebui_presets, fileNames: textgenerationwebui_preset_names }
239
+ = readPresetsFromDirectory(request.user.directories.textGen_Settings, {
240
+ sortFunction: sortByName(request.user.directories.textGen_Settings), removeFileExtension: true,
241
+ });
242
+
243
+ //Kobold
244
+ const { fileContents: koboldai_settings, fileNames: koboldai_setting_names }
245
+ = readPresetsFromDirectory(request.user.directories.koboldAI_Settings, {
246
+ sortFunction: sortByName(request.user.directories.koboldAI_Settings), removeFileExtension: true,
247
+ });
248
+
249
+ const worldFiles = fs
250
+ .readdirSync(request.user.directories.worlds)
251
+ .filter(file => path.extname(file).toLowerCase() === '.json')
252
+ .sort((a, b) => a.localeCompare(b));
253
+ const world_names = worldFiles.map(item => path.parse(item).name);
254
+
255
+ const themes = readAndParseFromDirectory(request.user.directories.themes);
256
+ const movingUIPresets = readAndParseFromDirectory(request.user.directories.movingUI);
257
+ const quickReplyPresets = readAndParseFromDirectory(request.user.directories.quickreplies);
258
+
259
+ const instruct = readAndParseFromDirectory(request.user.directories.instruct);
260
+ const context = readAndParseFromDirectory(request.user.directories.context);
261
+ const sysprompt = readAndParseFromDirectory(request.user.directories.sysprompt);
262
+ const reasoning = readAndParseFromDirectory(request.user.directories.reasoning);
263
+
264
+ response.send({
265
+ settings,
266
+ koboldai_settings,
267
+ koboldai_setting_names,
268
+ world_names,
269
+ novelai_settings,
270
+ novelai_setting_names,
271
+ openai_settings,
272
+ openai_setting_names,
273
+ textgenerationwebui_presets,
274
+ textgenerationwebui_preset_names,
275
+ themes,
276
+ movingUIPresets,
277
+ quickReplyPresets,
278
+ instruct,
279
+ context,
280
+ sysprompt,
281
+ reasoning,
282
+ enable_extensions: ENABLE_EXTENSIONS,
283
+ enable_extensions_auto_update: ENABLE_EXTENSIONS_AUTO_UPDATE,
284
+ enable_accounts: ENABLE_ACCOUNTS,
285
+ });
286
+ });
287
+
288
+ router.post('/get-snapshots', async (request, response) => {
289
+ try {
290
+ const snapshots = fs.readdirSync(request.user.directories.backups);
291
+ const userFilesPattern = getSettingsBackupFilePrefix(request.user.profile.handle);
292
+ const userSnapshots = snapshots.filter(x => x.startsWith(userFilesPattern));
293
+
294
+ const result = userSnapshots.map(x => {
295
+ const stat = fs.statSync(path.join(request.user.directories.backups, x));
296
+ return { date: stat.ctimeMs, name: x, size: stat.size };
297
+ });
298
+
299
+ response.json(result);
300
+ } catch (error) {
301
+ console.error(error);
302
+ response.sendStatus(500);
303
+ }
304
+ });
305
+
306
+ router.post('/load-snapshot', getFileNameValidationFunction('name'), async (request, response) => {
307
+ try {
308
+ const userFilesPattern = getSettingsBackupFilePrefix(request.user.profile.handle);
309
+
310
+ if (!request.body.name || !request.body.name.startsWith(userFilesPattern)) {
311
+ return response.status(400).send({ error: 'Invalid snapshot name' });
312
+ }
313
+
314
+ const snapshotName = request.body.name;
315
+ const snapshotPath = path.join(request.user.directories.backups, snapshotName);
316
+
317
+ if (!fs.existsSync(snapshotPath)) {
318
+ return response.sendStatus(404);
319
+ }
320
+
321
+ const content = fs.readFileSync(snapshotPath, 'utf8');
322
+
323
+ response.send(content);
324
+ } catch (error) {
325
+ console.error(error);
326
+ response.sendStatus(500);
327
+ }
328
+ });
329
+
330
+ router.post('/make-snapshot', async (request, response) => {
331
+ try {
332
+ backupUserSettings(request.user.profile.handle, false);
333
+ response.sendStatus(204);
334
+ } catch (error) {
335
+ console.error(error);
336
+ response.sendStatus(500);
337
+ }
338
+ });
339
+
340
+ router.post('/restore-snapshot', getFileNameValidationFunction('name'), async (request, response) => {
341
+ try {
342
+ const userFilesPattern = getSettingsBackupFilePrefix(request.user.profile.handle);
343
+
344
+ if (!request.body.name || !request.body.name.startsWith(userFilesPattern)) {
345
+ return response.status(400).send({ error: 'Invalid snapshot name' });
346
+ }
347
+
348
+ const snapshotName = request.body.name;
349
+ const snapshotPath = path.join(request.user.directories.backups, snapshotName);
350
+
351
+ if (!fs.existsSync(snapshotPath)) {
352
+ return response.sendStatus(404);
353
+ }
354
+
355
+ const pathToSettings = path.join(request.user.directories.root, SETTINGS_FILE);
356
+ fs.rmSync(pathToSettings, { force: true });
357
+ fs.copyFileSync(snapshotPath, pathToSettings);
358
+
359
+ response.sendStatus(204);
360
+ } catch (error) {
361
+ console.error(error);
362
+ response.sendStatus(500);
363
+ }
364
+ });
365
+
366
+ /**
367
+ * Initializes the settings endpoint
368
+ */
369
+ export async function init() {
370
+ await backupSettings();
371
+ }
src/endpoints/speech.js ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Buffer } from 'node:buffer';
2
+ import fs from 'node:fs';
3
+ import express from 'express';
4
+ import wavefile from 'wavefile';
5
+ import fetch from 'node-fetch';
6
+ import FormData from 'form-data';
7
+ import mime from 'mime-types';
8
+ import { getPipeline } from '../transformers.js';
9
+ import { forwardFetchResponse } from '../util.js';
10
+ import { readSecret, SECRET_KEYS } from './secrets.js';
11
+
12
+ export const router = express.Router();
13
+
14
+ /**
15
+ * Gets the audio data from a base64-encoded audio file.
16
+ * @param {string} audio Base64-encoded audio
17
+ * @returns {Float64Array} Audio data
18
+ */
19
+ function getWaveFile(audio) {
20
+ const wav = new wavefile.WaveFile();
21
+ wav.fromDataURI(audio);
22
+ wav.toBitDepth('32f');
23
+ wav.toSampleRate(16000);
24
+ let audioData = wav.getSamples();
25
+ if (Array.isArray(audioData)) {
26
+ if (audioData.length > 1) {
27
+ const SCALING_FACTOR = Math.sqrt(2);
28
+
29
+ // Merge channels (into first channel to save memory)
30
+ for (let i = 0; i < audioData[0].length; ++i) {
31
+ audioData[0][i] = SCALING_FACTOR * (audioData[0][i] + audioData[1][i]) / 2;
32
+ }
33
+ }
34
+
35
+ // Select first channel
36
+ audioData = audioData[0];
37
+ }
38
+
39
+ return audioData;
40
+ }
41
+
42
+ router.post('/recognize', async (req, res) => {
43
+ try {
44
+ const TASK = 'automatic-speech-recognition';
45
+ const { model, audio, lang } = req.body;
46
+ const pipe = await getPipeline(TASK, model);
47
+ const wav = getWaveFile(audio);
48
+ const start = performance.now();
49
+ const result = await pipe(wav, { language: lang || null, task: 'transcribe' });
50
+ const end = performance.now();
51
+ console.info(`Execution duration: ${(end - start) / 1000} seconds`);
52
+ console.info('Transcribed audio:', result.text);
53
+
54
+ return res.json({ text: result.text });
55
+ } catch (error) {
56
+ console.error(error);
57
+ return res.sendStatus(500);
58
+ }
59
+ });
60
+
61
+ router.post('/synthesize', async (req, res) => {
62
+ try {
63
+ const TASK = 'text-to-speech';
64
+ const { text, model, speaker } = req.body;
65
+ const pipe = await getPipeline(TASK, model);
66
+ const speaker_embeddings = speaker
67
+ ? new Float32Array(new Uint8Array(Buffer.from(speaker.startsWith('data:') ? speaker.split(',')[1] : speaker, 'base64')).buffer)
68
+ : null;
69
+ const start = performance.now();
70
+ const result = await pipe(text, { speaker_embeddings: speaker_embeddings });
71
+ const end = performance.now();
72
+ console.debug(`Execution duration: ${(end - start) / 1000} seconds`);
73
+
74
+ const wav = new wavefile.WaveFile();
75
+ wav.fromScratch(1, result.sampling_rate, '32f', result.audio);
76
+ const buffer = wav.toBuffer();
77
+
78
+ res.set('Content-Type', 'audio/wav');
79
+ return res.send(Buffer.from(buffer));
80
+ } catch (error) {
81
+ console.error(error);
82
+ return res.sendStatus(500);
83
+ }
84
+ });
85
+
86
+ const pollinations = express.Router();
87
+
88
+ pollinations.post('/voices', async (req, res) => {
89
+ try {
90
+ const model = req.body.model || 'openai-audio';
91
+
92
+ const response = await fetch('https://text.pollinations.ai/models');
93
+
94
+ if (!response.ok) {
95
+ throw new Error('Failed to fetch Pollinations models');
96
+ }
97
+
98
+ const data = await response.json();
99
+
100
+ if (!Array.isArray(data)) {
101
+ throw new Error('Invalid data format received from Pollinations');
102
+ }
103
+
104
+ const audioModelData = data.find(m => m.name === model);
105
+ if (!audioModelData || !Array.isArray(audioModelData.voices)) {
106
+ throw new Error('No voices found for the specified model');
107
+ }
108
+
109
+ const voices = audioModelData.voices;
110
+ return res.json(voices);
111
+ } catch (error) {
112
+ console.error(error);
113
+ return res.sendStatus(500);
114
+ }
115
+ });
116
+
117
+ pollinations.post('/generate', async (req, res) => {
118
+ try {
119
+ const text = req.body.text;
120
+ const model = req.body.model || 'openai-audio';
121
+ const voice = req.body.voice || 'alloy';
122
+
123
+ const url = new URL(`https://text.pollinations.ai/generate/${encodeURIComponent(text)}`);
124
+ url.searchParams.append('model', model);
125
+ url.searchParams.append('voice', voice);
126
+ url.searchParams.append('referrer', 'tavernintern');
127
+ console.info('Pollinations request URL:', url.toString());
128
+
129
+ const response = await fetch(url);
130
+
131
+ if (!response.ok) {
132
+ const text = await response.text();
133
+ throw new Error(`Failed to generate audio from Pollinations: ${text}`);
134
+ }
135
+
136
+ res.set('Content-Type', 'audio/mpeg');
137
+ forwardFetchResponse(response, res);
138
+ } catch (error) {
139
+ console.error(error);
140
+ return res.sendStatus(500);
141
+ }
142
+ });
143
+
144
+ router.use('/pollinations', pollinations);
145
+
146
+ const elevenlabs = express.Router();
147
+
148
+ elevenlabs.post('/voices', async (req, res) => {
149
+ try {
150
+ const apiKey = readSecret(req.user.directories, SECRET_KEYS.ELEVENLABS);
151
+ if (!apiKey) {
152
+ console.warn('ElevenLabs API key not found');
153
+ return res.sendStatus(400);
154
+ }
155
+
156
+ const response = await fetch('https://api.elevenlabs.io/v1/voices', {
157
+ headers: {
158
+ 'xi-api-key': apiKey,
159
+ },
160
+ });
161
+
162
+ if (!response.ok) {
163
+ const text = await response.text();
164
+ console.warn(`ElevenLabs voices fetch failed: HTTP ${response.status} - ${text}`);
165
+ return res.sendStatus(500);
166
+ }
167
+
168
+ const responseJson = await response.json();
169
+ return res.json(responseJson);
170
+ } catch (error) {
171
+ console.error(error);
172
+ return res.sendStatus(500);
173
+ }
174
+ });
175
+
176
+ elevenlabs.post('/voice-settings', async (req, res) => {
177
+ try {
178
+ const apiKey = readSecret(req.user.directories, SECRET_KEYS.ELEVENLABS);
179
+ if (!apiKey) {
180
+ console.warn('ElevenLabs API key not found');
181
+ return res.sendStatus(400);
182
+ }
183
+
184
+ const response = await fetch('https://api.elevenlabs.io/v1/voices/settings/default', {
185
+ headers: {
186
+ 'xi-api-key': apiKey,
187
+ },
188
+ });
189
+
190
+ if (!response.ok) {
191
+ const text = await response.text();
192
+ console.warn(`ElevenLabs voice settings fetch failed: HTTP ${response.status} - ${text}`);
193
+ return res.sendStatus(500);
194
+ }
195
+ const responseJson = await response.json();
196
+ return res.json(responseJson);
197
+ } catch (error) {
198
+ console.error(error);
199
+ return res.sendStatus(500);
200
+ }
201
+ });
202
+
203
+ elevenlabs.post('/synthesize', async (req, res) => {
204
+ try {
205
+ const apiKey = readSecret(req.user.directories, SECRET_KEYS.ELEVENLABS);
206
+ if (!apiKey) {
207
+ console.warn('ElevenLabs API key not found');
208
+ return res.sendStatus(400);
209
+ }
210
+
211
+ const { voiceId, request } = req.body;
212
+
213
+ if (!voiceId || !request) {
214
+ console.warn('ElevenLabs synthesis request missing voiceId or request body');
215
+ return res.sendStatus(400);
216
+ }
217
+
218
+ console.debug('ElevenLabs TTS request:', request);
219
+
220
+ const response = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, {
221
+ method: 'POST',
222
+ headers: {
223
+ 'xi-api-key': apiKey,
224
+ 'Content-Type': 'application/json',
225
+ },
226
+ body: JSON.stringify(request),
227
+ });
228
+
229
+ if (!response.ok) {
230
+ const text = await response.text();
231
+ console.warn(`ElevenLabs synthesis failed: HTTP ${response.status} - ${text}`);
232
+ return res.sendStatus(500);
233
+ }
234
+
235
+ res.set('Content-Type', 'audio/mpeg');
236
+ forwardFetchResponse(response, res);
237
+ } catch (error) {
238
+ console.error(error);
239
+ return res.sendStatus(500);
240
+ }
241
+ });
242
+
243
+ elevenlabs.post('/history', async (req, res) => {
244
+ try {
245
+ const apiKey = readSecret(req.user.directories, SECRET_KEYS.ELEVENLABS);
246
+ if (!apiKey) {
247
+ console.warn('ElevenLabs API key not found');
248
+ return res.sendStatus(400);
249
+ }
250
+
251
+ const response = await fetch('https://api.elevenlabs.io/v1/history', {
252
+ headers: {
253
+ 'xi-api-key': apiKey,
254
+ },
255
+ });
256
+
257
+ if (!response.ok) {
258
+ const text = await response.text();
259
+ console.warn(`ElevenLabs history fetch failed: HTTP ${response.status} - ${text}`);
260
+ return res.sendStatus(500);
261
+ }
262
+
263
+ const responseJson = await response.json();
264
+ return res.json(responseJson);
265
+ } catch (error) {
266
+ console.error(error);
267
+ return res.sendStatus(500);
268
+ }
269
+ });
270
+
271
+ elevenlabs.post('/history-audio', async (req, res) => {
272
+ try {
273
+ const apiKey = readSecret(req.user.directories, SECRET_KEYS.ELEVENLABS);
274
+ if (!apiKey) {
275
+ console.warn('ElevenLabs API key not found');
276
+ return res.sendStatus(400);
277
+ }
278
+
279
+ const { historyItemId } = req.body;
280
+ if (!historyItemId) {
281
+ console.warn('ElevenLabs history audio request missing historyItemId');
282
+ return res.sendStatus(400);
283
+ }
284
+
285
+ console.debug('ElevenLabs history audio request for ID:', historyItemId);
286
+
287
+ const response = await fetch(`https://api.elevenlabs.io/v1/history/${historyItemId}/audio`, {
288
+ headers: {
289
+ 'xi-api-key': apiKey,
290
+ },
291
+ });
292
+
293
+ if (!response.ok) {
294
+ const text = await response.text();
295
+ console.warn(`ElevenLabs history audio fetch failed: HTTP ${response.status} - ${text}`);
296
+ return res.sendStatus(500);
297
+ }
298
+
299
+ res.set('Content-Type', 'audio/mpeg');
300
+ forwardFetchResponse(response, res);
301
+ } catch (error) {
302
+ console.error(error);
303
+ return res.sendStatus(500);
304
+ }
305
+ });
306
+
307
+ elevenlabs.post('/voices/add', async (req, res) => {
308
+ try {
309
+ const apiKey = readSecret(req.user.directories, SECRET_KEYS.ELEVENLABS);
310
+ if (!apiKey) {
311
+ console.warn('ElevenLabs API key not found');
312
+ return res.sendStatus(400);
313
+ }
314
+
315
+ const { name, description, labels, files } = req.body;
316
+
317
+ const formData = new FormData();
318
+ formData.append('name', name || 'Custom Voice');
319
+ formData.append('description', description || 'Uploaded via TavernIntern');
320
+ formData.append('labels', labels || '');
321
+
322
+ for (const fileData of (files || [])) {
323
+ const [mimeType, base64Data] = /^data:(.+);base64,(.+)$/.exec(fileData)?.slice(1) || [];
324
+ if (!mimeType || !base64Data) {
325
+ console.warn('Invalid audio file data provided for ElevenLabs voice upload');
326
+ continue;
327
+ }
328
+ const buffer = Buffer.from(base64Data, 'base64');
329
+ formData.append('files', buffer, {
330
+ filename: `audio.${mime.extension(mimeType) || 'wav'}`,
331
+ contentType: mimeType,
332
+ });
333
+ }
334
+
335
+ console.debug('ElevenLabs voice upload request:', { name, description, labels, files: files?.length || 0 });
336
+
337
+ const response = await fetch('https://api.elevenlabs.io/v1/voices/add', {
338
+ method: 'POST',
339
+ headers: {
340
+ 'xi-api-key': apiKey,
341
+ },
342
+ body: formData,
343
+ });
344
+
345
+ if (!response.ok) {
346
+ const text = await response.text();
347
+ console.warn(`ElevenLabs voice upload failed: HTTP ${response.status} - ${text}`);
348
+ return res.sendStatus(500);
349
+ }
350
+
351
+ const responseJson = await response.json();
352
+ return res.json(responseJson);
353
+ } catch (error) {
354
+ console.error(error);
355
+ return res.sendStatus(500);
356
+ }
357
+ });
358
+
359
+ elevenlabs.post('/recognize', async (req, res) => {
360
+ try {
361
+ const apiKey = readSecret(req.user.directories, SECRET_KEYS.ELEVENLABS);
362
+ if (!apiKey) {
363
+ console.warn('ElevenLabs API key not found');
364
+ return res.sendStatus(400);
365
+ }
366
+
367
+ if (!req.file) {
368
+ console.warn('No audio file found');
369
+ return res.sendStatus(400);
370
+ }
371
+
372
+ console.info('Processing audio file with ElevenLabs', req.file.path);
373
+ const formData = new FormData();
374
+ formData.append('file', fs.createReadStream(req.file.path), { filename: 'audio.wav', contentType: 'audio/wav' });
375
+ formData.append('model_id', req.body.model);
376
+
377
+ const response = await fetch('https://api.elevenlabs.io/v1/speech-to-text', {
378
+ method: 'POST',
379
+ headers: {
380
+ 'xi-api-key': apiKey,
381
+ },
382
+ body: formData,
383
+ });
384
+
385
+ if (!response.ok) {
386
+ const text = await response.text();
387
+ console.warn(`ElevenLabs speech recognition failed: HTTP ${response.status} - ${text}`);
388
+ return res.sendStatus(500);
389
+ }
390
+
391
+ fs.unlinkSync(req.file.path);
392
+ const responseJson = await response.json();
393
+ console.debug('ElevenLabs speech recognition response:', responseJson);
394
+ return res.json(responseJson);
395
+ } catch (error) {
396
+ console.error(error);
397
+ return res.sendStatus(500);
398
+ }
399
+ });
400
+
401
+ router.use('/elevenlabs', elevenlabs);
src/endpoints/sprites.js ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import express from 'express';
5
+ import mime from 'mime-types';
6
+ import sanitize from 'sanitize-filename';
7
+ import { sync as writeFileAtomicSync } from 'write-file-atomic';
8
+
9
+ import { getImageBuffers } from '../util.js';
10
+
11
+ /**
12
+ * Gets the path to the sprites folder for the provided character name
13
+ * @param {import('../users.js').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.js').UserDirectoryList} directories User directories
45
+ * @param {object} data RisuAI character data
46
+ * @returns {void}
47
+ */
48
+ export 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 = getSpritesPath(directories, name, false);
75
+
76
+ // Invalid sprites path
77
+ if (!spritesPath) {
78
+ return;
79
+ }
80
+
81
+ // Create sprites folder if it doesn't exist
82
+ if (!fs.existsSync(spritesPath)) {
83
+ fs.mkdirSync(spritesPath, { recursive: true });
84
+ }
85
+
86
+ // Path to sprites is not a directory. This should never happen.
87
+ if (!fs.statSync(spritesPath).isDirectory()) {
88
+ return;
89
+ }
90
+
91
+ console.info(`RisuAI: Found ${images.length} sprites for ${name}. Writing to disk.`);
92
+ const files = fs.readdirSync(spritesPath);
93
+
94
+ outer: for (const [label, fileBase64] of images) {
95
+ // Remove existing sprite with the same label
96
+ for (const file of files) {
97
+ if (path.parse(file).name === label) {
98
+ console.warn(`RisuAI: The sprite ${label} for ${name} already exists. Skipping.`);
99
+ continue outer;
100
+ }
101
+ }
102
+
103
+ const filename = label + '.png';
104
+ const pathToFile = path.join(spritesPath, sanitize(filename));
105
+ writeFileAtomicSync(pathToFile, fileBase64, { encoding: 'base64' });
106
+ }
107
+
108
+ // Remove additionalAssets and emotions from data (they are now in the sprites folder)
109
+ delete data.data.extensions.risuai.additionalAssets;
110
+ delete data.data.extensions.risuai.emotions;
111
+ } catch (error) {
112
+ console.error(error);
113
+ }
114
+ }
115
+
116
+ export const router = express.Router();
117
+
118
+ router.get('/get', function (request, response) {
119
+ const name = String(request.query.name);
120
+ const isSubfolder = name.includes('/');
121
+ const spritesPath = getSpritesPath(request.user.directories, name, isSubfolder);
122
+ let sprites = [];
123
+
124
+ try {
125
+ if (spritesPath && fs.existsSync(spritesPath) && fs.statSync(spritesPath).isDirectory()) {
126
+ sprites = fs.readdirSync(spritesPath)
127
+ .filter(file => {
128
+ const mimeType = mime.lookup(file);
129
+ return mimeType && mimeType.startsWith('image/');
130
+ })
131
+ .map((file) => {
132
+ const pathToSprite = path.join(spritesPath, file);
133
+ const mtime = fs.statSync(pathToSprite).mtime?.toISOString().replace(/[^0-9]/g, '').slice(0, 14);
134
+
135
+ const fileName = path.parse(pathToSprite).name.toLowerCase();
136
+ // Extract the label from the filename via regex, which can be suffixed with a sub-name, either connected with a dash or a dot.
137
+ // Examples: joy.png, joy-1.png, joy.expressive.png
138
+ const label = fileName.match(/^(.+?)(?:[-\\.].*?)?$/)?.[1] ?? fileName;
139
+
140
+ return {
141
+ label: label,
142
+ path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''),
143
+ };
144
+ });
145
+ }
146
+ }
147
+ catch (err) {
148
+ console.error(err);
149
+ }
150
+ return response.send(sprites);
151
+ });
152
+
153
+ router.post('/delete', async (request, response) => {
154
+ const label = request.body.label;
155
+ const name = String(request.body.name);
156
+ const isSubfolder = name.includes('/');
157
+ const spriteName = request.body.spriteName || label;
158
+
159
+ if (!spriteName || !name) {
160
+ return response.sendStatus(400);
161
+ }
162
+
163
+ try {
164
+ const spritesPath = getSpritesPath(request.user.directories, name, isSubfolder);
165
+
166
+ // No sprites folder exists, or not a directory
167
+ if (!spritesPath || !fs.existsSync(spritesPath) || !fs.statSync(spritesPath).isDirectory()) {
168
+ return response.sendStatus(404);
169
+ }
170
+
171
+ const files = fs.readdirSync(spritesPath);
172
+
173
+ // Remove existing sprite with the same label
174
+ for (const file of files) {
175
+ if (path.parse(file).name === spriteName) {
176
+ fs.unlinkSync(path.join(spritesPath, file));
177
+ }
178
+ }
179
+
180
+ return response.sendStatus(200);
181
+ } catch (error) {
182
+ console.error(error);
183
+ return response.sendStatus(500);
184
+ }
185
+ });
186
+
187
+ router.post('/upload-zip', async (request, response) => {
188
+ const file = request.file;
189
+ const name = String(request.body.name);
190
+ const isSubfolder = name.includes('/');
191
+
192
+ if (!file || !name) {
193
+ return response.sendStatus(400);
194
+ }
195
+
196
+ try {
197
+ const spritesPath = getSpritesPath(request.user.directories, name, isSubfolder);
198
+
199
+ // Invalid sprites path
200
+ if (!spritesPath) {
201
+ return response.sendStatus(400);
202
+ }
203
+
204
+ // Create sprites folder if it doesn't exist
205
+ if (!fs.existsSync(spritesPath)) {
206
+ fs.mkdirSync(spritesPath, { recursive: true });
207
+ }
208
+
209
+ // Path to sprites is not a directory. This should never happen.
210
+ if (!fs.statSync(spritesPath).isDirectory()) {
211
+ return response.sendStatus(404);
212
+ }
213
+
214
+ const spritePackPath = path.join(file.destination, file.filename);
215
+ const sprites = await getImageBuffers(spritePackPath);
216
+ const files = fs.readdirSync(spritesPath);
217
+
218
+ for (const [filename, buffer] of sprites) {
219
+ // Remove existing sprite with the same label
220
+ const existingFile = files.find(file => path.parse(file).name === path.parse(filename).name);
221
+
222
+ if (existingFile) {
223
+ fs.unlinkSync(path.join(spritesPath, existingFile));
224
+ }
225
+
226
+ // Write sprite buffer to disk
227
+ const pathToSprite = path.join(spritesPath, sanitize(filename));
228
+ writeFileAtomicSync(pathToSprite, buffer);
229
+ }
230
+
231
+ // Remove uploaded ZIP file
232
+ fs.unlinkSync(spritePackPath);
233
+ return response.send({ ok: true, count: sprites.length });
234
+ } catch (error) {
235
+ console.error(error);
236
+ return response.sendStatus(500);
237
+ }
238
+ });
239
+
240
+ router.post('/upload', async (request, response) => {
241
+ const file = request.file;
242
+ const label = request.body.label;
243
+ const name = String(request.body.name);
244
+ const isSubfolder = name.includes('/');
245
+ const spriteName = request.body.spriteName || label;
246
+
247
+ if (!file || !label || !name) {
248
+ return response.sendStatus(400);
249
+ }
250
+
251
+ try {
252
+ const spritesPath = getSpritesPath(request.user.directories, name, isSubfolder);
253
+
254
+ // Invalid sprites path
255
+ if (!spritesPath) {
256
+ return response.sendStatus(400);
257
+ }
258
+
259
+ // Create sprites folder if it doesn't exist
260
+ if (!fs.existsSync(spritesPath)) {
261
+ fs.mkdirSync(spritesPath, { recursive: true });
262
+ }
263
+
264
+ // Path to sprites is not a directory. This should never happen.
265
+ if (!fs.statSync(spritesPath).isDirectory()) {
266
+ return response.sendStatus(404);
267
+ }
268
+
269
+ const files = fs.readdirSync(spritesPath);
270
+
271
+ // Remove existing sprite with the same label
272
+ for (const file of files) {
273
+ if (path.parse(file).name === spriteName) {
274
+ fs.unlinkSync(path.join(spritesPath, file));
275
+ }
276
+ }
277
+
278
+ const filename = spriteName + path.parse(file.originalname).ext;
279
+ const spritePath = path.join(file.destination, file.filename);
280
+ const pathToFile = path.join(spritesPath, sanitize(filename));
281
+ // Copy uploaded file to sprites folder
282
+ fs.cpSync(spritePath, pathToFile);
283
+ // Remove uploaded file
284
+ fs.unlinkSync(spritePath);
285
+ return response.send({ ok: true });
286
+ } catch (error) {
287
+ console.error(error);
288
+ return response.sendStatus(500);
289
+ }
290
+ });
src/endpoints/stable-diffusion.js ADDED
@@ -0,0 +1,1822 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import express from 'express';
5
+ import fetch from 'node-fetch';
6
+ import sanitize from 'sanitize-filename';
7
+ import { sync as writeFileAtomicSync } from 'write-file-atomic';
8
+ import FormData from 'form-data';
9
+ import urlJoin from 'url-join';
10
+ import _ from 'lodash';
11
+
12
+ import { delay, getBasicAuthHeader, isValidUrl, tryParse } from '../util.js';
13
+ import { readSecret, SECRET_KEYS } from './secrets.js';
14
+ import { AIMLAPI_HEADERS } from '../constants.js';
15
+
16
+ /**
17
+ * Gets the comfy workflows.
18
+ * @param {import('../users.js').UserDirectoryList} directories
19
+ * @returns {string[]} List of comfy workflows
20
+ */
21
+ function getComfyWorkflows(directories) {
22
+ return fs
23
+ .readdirSync(directories.comfyWorkflows)
24
+ .filter(file => file[0] !== '.' && file.toLowerCase().endsWith('.json'))
25
+ .sort(Intl.Collator().compare);
26
+ }
27
+
28
+ export const router = express.Router();
29
+
30
+ router.post('/ping', async (request, response) => {
31
+ try {
32
+ const url = new URL(request.body.url);
33
+ url.pathname = '/sdapi/v1/options';
34
+
35
+ const result = await fetch(url, {
36
+ method: 'GET',
37
+ headers: {
38
+ 'Authorization': getBasicAuthHeader(request.body.auth),
39
+ },
40
+ });
41
+
42
+ if (!result.ok) {
43
+ throw new Error('SD WebUI returned an error.');
44
+ }
45
+
46
+ return response.sendStatus(200);
47
+ } catch (error) {
48
+ console.error(error);
49
+ return response.sendStatus(500);
50
+ }
51
+ });
52
+
53
+ router.post('/upscalers', async (request, response) => {
54
+ try {
55
+ async function getUpscalerModels() {
56
+ const url = new URL(request.body.url);
57
+ url.pathname = '/sdapi/v1/upscalers';
58
+
59
+ const result = await fetch(url, {
60
+ method: 'GET',
61
+ headers: {
62
+ 'Authorization': getBasicAuthHeader(request.body.auth),
63
+ },
64
+ });
65
+
66
+ if (!result.ok) {
67
+ throw new Error('SD WebUI returned an error.');
68
+ }
69
+
70
+ /** @type {any} */
71
+ const data = await result.json();
72
+ return data.map(x => x.name);
73
+ }
74
+
75
+ async function getLatentUpscalers() {
76
+ const url = new URL(request.body.url);
77
+ url.pathname = '/sdapi/v1/latent-upscale-modes';
78
+
79
+ const result = await fetch(url, {
80
+ method: 'GET',
81
+ headers: {
82
+ 'Authorization': getBasicAuthHeader(request.body.auth),
83
+ },
84
+ });
85
+
86
+ if (!result.ok) {
87
+ throw new Error('SD WebUI returned an error.');
88
+ }
89
+
90
+ /** @type {any} */
91
+ const data = await result.json();
92
+ return data.map(x => x.name);
93
+ }
94
+
95
+ const [upscalers, latentUpscalers] = await Promise.all([getUpscalerModels(), getLatentUpscalers()]);
96
+
97
+ // 0 = None, then Latent Upscalers, then Upscalers
98
+ upscalers.splice(1, 0, ...latentUpscalers);
99
+
100
+ return response.send(upscalers);
101
+ } catch (error) {
102
+ console.error(error);
103
+ return response.sendStatus(500);
104
+ }
105
+ });
106
+
107
+ router.post('/vaes', async (request, response) => {
108
+ try {
109
+ const autoUrl = new URL(request.body.url);
110
+ autoUrl.pathname = '/sdapi/v1/sd-vae';
111
+ const forgeUrl = new URL(request.body.url);
112
+ forgeUrl.pathname = '/sdapi/v1/sd-modules';
113
+
114
+ const requestInit = {
115
+ method: 'GET',
116
+ headers: {
117
+ 'Authorization': getBasicAuthHeader(request.body.auth),
118
+ },
119
+ };
120
+ const results = await Promise.allSettled([
121
+ fetch(autoUrl, requestInit).then(r => r.ok ? r.json() : Promise.reject(r.statusText)),
122
+ fetch(forgeUrl, requestInit).then(r => r.ok ? r.json() : Promise.reject(r.statusText)),
123
+ ]);
124
+
125
+ const data = results.find(r => r.status === 'fulfilled')?.value;
126
+
127
+ if (!Array.isArray(data)) {
128
+ throw new Error('SD WebUI returned an error.');
129
+ }
130
+
131
+ const names = data.map(x => x.model_name);
132
+ return response.send(names);
133
+ } catch (error) {
134
+ console.error(error);
135
+ return response.sendStatus(500);
136
+ }
137
+ });
138
+
139
+ router.post('/samplers', async (request, response) => {
140
+ try {
141
+ const url = new URL(request.body.url);
142
+ url.pathname = '/sdapi/v1/samplers';
143
+
144
+ const result = await fetch(url, {
145
+ method: 'GET',
146
+ headers: {
147
+ 'Authorization': getBasicAuthHeader(request.body.auth),
148
+ },
149
+ });
150
+
151
+ if (!result.ok) {
152
+ throw new Error('SD WebUI returned an error.');
153
+ }
154
+
155
+ /** @type {any} */
156
+ const data = await result.json();
157
+ const names = data.map(x => x.name);
158
+ return response.send(names);
159
+
160
+ } catch (error) {
161
+ console.error(error);
162
+ return response.sendStatus(500);
163
+ }
164
+ });
165
+
166
+ router.post('/schedulers', async (request, response) => {
167
+ try {
168
+ const url = new URL(request.body.url);
169
+ url.pathname = '/sdapi/v1/schedulers';
170
+
171
+ const result = await fetch(url, {
172
+ method: 'GET',
173
+ headers: {
174
+ 'Authorization': getBasicAuthHeader(request.body.auth),
175
+ },
176
+ });
177
+
178
+ if (!result.ok) {
179
+ throw new Error('SD WebUI returned an error.');
180
+ }
181
+
182
+ /** @type {any} */
183
+ const data = await result.json();
184
+ const names = data.map(x => x.name);
185
+ return response.send(names);
186
+ } catch (error) {
187
+ console.error(error);
188
+ return response.sendStatus(500);
189
+ }
190
+ });
191
+
192
+ router.post('/models', async (request, response) => {
193
+ try {
194
+ const url = new URL(request.body.url);
195
+ url.pathname = '/sdapi/v1/sd-models';
196
+
197
+ const result = await fetch(url, {
198
+ method: 'GET',
199
+ headers: {
200
+ 'Authorization': getBasicAuthHeader(request.body.auth),
201
+ },
202
+ });
203
+
204
+ if (!result.ok) {
205
+ throw new Error('SD WebUI returned an error.');
206
+ }
207
+
208
+ /** @type {any} */
209
+ const data = await result.json();
210
+ const models = data.map(x => ({ value: x.title, text: x.title }));
211
+ return response.send(models);
212
+ } catch (error) {
213
+ console.error(error);
214
+ return response.sendStatus(500);
215
+ }
216
+ });
217
+
218
+ router.post('/get-model', async (request, response) => {
219
+ try {
220
+ const url = new URL(request.body.url);
221
+ url.pathname = '/sdapi/v1/options';
222
+
223
+ const result = await fetch(url, {
224
+ method: 'GET',
225
+ headers: {
226
+ 'Authorization': getBasicAuthHeader(request.body.auth),
227
+ },
228
+ });
229
+ /** @type {any} */
230
+ const data = await result.json();
231
+ return response.send(data['sd_model_checkpoint']);
232
+ } catch (error) {
233
+ console.error(error);
234
+ return response.sendStatus(500);
235
+ }
236
+ });
237
+
238
+ router.post('/set-model', async (request, response) => {
239
+ try {
240
+ async function getProgress() {
241
+ const url = new URL(request.body.url);
242
+ url.pathname = '/sdapi/v1/progress';
243
+
244
+ const result = await fetch(url, {
245
+ method: 'GET',
246
+ headers: {
247
+ 'Authorization': getBasicAuthHeader(request.body.auth),
248
+ },
249
+ });
250
+ return await result.json();
251
+ }
252
+
253
+ const url = new URL(request.body.url);
254
+ url.pathname = '/sdapi/v1/options';
255
+
256
+ const options = {
257
+ sd_model_checkpoint: request.body.model,
258
+ };
259
+
260
+ const result = await fetch(url, {
261
+ method: 'POST',
262
+ body: JSON.stringify(options),
263
+ headers: {
264
+ 'Content-Type': 'application/json',
265
+ 'Authorization': getBasicAuthHeader(request.body.auth),
266
+ },
267
+ });
268
+
269
+ if (!result.ok) {
270
+ throw new Error('SD WebUI returned an error.');
271
+ }
272
+
273
+ const MAX_ATTEMPTS = 10;
274
+ const CHECK_INTERVAL = 2000;
275
+
276
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
277
+ /** @type {any} */
278
+ const progressState = await getProgress();
279
+
280
+ const progress = progressState['progress'];
281
+ const jobCount = progressState['state']['job_count'];
282
+ if (progress === 0.0 && jobCount === 0) {
283
+ break;
284
+ }
285
+
286
+ console.info(`Waiting for SD WebUI to finish model loading... Progress: ${progress}; Job count: ${jobCount}`);
287
+ await delay(CHECK_INTERVAL);
288
+ }
289
+
290
+ return response.sendStatus(200);
291
+ } catch (error) {
292
+ console.error(error);
293
+ return response.sendStatus(500);
294
+ }
295
+ });
296
+
297
+ router.post('/generate', async (request, response) => {
298
+ try {
299
+ try {
300
+ const optionsUrl = new URL(request.body.url);
301
+ optionsUrl.pathname = '/sdapi/v1/options';
302
+ const optionsResult = await fetch(optionsUrl, { headers: { 'Authorization': getBasicAuthHeader(request.body.auth) } });
303
+ if (optionsResult.ok) {
304
+ const optionsData = /** @type {any} */ (await optionsResult.json());
305
+ const isForge = 'forge_preset' in optionsData;
306
+
307
+ if (!isForge) {
308
+ _.unset(request.body, 'override_settings.forge_additional_modules');
309
+ }
310
+ }
311
+ } catch (error) {
312
+ console.error('SD WebUI failed to get options:', error);
313
+ }
314
+
315
+ const controller = new AbortController();
316
+ request.socket.removeAllListeners('close');
317
+ request.socket.on('close', function () {
318
+ if (!response.writableEnded) {
319
+ const interruptUrl = new URL(request.body.url);
320
+ interruptUrl.pathname = '/sdapi/v1/interrupt';
321
+ fetch(interruptUrl, { method: 'POST', headers: { 'Authorization': getBasicAuthHeader(request.body.auth) } });
322
+ }
323
+ controller.abort();
324
+ });
325
+
326
+ console.debug('SD WebUI request:', request.body);
327
+ const txt2imgUrl = new URL(request.body.url);
328
+ txt2imgUrl.pathname = '/sdapi/v1/txt2img';
329
+ const result = await fetch(txt2imgUrl, {
330
+ method: 'POST',
331
+ body: JSON.stringify(request.body),
332
+ headers: {
333
+ 'Content-Type': 'application/json',
334
+ 'Authorization': getBasicAuthHeader(request.body.auth),
335
+ },
336
+ signal: controller.signal,
337
+ });
338
+
339
+ if (!result.ok) {
340
+ const text = await result.text();
341
+ throw new Error('SD WebUI returned an error.', { cause: text });
342
+ }
343
+
344
+ const data = await result.json();
345
+ return response.send(data);
346
+ } catch (error) {
347
+ console.error(error);
348
+ return response.sendStatus(500);
349
+ }
350
+ });
351
+
352
+ router.post('/sd-next/upscalers', async (request, response) => {
353
+ try {
354
+ const url = new URL(request.body.url);
355
+ url.pathname = '/sdapi/v1/upscalers';
356
+
357
+ const result = await fetch(url, {
358
+ method: 'GET',
359
+ headers: {
360
+ 'Authorization': getBasicAuthHeader(request.body.auth),
361
+ },
362
+ });
363
+
364
+ if (!result.ok) {
365
+ throw new Error('SD WebUI returned an error.');
366
+ }
367
+
368
+ // Vlad doesn't provide Latent Upscalers in the API, so we have to hardcode them here
369
+ const latentUpscalers = ['Latent', 'Latent (antialiased)', 'Latent (bicubic)', 'Latent (bicubic antialiased)', 'Latent (nearest)', 'Latent (nearest-exact)'];
370
+
371
+ /** @type {any} */
372
+ const data = await result.json();
373
+ const names = data.map(x => x.name);
374
+
375
+ // 0 = None, then Latent Upscalers, then Upscalers
376
+ names.splice(1, 0, ...latentUpscalers);
377
+
378
+ return response.send(names);
379
+ } catch (error) {
380
+ console.error(error);
381
+ return response.sendStatus(500);
382
+ }
383
+ });
384
+
385
+ const comfy = express.Router();
386
+
387
+ comfy.post('/ping', async (request, response) => {
388
+ try {
389
+ const url = new URL(urlJoin(request.body.url, '/system_stats'));
390
+
391
+ const result = await fetch(url);
392
+ if (!result.ok) {
393
+ throw new Error('ComfyUI returned an error.');
394
+ }
395
+
396
+ return response.sendStatus(200);
397
+ } catch (error) {
398
+ console.error(error);
399
+ return response.sendStatus(500);
400
+ }
401
+ });
402
+
403
+ comfy.post('/samplers', async (request, response) => {
404
+ try {
405
+ const url = new URL(urlJoin(request.body.url, '/object_info'));
406
+
407
+ const result = await fetch(url);
408
+ if (!result.ok) {
409
+ throw new Error('ComfyUI returned an error.');
410
+ }
411
+
412
+ /** @type {any} */
413
+ const data = await result.json();
414
+ return response.send(data.KSampler.input.required.sampler_name[0]);
415
+ } catch (error) {
416
+ console.error(error);
417
+ return response.sendStatus(500);
418
+ }
419
+ });
420
+
421
+ comfy.post('/models', async (request, response) => {
422
+ try {
423
+ const url = new URL(urlJoin(request.body.url, '/object_info'));
424
+
425
+ const result = await fetch(url);
426
+ if (!result.ok) {
427
+ throw new Error('ComfyUI returned an error.');
428
+ }
429
+ /** @type {any} */
430
+ const data = await result.json();
431
+
432
+ const ckpts = data.CheckpointLoaderSimple.input.required.ckpt_name[0].map(it => ({ value: it, text: it })) || [];
433
+ const unets = data.UNETLoader.input.required.unet_name[0].map(it => ({ value: it, text: `UNet: ${it}` })) || [];
434
+
435
+ // load list of GGUF unets from diffusion_models if the loader node is available
436
+ const ggufs = data.UnetLoaderGGUF?.input.required.unet_name[0].map(it => ({ value: it, text: `GGUF: ${it}` })) || [];
437
+ const models = [...ckpts, ...unets, ...ggufs];
438
+
439
+ // make the display names of the models somewhat presentable
440
+ models.forEach(it => it.text = it.text.replace(/\.[^.]*$/, '').replace(/_/g, ' '));
441
+
442
+ return response.send(models);
443
+ } catch (error) {
444
+ console.error(error);
445
+ return response.sendStatus(500);
446
+ }
447
+ });
448
+
449
+ comfy.post('/schedulers', async (request, response) => {
450
+ try {
451
+ const url = new URL(urlJoin(request.body.url, '/object_info'));
452
+
453
+ const result = await fetch(url);
454
+ if (!result.ok) {
455
+ throw new Error('ComfyUI returned an error.');
456
+ }
457
+
458
+ /** @type {any} */
459
+ const data = await result.json();
460
+ return response.send(data.KSampler.input.required.scheduler[0]);
461
+ } catch (error) {
462
+ console.error(error);
463
+ return response.sendStatus(500);
464
+ }
465
+ });
466
+
467
+ comfy.post('/vaes', async (request, response) => {
468
+ try {
469
+ const url = new URL(urlJoin(request.body.url, '/object_info'));
470
+
471
+ const result = await fetch(url);
472
+ if (!result.ok) {
473
+ throw new Error('ComfyUI returned an error.');
474
+ }
475
+
476
+ /** @type {any} */
477
+ const data = await result.json();
478
+ return response.send(data.VAELoader.input.required.vae_name[0]);
479
+ } catch (error) {
480
+ console.error(error);
481
+ return response.sendStatus(500);
482
+ }
483
+ });
484
+
485
+ comfy.post('/workflows', async (request, response) => {
486
+ try {
487
+ const data = getComfyWorkflows(request.user.directories);
488
+ return response.send(data);
489
+ } catch (error) {
490
+ console.error(error);
491
+ return response.sendStatus(500);
492
+ }
493
+ });
494
+
495
+ comfy.post('/workflow', async (request, response) => {
496
+ try {
497
+ let filePath = path.join(request.user.directories.comfyWorkflows, sanitize(String(request.body.file_name)));
498
+ if (!fs.existsSync(filePath)) {
499
+ filePath = path.join(request.user.directories.comfyWorkflows, 'Default_Comfy_Workflow.json');
500
+ }
501
+ const data = fs.readFileSync(filePath, { encoding: 'utf-8' });
502
+ return response.send(JSON.stringify(data));
503
+ } catch (error) {
504
+ console.error(error);
505
+ return response.sendStatus(500);
506
+ }
507
+ });
508
+
509
+ comfy.post('/save-workflow', async (request, response) => {
510
+ try {
511
+ const filePath = path.join(request.user.directories.comfyWorkflows, sanitize(String(request.body.file_name)));
512
+ writeFileAtomicSync(filePath, request.body.workflow, 'utf8');
513
+ const data = getComfyWorkflows(request.user.directories);
514
+ return response.send(data);
515
+ } catch (error) {
516
+ console.error(error);
517
+ return response.sendStatus(500);
518
+ }
519
+ });
520
+
521
+ comfy.post('/delete-workflow', async (request, response) => {
522
+ try {
523
+ const filePath = path.join(request.user.directories.comfyWorkflows, sanitize(String(request.body.file_name)));
524
+ if (fs.existsSync(filePath)) {
525
+ fs.unlinkSync(filePath);
526
+ }
527
+ return response.sendStatus(200);
528
+ } catch (error) {
529
+ console.error(error);
530
+ return response.sendStatus(500);
531
+ }
532
+ });
533
+
534
+ comfy.post('/generate', async (request, response) => {
535
+ try {
536
+ let item;
537
+ const url = new URL(urlJoin(request.body.url, '/prompt'));
538
+
539
+ const controller = new AbortController();
540
+ request.socket.removeAllListeners('close');
541
+ request.socket.on('close', function () {
542
+ if (!response.writableEnded && !item) {
543
+ const interruptUrl = new URL(urlJoin(request.body.url, '/interrupt'));
544
+ fetch(interruptUrl, { method: 'POST', headers: { 'Authorization': getBasicAuthHeader(request.body.auth) } });
545
+ }
546
+ controller.abort();
547
+ });
548
+
549
+ const promptResult = await fetch(url, {
550
+ method: 'POST',
551
+ body: request.body.prompt,
552
+ });
553
+ if (!promptResult.ok) {
554
+ const text = await promptResult.text();
555
+ throw new Error('ComfyUI returned an error.', { cause: tryParse(text) });
556
+ }
557
+
558
+ /** @type {any} */
559
+ const data = await promptResult.json();
560
+ const id = data.prompt_id;
561
+ const historyUrl = new URL(urlJoin(request.body.url, '/history'));
562
+ while (true) {
563
+ const result = await fetch(historyUrl);
564
+ if (!result.ok) {
565
+ throw new Error('ComfyUI returned an error.');
566
+ }
567
+ /** @type {any} */
568
+ const history = await result.json();
569
+ item = history[id];
570
+ if (item) {
571
+ break;
572
+ }
573
+ await delay(100);
574
+ }
575
+ if (item.status.status_str === 'error') {
576
+ // Report node tracebacks if available
577
+ const errorMessages = item.status?.messages
578
+ ?.filter(it => it[0] === 'execution_error')
579
+ .map(it => it[1])
580
+ .map(it => `${it.node_type} [${it.node_id}] ${it.exception_type}: ${it.exception_message}`)
581
+ .join('\n') || '';
582
+ throw new Error(`ComfyUI generation did not succeed.\n\n${errorMessages}`.trim());
583
+ }
584
+ const outputs = Object.keys(item.outputs).map(it => item.outputs[it]);
585
+ console.debug('ComfyUI outputs:', outputs);
586
+ const imgInfo = outputs.map(it => it.images).flat()[0] ?? outputs.map(it => it.gifs).flat()[0];
587
+ if (!imgInfo) {
588
+ throw new Error('ComfyUI did not return any recognizable outputs.');
589
+ }
590
+ const imgUrl = new URL(urlJoin(request.body.url, '/view'));
591
+ imgUrl.search = `?filename=${imgInfo.filename}&subfolder=${imgInfo.subfolder}&type=${imgInfo.type}`;
592
+ const imgResponse = await fetch(imgUrl);
593
+ if (!imgResponse.ok) {
594
+ throw new Error('ComfyUI returned an error.');
595
+ }
596
+ const format = path.extname(imgInfo.filename).slice(1).toLowerCase() || 'png';
597
+ const imgBuffer = await imgResponse.arrayBuffer();
598
+ return response.send({ format: format, data: Buffer.from(imgBuffer).toString('base64') });
599
+ } catch (error) {
600
+ console.error('ComfyUI error:', error);
601
+ response.status(500).send(error.message);
602
+ return response;
603
+ }
604
+ });
605
+
606
+ const comfyRunPod = express.Router();
607
+
608
+ comfyRunPod.post('/ping', async (request, response) => {
609
+ try {
610
+ const key = readSecret(request.user.directories, SECRET_KEYS.COMFY_RUNPOD);
611
+
612
+ if (!key) {
613
+ console.warn('RunPod key not found.');
614
+ return response.sendStatus(400);
615
+ }
616
+
617
+ const url = new URL(urlJoin(request.body.url, '/health'));
618
+
619
+ const result = await fetch(url, {
620
+ method: 'GET',
621
+ headers: { 'Authorization': `Bearer ${key}` },
622
+ });
623
+ if (!result.ok) {
624
+ throw new Error('ComfyUI returned an error.');
625
+ }
626
+ /** @type {any} */
627
+ const data = await result.json();
628
+ if (data.workers.ready <= 0) {
629
+ console.warn(`No workers reported as ready. ${result}`);
630
+ }
631
+
632
+ return response.sendStatus(200);
633
+ } catch (error) {
634
+ console.error(error);
635
+ return response.sendStatus(500);
636
+ }
637
+ });
638
+
639
+ comfyRunPod.post('/generate', async (request, response) => {
640
+ try {
641
+ const key = readSecret(request.user.directories, SECRET_KEYS.COMFY_RUNPOD);
642
+
643
+ if (!key) {
644
+ console.warn('RunPod key not found.');
645
+ return response.sendStatus(400);
646
+ }
647
+
648
+ let jobId;
649
+ let item;
650
+ const url = new URL(urlJoin(request.body.url, '/run'));
651
+
652
+ const controller = new AbortController();
653
+ request.socket.removeAllListeners('close');
654
+ request.socket.on('close', function () {
655
+ if (!response.writableEnded && !item) {
656
+ const interruptUrl = new URL(urlJoin(request.body.url, `/cancel/${jobId}`));
657
+ fetch(interruptUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${key}` } });
658
+ }
659
+ controller.abort();
660
+ });
661
+ const workflow = JSON.parse(request.body.prompt).prompt;
662
+ const wrappedWorkflow = workflow?.input?.workflow ? workflow : ({ input: { workflow: workflow } });
663
+ const runpodPrompt = JSON.stringify(wrappedWorkflow);
664
+
665
+ console.debug('ComfyUI RunPod request:', wrappedWorkflow);
666
+
667
+ const promptResult = await fetch(url, {
668
+ method: 'POST',
669
+ headers: { 'Authorization': `Bearer ${key}` },
670
+ body: runpodPrompt,
671
+ });
672
+ if (!promptResult.ok) {
673
+ const text = await promptResult.text();
674
+ throw new Error('ComfyUI returned an error.', { cause: tryParse(text) });
675
+ }
676
+
677
+ /** @type {any} */
678
+ const data = await promptResult.json();
679
+ jobId = data.id;
680
+ const statusUrl = new URL(urlJoin(request.body.url, `/status/${jobId}`));
681
+ while (true) {
682
+ const result = await fetch(statusUrl, {
683
+ method: 'GET',
684
+ headers: { 'Authorization': `Bearer ${key}` },
685
+ });
686
+ if (!result.ok) {
687
+ throw new Error('ComfyUI returned an error.');
688
+ }
689
+ /** @type {any} */
690
+ const status = await result.json();
691
+ if (status.output) {
692
+ item = status.output.images[0];
693
+ }
694
+ if (item) {
695
+ break;
696
+ }
697
+ await delay(500);
698
+ }
699
+ const format = path.extname(item.filename).slice(1).toLowerCase() || 'png';
700
+ return response.send({ format: format, data: item.data });
701
+ } catch (error) {
702
+ console.error('ComfyUI error:', error);
703
+ response.status(500).send(error.message);
704
+ return response;
705
+ }
706
+ });
707
+
708
+ const together = express.Router();
709
+
710
+ together.post('/models', async (request, response) => {
711
+ try {
712
+ const key = readSecret(request.user.directories, SECRET_KEYS.TOGETHERAI);
713
+
714
+ if (!key) {
715
+ console.warn('TogetherAI key not found.');
716
+ return response.sendStatus(400);
717
+ }
718
+
719
+ const modelsResponse = await fetch('https://api.together.xyz/api/models', {
720
+ method: 'GET',
721
+ headers: {
722
+ 'Authorization': `Bearer ${key}`,
723
+ },
724
+ });
725
+
726
+ if (!modelsResponse.ok) {
727
+ console.warn('TogetherAI returned an error.');
728
+ return response.sendStatus(500);
729
+ }
730
+
731
+ const data = await modelsResponse.json();
732
+
733
+ if (!Array.isArray(data)) {
734
+ console.warn('TogetherAI returned invalid data.');
735
+ return response.sendStatus(500);
736
+ }
737
+
738
+ const models = data
739
+ .filter(x => x.type === 'image')
740
+ .map(x => ({ value: x.id, text: x.display_name }));
741
+
742
+ return response.send(models);
743
+ } catch (error) {
744
+ console.error(error);
745
+ return response.sendStatus(500);
746
+ }
747
+ });
748
+
749
+ together.post('/generate', async (request, response) => {
750
+ try {
751
+ const key = readSecret(request.user.directories, SECRET_KEYS.TOGETHERAI);
752
+
753
+ if (!key) {
754
+ console.warn('TogetherAI key not found.');
755
+ return response.sendStatus(400);
756
+ }
757
+
758
+ console.debug('TogetherAI request:', request.body);
759
+
760
+ const result = await fetch('https://api.together.xyz/v1/images/generations', {
761
+ method: 'POST',
762
+ body: JSON.stringify({
763
+ prompt: request.body.prompt,
764
+ negative_prompt: request.body.negative_prompt,
765
+ height: request.body.height,
766
+ width: request.body.width,
767
+ model: request.body.model,
768
+ steps: request.body.steps,
769
+ n: 1,
770
+ // Limited to 10000 on playground, works fine with more.
771
+ seed: request.body.seed >= 0 ? request.body.seed : Math.floor(Math.random() * 10_000_000),
772
+ }),
773
+ headers: {
774
+ 'Content-Type': 'application/json',
775
+ 'Authorization': `Bearer ${key}`,
776
+ },
777
+ });
778
+
779
+ if (!result.ok) {
780
+ console.warn('TogetherAI returned an error.', { body: await result.text() });
781
+ return response.sendStatus(500);
782
+ }
783
+
784
+ /** @type {any} */
785
+ const data = await result.json();
786
+ console.debug('TogetherAI response:', data);
787
+
788
+ const choice = data?.data?.[0];
789
+ let b64_json = choice.b64_json;
790
+
791
+ if (!b64_json) {
792
+ const buffer = await (await fetch(choice.url)).arrayBuffer();
793
+ b64_json = Buffer.from(buffer).toString('base64');
794
+ }
795
+
796
+ return response.send({ format: 'jpg', data: b64_json });
797
+ } catch (error) {
798
+ console.error(error);
799
+ return response.sendStatus(500);
800
+ }
801
+ });
802
+
803
+ const drawthings = express.Router();
804
+
805
+ drawthings.post('/ping', async (request, response) => {
806
+ try {
807
+ const url = new URL(request.body.url);
808
+ url.pathname = '/';
809
+
810
+ const result = await fetch(url, {
811
+ method: 'HEAD',
812
+ });
813
+
814
+ if (!result.ok) {
815
+ throw new Error('SD DrawThings API returned an error.');
816
+ }
817
+
818
+ return response.sendStatus(200);
819
+ } catch (error) {
820
+ console.error(error);
821
+ return response.sendStatus(500);
822
+ }
823
+ });
824
+
825
+ drawthings.post('/get-model', async (request, response) => {
826
+ try {
827
+ const url = new URL(request.body.url);
828
+ url.pathname = '/';
829
+
830
+ const result = await fetch(url, {
831
+ method: 'GET',
832
+ });
833
+
834
+ /** @type {any} */
835
+ const data = await result.json();
836
+
837
+ return response.send(data['model']);
838
+ } catch (error) {
839
+ console.error(error);
840
+ return response.sendStatus(500);
841
+ }
842
+ });
843
+
844
+ drawthings.post('/get-upscaler', async (request, response) => {
845
+ try {
846
+ const url = new URL(request.body.url);
847
+ url.pathname = '/';
848
+
849
+ const result = await fetch(url, {
850
+ method: 'GET',
851
+ });
852
+
853
+ /** @type {any} */
854
+ const data = await result.json();
855
+
856
+ return response.send(data['upscaler']);
857
+ } catch (error) {
858
+ console.error(error);
859
+ return response.sendStatus(500);
860
+ }
861
+ });
862
+
863
+ drawthings.post('/generate', async (request, response) => {
864
+ try {
865
+ console.debug('SD DrawThings API request:', request.body);
866
+
867
+ const url = new URL(request.body.url);
868
+ url.pathname = '/sdapi/v1/txt2img';
869
+
870
+ const body = { ...request.body };
871
+ const auth = getBasicAuthHeader(request.body.auth);
872
+ delete body.url;
873
+ delete body.auth;
874
+
875
+ const result = await fetch(url, {
876
+ method: 'POST',
877
+ body: JSON.stringify(body),
878
+ headers: {
879
+ 'Content-Type': 'application/json',
880
+ 'Authorization': auth,
881
+ },
882
+ });
883
+
884
+ if (!result.ok) {
885
+ const text = await result.text();
886
+ throw new Error('SD DrawThings API returned an error.', { cause: text });
887
+ }
888
+
889
+ const data = await result.json();
890
+ return response.send(data);
891
+ } catch (error) {
892
+ console.error(error);
893
+ return response.sendStatus(500);
894
+ }
895
+ });
896
+
897
+ const pollinations = express.Router();
898
+
899
+ pollinations.post('/models', async (_request, response) => {
900
+ try {
901
+ const modelsUrl = new URL('https://image.pollinations.ai/models');
902
+ const result = await fetch(modelsUrl);
903
+
904
+ if (!result.ok) {
905
+ console.warn('Pollinations returned an error.', result.status, result.statusText);
906
+ throw new Error('Pollinations request failed.');
907
+ }
908
+
909
+ const data = await result.json();
910
+
911
+ if (!Array.isArray(data)) {
912
+ console.warn('Pollinations returned invalid data.');
913
+ throw new Error('Pollinations request failed.');
914
+ }
915
+
916
+ const models = data.map(x => ({ value: x, text: x }));
917
+ return response.send(models);
918
+ } catch (error) {
919
+ console.error(error);
920
+ return response.sendStatus(500);
921
+ }
922
+ });
923
+
924
+ pollinations.post('/generate', async (request, response) => {
925
+ try {
926
+ const promptUrl = new URL(`https://image.pollinations.ai/prompt/${encodeURIComponent(request.body.prompt)}`);
927
+ const params = new URLSearchParams({
928
+ model: String(request.body.model),
929
+ negative_prompt: String(request.body.negative_prompt),
930
+ seed: String(request.body.seed >= 0 ? request.body.seed : Math.floor(Math.random() * 10_000_000)),
931
+ width: String(request.body.width ?? 1024),
932
+ height: String(request.body.height ?? 1024),
933
+ nologo: String(true),
934
+ nofeed: String(true),
935
+ private: String(true),
936
+ referrer: 'tavernintern',
937
+ });
938
+ if (request.body.enhance) {
939
+ params.set('enhance', String(true));
940
+ }
941
+ promptUrl.search = params.toString();
942
+
943
+ console.info('Pollinations request URL:', promptUrl.toString());
944
+
945
+ const result = await fetch(promptUrl);
946
+
947
+ if (!result.ok) {
948
+ const text = await result.text();
949
+ console.warn('Pollinations returned an error.', text);
950
+ throw new Error('Pollinations request failed.');
951
+ }
952
+
953
+ const buffer = await result.arrayBuffer();
954
+ const base64 = Buffer.from(buffer).toString('base64');
955
+
956
+ return response.send({ image: base64 });
957
+ } catch (error) {
958
+ console.error(error);
959
+ return response.sendStatus(500);
960
+ }
961
+ });
962
+
963
+ const stability = express.Router();
964
+
965
+ stability.post('/generate', async (request, response) => {
966
+ try {
967
+ const key = readSecret(request.user.directories, SECRET_KEYS.STABILITY);
968
+
969
+ if (!key) {
970
+ console.warn('Stability AI key not found.');
971
+ return response.sendStatus(400);
972
+ }
973
+
974
+ const { payload, model } = request.body;
975
+
976
+ console.debug('Stability AI request:', model, payload);
977
+
978
+ const formData = new FormData();
979
+ for (const [key, value] of Object.entries(payload)) {
980
+ if (value !== undefined) {
981
+ formData.append(key, String(value));
982
+ }
983
+ }
984
+
985
+ let apiUrl;
986
+ switch (model) {
987
+ case 'stable-image-ultra':
988
+ apiUrl = 'https://api.stability.ai/v2beta/stable-image/generate/ultra';
989
+ break;
990
+ case 'stable-image-core':
991
+ apiUrl = 'https://api.stability.ai/v2beta/stable-image/generate/core';
992
+ break;
993
+ case 'stable-diffusion-3':
994
+ apiUrl = 'https://api.stability.ai/v2beta/stable-image/generate/sd3';
995
+ break;
996
+ default:
997
+ throw new Error('Invalid Stability AI model selected');
998
+ }
999
+
1000
+ const result = await fetch(apiUrl, {
1001
+ method: 'POST',
1002
+ headers: {
1003
+ 'Authorization': `Bearer ${key}`,
1004
+ 'Accept': 'image/*',
1005
+ },
1006
+ body: formData,
1007
+ });
1008
+
1009
+ if (!result.ok) {
1010
+ const text = await result.text();
1011
+ console.warn('Stability AI returned an error.', result.status, result.statusText, text);
1012
+ return response.sendStatus(500);
1013
+ }
1014
+
1015
+ const buffer = await result.arrayBuffer();
1016
+ return response.send(Buffer.from(buffer).toString('base64'));
1017
+ } catch (error) {
1018
+ console.error(error);
1019
+ return response.sendStatus(500);
1020
+ }
1021
+ });
1022
+
1023
+ const huggingface = express.Router();
1024
+
1025
+ huggingface.post('/generate', async (request, response) => {
1026
+ try {
1027
+ const key = readSecret(request.user.directories, SECRET_KEYS.HUGGINGFACE);
1028
+
1029
+ if (!key) {
1030
+ console.warn('Hugging Face key not found.');
1031
+ return response.sendStatus(400);
1032
+ }
1033
+
1034
+ console.debug('Hugging Face request:', request.body);
1035
+
1036
+ const result = await fetch(`https://api-inference.huggingface.co/models/${request.body.model}`, {
1037
+ method: 'POST',
1038
+ body: JSON.stringify({
1039
+ inputs: request.body.prompt,
1040
+ }),
1041
+ headers: {
1042
+ 'Content-Type': 'application/json',
1043
+ 'Authorization': `Bearer ${key}`,
1044
+ },
1045
+ });
1046
+
1047
+ if (!result.ok) {
1048
+ console.warn('Hugging Face returned an error.');
1049
+ return response.sendStatus(500);
1050
+ }
1051
+
1052
+ const buffer = await result.arrayBuffer();
1053
+ return response.send({
1054
+ image: Buffer.from(buffer).toString('base64'),
1055
+ });
1056
+ } catch (error) {
1057
+ console.error(error);
1058
+ return response.sendStatus(500);
1059
+ }
1060
+ });
1061
+
1062
+ const electronhub = express.Router();
1063
+
1064
+ electronhub.post('/models', async (request, response) => {
1065
+ try {
1066
+ const key = readSecret(request.user.directories, SECRET_KEYS.ELECTRONHUB);
1067
+
1068
+ if (!key) {
1069
+ console.warn('Electron Hub key not found.');
1070
+ return response.sendStatus(400);
1071
+ }
1072
+
1073
+ const modelsResponse = await fetch('https://api.electronhub.ai/v1/models', {
1074
+ method: 'GET',
1075
+ headers: {
1076
+ 'Authorization': `Bearer ${key}`,
1077
+ 'Content-Type': 'application/json',
1078
+ },
1079
+ });
1080
+
1081
+ if (!modelsResponse.ok) {
1082
+ console.warn('Electron Hub returned an error.');
1083
+ return response.sendStatus(500);
1084
+ }
1085
+
1086
+ /** @type {any} */
1087
+ const data = await modelsResponse.json();
1088
+
1089
+ if (!Array.isArray(data?.data)) {
1090
+ console.warn('Electron Hub returned invalid data.');
1091
+ return response.sendStatus(500);
1092
+ }
1093
+
1094
+ const models = data.data
1095
+ .filter(x => x && Array.isArray(x.endpoints) && x.endpoints.includes('/v1/images/generations'))
1096
+ .map(x => ({ ...x, value: x.id, text: x.name }));
1097
+ return response.send(models);
1098
+ } catch (error) {
1099
+ console.error(error);
1100
+ return response.sendStatus(500);
1101
+ }
1102
+ });
1103
+
1104
+ electronhub.post('/generate', async (request, response) => {
1105
+ try {
1106
+ const key = readSecret(request.user.directories, SECRET_KEYS.ELECTRONHUB);
1107
+
1108
+ if (!key) {
1109
+ console.warn('Electron Hub key not found.');
1110
+ return response.sendStatus(400);
1111
+ }
1112
+
1113
+ let bodyParams = {
1114
+ model: request.body.model,
1115
+ prompt: request.body.prompt,
1116
+ response_format: 'b64_json',
1117
+ };
1118
+
1119
+ if (request.body.size) {
1120
+ bodyParams.size = request.body.size;
1121
+ }
1122
+
1123
+ if (request.body.quality) {
1124
+ bodyParams.quality = request.body.quality;
1125
+ }
1126
+
1127
+ console.debug('Electron Hub request:', bodyParams);
1128
+
1129
+ const result = await fetch('https://api.electronhub.ai/v1/images/generations', {
1130
+ method: 'POST',
1131
+ headers: {
1132
+ 'Authorization': `Bearer ${key}`,
1133
+ 'Content-Type': 'application/json',
1134
+ },
1135
+ body: JSON.stringify({
1136
+ ...bodyParams,
1137
+ }),
1138
+ });
1139
+
1140
+ if (!result.ok) {
1141
+ const errorText = await result.text();
1142
+ console.warn('Electron Hub returned an error.', result.status, result.statusText, errorText);
1143
+ return response.sendStatus(500);
1144
+ }
1145
+
1146
+ /** @type {any} */
1147
+ const data = await result.json();
1148
+ const image = data?.data?.[0]?.b64_json;
1149
+
1150
+ if (!image) {
1151
+ console.warn('Electron Hub returned invalid data.');
1152
+ return response.sendStatus(500);
1153
+ }
1154
+
1155
+ return response.send({ image });
1156
+ } catch (error) {
1157
+ console.error(error);
1158
+ return response.sendStatus(500);
1159
+ }
1160
+ });
1161
+
1162
+ electronhub.post('/sizes', async (request, response) => {
1163
+ const result = await fetch(`https://api.electronhub.ai/v1/models/${request.body.model}`, {
1164
+ method: 'GET',
1165
+ headers: {
1166
+ 'Content-Type': 'application/json',
1167
+ },
1168
+ });
1169
+
1170
+ if (!result.ok) {
1171
+ console.warn('Electron Hub returned an error.');
1172
+ return response.sendStatus(500);
1173
+ }
1174
+
1175
+ /** @type {any} */
1176
+ const data = await result.json();
1177
+
1178
+ const sizes = data.sizes;
1179
+
1180
+ if (!sizes) {
1181
+ console.warn('Electron Hub returned invalid data.');
1182
+ return response.sendStatus(500);
1183
+ }
1184
+
1185
+ return response.send({ sizes });
1186
+ });
1187
+
1188
+ const chutes = express.Router();
1189
+
1190
+ chutes.post('/models', async (request, response) => {
1191
+ try {
1192
+ const key = readSecret(request.user.directories, SECRET_KEYS.CHUTES);
1193
+
1194
+ if (!key) {
1195
+ console.warn('Chutes key not found.');
1196
+ return response.sendStatus(400);
1197
+ }
1198
+
1199
+ const modelsResponse = await fetch('https://api.chutes.ai/chutes/?template=diffusion&include_public=true&limit=999', {
1200
+ method: 'GET',
1201
+ headers: {
1202
+ 'Authorization': `Bearer ${key}`,
1203
+ 'Content-Type': 'application/json',
1204
+ },
1205
+ });
1206
+
1207
+ if (!modelsResponse.ok) {
1208
+ console.warn('Chutes returned an error.');
1209
+ return response.sendStatus(500);
1210
+ }
1211
+
1212
+ const data = await modelsResponse.json();
1213
+
1214
+ const chutesData = /** @type {{items: Array<{name: string}>}} */ (data);
1215
+ const models = chutesData.items.map(x => ({ value: x.name, text: x.name })).sort((a, b) => a?.text?.localeCompare(b?.text));
1216
+ return response.send(models);
1217
+ }
1218
+ catch (error) {
1219
+ console.error(error);
1220
+ return response.sendStatus(500);
1221
+ }
1222
+ });
1223
+
1224
+ chutes.post('/generate', async (request, response) => {
1225
+ try {
1226
+ const key = readSecret(request.user.directories, SECRET_KEYS.CHUTES);
1227
+
1228
+ if (!key) {
1229
+ console.warn('Chutes key not found.');
1230
+ return response.sendStatus(400);
1231
+ }
1232
+
1233
+ const bodyParams = {
1234
+ model: request.body.model,
1235
+ prompt: request.body.prompt,
1236
+ negative_prompt: request.body.negative_prompt,
1237
+ guidance_scale: request.body.guidance_scale || 7.0,
1238
+ width: request.body.width || 1024,
1239
+ height: request.body.height || 1024,
1240
+ num_inference_steps: request.body.steps || 10,
1241
+ };
1242
+
1243
+ console.debug('Chutes request:', bodyParams);
1244
+
1245
+ const result = await fetch('https://image.chutes.ai/generate', {
1246
+ method: 'POST',
1247
+ headers: {
1248
+ 'Authorization': `Bearer ${key}`,
1249
+ 'Content-Type': 'application/json',
1250
+ },
1251
+ body: JSON.stringify(bodyParams),
1252
+ });
1253
+
1254
+ if (!result.ok) {
1255
+ const text = await result.text();
1256
+ console.warn('Chutes returned an error:', text);
1257
+ return response.sendStatus(500);
1258
+ }
1259
+
1260
+ const buffer = await result.arrayBuffer();
1261
+ const base64 = Buffer.from(buffer).toString('base64');
1262
+
1263
+ return response.send({ image: base64 });
1264
+ }
1265
+ catch (error) {
1266
+ console.error(error);
1267
+ return response.sendStatus(500);
1268
+ }
1269
+ });
1270
+
1271
+ const nanogpt = express.Router();
1272
+
1273
+ nanogpt.post('/models', async (request, response) => {
1274
+ try {
1275
+ const key = readSecret(request.user.directories, SECRET_KEYS.NANOGPT);
1276
+
1277
+ if (!key) {
1278
+ console.warn('NanoGPT key not found.');
1279
+ return response.sendStatus(400);
1280
+ }
1281
+
1282
+ const modelsResponse = await fetch('https://nano-gpt.com/api/models', {
1283
+ method: 'GET',
1284
+ headers: {
1285
+ 'x-api-key': key,
1286
+ 'Content-Type': 'application/json',
1287
+ },
1288
+ });
1289
+
1290
+ if (!modelsResponse.ok) {
1291
+ console.warn('NanoGPT returned an error.');
1292
+ return response.sendStatus(500);
1293
+ }
1294
+
1295
+ /** @type {any} */
1296
+ const data = await modelsResponse.json();
1297
+ const imageModels = data?.models?.image;
1298
+
1299
+ if (!imageModels || typeof imageModels !== 'object') {
1300
+ console.warn('NanoGPT returned invalid data.');
1301
+ return response.sendStatus(500);
1302
+ }
1303
+
1304
+ const models = Object.values(imageModels).map(x => ({ value: x.model, text: x.name }));
1305
+ return response.send(models);
1306
+ }
1307
+ catch (error) {
1308
+ console.error(error);
1309
+ return response.sendStatus(500);
1310
+ }
1311
+ });
1312
+
1313
+ nanogpt.post('/generate', async (request, response) => {
1314
+ try {
1315
+ const key = readSecret(request.user.directories, SECRET_KEYS.NANOGPT);
1316
+
1317
+ if (!key) {
1318
+ console.warn('NanoGPT key not found.');
1319
+ return response.sendStatus(400);
1320
+ }
1321
+
1322
+ console.debug('NanoGPT request:', request.body);
1323
+
1324
+ const result = await fetch('https://nano-gpt.com/api/generate-image', {
1325
+ method: 'POST',
1326
+ body: JSON.stringify(request.body),
1327
+ headers: {
1328
+ 'x-api-key': key,
1329
+ 'Content-Type': 'application/json',
1330
+ },
1331
+ });
1332
+
1333
+ if (!result.ok) {
1334
+ console.warn('NanoGPT returned an error.');
1335
+ return response.sendStatus(500);
1336
+ }
1337
+
1338
+ /** @type {any} */
1339
+ const data = await result.json();
1340
+
1341
+ const image = data?.data?.[0]?.b64_json;
1342
+ if (!image) {
1343
+ console.warn('NanoGPT returned invalid data.');
1344
+ return response.sendStatus(500);
1345
+ }
1346
+
1347
+ return response.send({ image });
1348
+ }
1349
+ catch (error) {
1350
+ console.error(error);
1351
+ return response.sendStatus(500);
1352
+ }
1353
+ });
1354
+
1355
+ const bfl = express.Router();
1356
+
1357
+ bfl.post('/generate', async (request, response) => {
1358
+ try {
1359
+ const key = readSecret(request.user.directories, SECRET_KEYS.BFL);
1360
+
1361
+ if (!key) {
1362
+ console.warn('BFL key not found.');
1363
+ return response.sendStatus(400);
1364
+ }
1365
+
1366
+ const requestBody = {
1367
+ prompt: request.body.prompt,
1368
+ steps: request.body.steps,
1369
+ guidance: request.body.guidance,
1370
+ width: request.body.width,
1371
+ height: request.body.height,
1372
+ prompt_upsampling: request.body.prompt_upsampling,
1373
+ seed: request.body.seed ?? null,
1374
+ safety_tolerance: 6, // being least strict
1375
+ output_format: 'jpeg',
1376
+ };
1377
+
1378
+ function getClosestAspectRatio(width, height) {
1379
+ const minAspect = 9 / 21;
1380
+ const maxAspect = 21 / 9;
1381
+ const currentAspect = width / height;
1382
+
1383
+ const gcd = (a, b) => b === 0 ? a : gcd(b, a % b);
1384
+ const simplifyRatio = (w, h) => {
1385
+ const divisor = gcd(w, h);
1386
+ return `${w / divisor}:${h / divisor}`;
1387
+ };
1388
+
1389
+ if (currentAspect < minAspect) {
1390
+ const adjustedHeight = Math.round(width / minAspect);
1391
+ return simplifyRatio(width, adjustedHeight);
1392
+ } else if (currentAspect > maxAspect) {
1393
+ const adjustedWidth = Math.round(height * maxAspect);
1394
+ return simplifyRatio(adjustedWidth, height);
1395
+ } else {
1396
+ return simplifyRatio(width, height);
1397
+ }
1398
+ }
1399
+
1400
+ if (String(request.body.model).endsWith('-ultra')) {
1401
+ requestBody.aspect_ratio = getClosestAspectRatio(request.body.width, request.body.height);
1402
+ delete requestBody.steps;
1403
+ delete requestBody.guidance;
1404
+ delete requestBody.width;
1405
+ delete requestBody.height;
1406
+ delete requestBody.prompt_upsampling;
1407
+ }
1408
+
1409
+ if (String(request.body.model).endsWith('-pro-1.1')) {
1410
+ delete requestBody.steps;
1411
+ delete requestBody.guidance;
1412
+ }
1413
+
1414
+ console.debug('BFL request:', requestBody);
1415
+
1416
+ const result = await fetch(`https://api.bfl.ml/v1/${request.body.model}`, {
1417
+ method: 'POST',
1418
+ body: JSON.stringify(requestBody),
1419
+ headers: {
1420
+ 'Content-Type': 'application/json',
1421
+ 'x-key': key,
1422
+ },
1423
+ });
1424
+
1425
+ if (!result.ok) {
1426
+ console.warn('BFL returned an error.');
1427
+ return response.sendStatus(500);
1428
+ }
1429
+
1430
+ /** @type {any} */
1431
+ const taskData = await result.json();
1432
+ const { id } = taskData;
1433
+
1434
+ const MAX_ATTEMPTS = 100;
1435
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
1436
+ await delay(2500);
1437
+
1438
+ const statusResult = await fetch(`https://api.bfl.ml/v1/get_result?id=${id}`);
1439
+
1440
+ if (!statusResult.ok) {
1441
+ const text = await statusResult.text();
1442
+ console.warn('BFL returned an error.', text);
1443
+ return response.sendStatus(500);
1444
+ }
1445
+
1446
+ /** @type {any} */
1447
+ const statusData = await statusResult.json();
1448
+
1449
+ if (statusData?.status === 'Pending') {
1450
+ continue;
1451
+ }
1452
+
1453
+ if (statusData?.status === 'Ready') {
1454
+ const { sample } = statusData.result;
1455
+ const fetchResult = await fetch(sample);
1456
+ const fetchData = await fetchResult.arrayBuffer();
1457
+ const image = Buffer.from(fetchData).toString('base64');
1458
+ return response.send({ image: image });
1459
+ }
1460
+
1461
+ throw new Error('BFL failed to generate image.', { cause: statusData });
1462
+ }
1463
+ } catch (error) {
1464
+ console.error(error);
1465
+ return response.sendStatus(500);
1466
+ }
1467
+ });
1468
+
1469
+ const falai = express.Router();
1470
+
1471
+ falai.post('/models', async (_request, response) => {
1472
+ try {
1473
+ const modelsUrl = new URL('https://fal.ai/api/models?categories=text-to-image');
1474
+ let page = 1;
1475
+ /** @type {any} */
1476
+ let modelsResponse;
1477
+ let models = [];
1478
+
1479
+ do {
1480
+ modelsUrl.searchParams.set('page', page.toString());
1481
+ const result = await fetch(modelsUrl);
1482
+
1483
+ if (!result.ok) {
1484
+ console.warn('FAL.AI returned an error.', result.status, result.statusText);
1485
+ throw new Error('FAL.AI request failed.');
1486
+ }
1487
+
1488
+ modelsResponse = await result.json();
1489
+ if (!('items' in modelsResponse) || !Array.isArray(modelsResponse.items)) {
1490
+ console.warn('FAL.AI returned invalid data.');
1491
+ throw new Error('FAL.AI request failed.');
1492
+ }
1493
+
1494
+ models = models.concat(
1495
+ modelsResponse.items.filter(
1496
+ x => (
1497
+ !x.title.toLowerCase().includes('inpainting') &&
1498
+ !x.title.toLowerCase().includes('control') &&
1499
+ !x.title.toLowerCase().includes('upscale') &&
1500
+ !x.title.toLowerCase().includes('lora')
1501
+ ),
1502
+ ),
1503
+ );
1504
+
1505
+ page = modelsResponse.page + 1;
1506
+ } while (modelsResponse != null && page < modelsResponse.pages);
1507
+
1508
+ const modelOptions = models
1509
+ .sort((a, b) => a.title.localeCompare(b.title))
1510
+ .map(x => ({ value: x.modelUrl.split('fal-ai/')[1], text: x.title }))
1511
+ .map(x => ({ ...x, text: `${x.text} (${x.value})` }));
1512
+ return response.send(modelOptions);
1513
+ } catch (error) {
1514
+ console.error(error);
1515
+ return response.sendStatus(500);
1516
+ }
1517
+ });
1518
+
1519
+ falai.post('/generate', async (request, response) => {
1520
+ try {
1521
+ const key = readSecret(request.user.directories, SECRET_KEYS.FALAI);
1522
+
1523
+ if (!key) {
1524
+ console.warn('FAL.AI key not found.');
1525
+ return response.sendStatus(400);
1526
+ }
1527
+
1528
+ const requestBody = {
1529
+ prompt: request.body.prompt,
1530
+ image_size: { 'width': request.body.width, 'height': request.body.height },
1531
+ num_inference_steps: request.body.steps,
1532
+ seed: request.body.seed ?? null,
1533
+ guidance_scale: request.body.guidance,
1534
+ enable_safety_checker: false, // Disable general safety checks
1535
+ safety_tolerance: 6, // Make Flux the least strict
1536
+ };
1537
+
1538
+ console.debug('FAL.AI request:', requestBody);
1539
+
1540
+ const result = await fetch(`https://queue.fal.run/fal-ai/${request.body.model}`, {
1541
+ method: 'POST',
1542
+ body: JSON.stringify(requestBody),
1543
+ headers: {
1544
+ 'Content-Type': 'application/json',
1545
+ 'Authorization': `Key ${key}`,
1546
+ },
1547
+ });
1548
+
1549
+ if (!result.ok) {
1550
+ console.warn('FAL.AI returned an error.');
1551
+ return response.sendStatus(500);
1552
+ }
1553
+
1554
+ /** @type {any} */
1555
+ const taskData = await result.json();
1556
+ const { status_url } = taskData;
1557
+
1558
+ const MAX_ATTEMPTS = 100;
1559
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
1560
+ await delay(2500);
1561
+
1562
+ const statusResult = await fetch(status_url, {
1563
+ headers: {
1564
+ 'Authorization': `Key ${key}`,
1565
+ },
1566
+ });
1567
+
1568
+ if (!statusResult.ok) {
1569
+ const text = await statusResult.text();
1570
+ console.warn('FAL.AI returned an error.', text);
1571
+ return response.sendStatus(500);
1572
+ }
1573
+
1574
+ /** @type {any} */
1575
+ const statusData = await statusResult.json();
1576
+
1577
+ if (statusData?.status === 'IN_QUEUE' || statusData?.status === 'IN_PROGRESS') {
1578
+ continue;
1579
+ }
1580
+
1581
+ if (statusData?.status === 'COMPLETED') {
1582
+ const resultFetch = await fetch(statusData?.response_url, {
1583
+ method: 'GET',
1584
+ headers: {
1585
+ 'Authorization': `Key ${key}`,
1586
+ },
1587
+ });
1588
+ /** @type {any} */
1589
+ const resultData = await resultFetch.json();
1590
+
1591
+ if (resultData.detail !== null && resultData.detail !== undefined) {
1592
+ throw new Error('FAL.AI failed to generate image.', { cause: `${resultData.detail[0].loc[1]}: ${resultData.detail[0].msg}` });
1593
+ }
1594
+
1595
+ const imageFetch = await fetch(resultData?.images[0].url, {
1596
+ headers: {
1597
+ 'Authorization': `Key ${key}`,
1598
+ },
1599
+ });
1600
+
1601
+ const fetchData = await imageFetch.arrayBuffer();
1602
+ const image = Buffer.from(fetchData).toString('base64');
1603
+ return response.send({ image: image });
1604
+ }
1605
+
1606
+ throw new Error('FAL.AI failed to generate image.', { cause: statusData });
1607
+ }
1608
+ } catch (error) {
1609
+ console.error(error);
1610
+ return response.status(500).send(error.cause || error.message);
1611
+ }
1612
+ });
1613
+
1614
+ const xai = express.Router();
1615
+
1616
+ xai.post('/generate', async (request, response) => {
1617
+ try {
1618
+ const key = readSecret(request.user.directories, SECRET_KEYS.XAI);
1619
+
1620
+ if (!key) {
1621
+ console.warn('xAI key not found.');
1622
+ return response.sendStatus(400);
1623
+ }
1624
+
1625
+ const requestBody = {
1626
+ prompt: request.body.prompt,
1627
+ model: request.body.model,
1628
+ response_format: 'b64_json',
1629
+ };
1630
+
1631
+ console.debug('xAI request:', requestBody);
1632
+
1633
+ const result = await fetch('https://api.x.ai/v1/images/generations', {
1634
+ method: 'POST',
1635
+ body: JSON.stringify(requestBody),
1636
+ headers: {
1637
+ 'Content-Type': 'application/json',
1638
+ 'Authorization': `Bearer ${key}`,
1639
+ },
1640
+ });
1641
+
1642
+ if (!result.ok) {
1643
+ const text = await result.text();
1644
+ console.warn('xAI returned an error.', text);
1645
+ return response.sendStatus(500);
1646
+ }
1647
+
1648
+ /** @type {any} */
1649
+ const data = await result.json();
1650
+
1651
+ const image = data?.data?.[0]?.b64_json;
1652
+ if (!image) {
1653
+ console.warn('xAI returned invalid data.');
1654
+ return response.sendStatus(500);
1655
+ }
1656
+
1657
+ return response.send({ image });
1658
+ } catch (error) {
1659
+ console.error('Error communicating with xAI', error);
1660
+ return response.sendStatus(500);
1661
+ }
1662
+ });
1663
+
1664
+ const aimlapi = express.Router();
1665
+
1666
+ aimlapi.post('/models', async (request, response) => {
1667
+ try {
1668
+ const key = readSecret(request.user.directories, SECRET_KEYS.AIMLAPI);
1669
+
1670
+ if (!key) {
1671
+ console.warn('AI/ML API key not found.');
1672
+ return response.sendStatus(400);
1673
+ }
1674
+
1675
+ const modelsResponse = await fetch('https://api.aimlapi.com/v1/models', {
1676
+ method: 'GET',
1677
+ headers: {
1678
+ Authorization: `Bearer ${key}`,
1679
+ },
1680
+ });
1681
+
1682
+ if (!modelsResponse.ok) {
1683
+ console.warn('AI/ML API returned an error.');
1684
+ return response.sendStatus(500);
1685
+ }
1686
+
1687
+ /** @type {any} */
1688
+ const data = await modelsResponse.json();
1689
+ const models = (data.data || [])
1690
+ .filter(model =>
1691
+ model.type === 'image' &&
1692
+ model.id !== 'triposr' &&
1693
+ model.id !== 'flux/dev/image-to-image',
1694
+ )
1695
+ .map(model => ({
1696
+ value: model.id,
1697
+ text: model.info?.name || model.id,
1698
+ }));
1699
+
1700
+ return response.send({ data: models });
1701
+ } catch (error) {
1702
+ console.error(error);
1703
+ return response.sendStatus(500);
1704
+ }
1705
+ });
1706
+
1707
+ aimlapi.post('/generate-image', async (req, res) => {
1708
+ try {
1709
+ const key = readSecret(req.user.directories, SECRET_KEYS.AIMLAPI);
1710
+ if (!key) return res.sendStatus(400);
1711
+
1712
+ console.debug('AI/ML API image request:', req.body);
1713
+
1714
+ const apiRes = await fetch('https://api.aimlapi.com/v1/images/generations', {
1715
+ method: 'POST',
1716
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}`, ...AIMLAPI_HEADERS },
1717
+ body: JSON.stringify(req.body),
1718
+ });
1719
+ if (!apiRes.ok) {
1720
+ const err = await apiRes.text();
1721
+ return res.status(500).send(err);
1722
+ }
1723
+ /** @type {any} */
1724
+ const data = await apiRes.json();
1725
+
1726
+ const imgObj = Array.isArray(data.images) ? data.images[0] : data.data?.[0];
1727
+ if (!imgObj) return res.status(500).send('No image returned');
1728
+
1729
+ let base64;
1730
+ if (imgObj.b64_json || imgObj.base64) {
1731
+ base64 = imgObj.b64_json || imgObj.base64;
1732
+ } else if (imgObj.url) {
1733
+ const blobRes = await fetch(imgObj.url);
1734
+ if (!blobRes.ok) throw new Error('Failed to fetch image URL');
1735
+ const buffer = await blobRes.arrayBuffer();
1736
+ base64 = Buffer.from(buffer).toString('base64');
1737
+ } else {
1738
+ throw new Error('Unsupported image format');
1739
+ }
1740
+
1741
+ return res.json({ format: 'png', data: base64 });
1742
+ } catch (e) {
1743
+ console.error(e);
1744
+ res.status(500).send('Internal error');
1745
+ }
1746
+ });
1747
+
1748
+ const zai = express.Router();
1749
+
1750
+ zai.post('/generate', async (request, response) => {
1751
+ try {
1752
+ const key = readSecret(request.user.directories, SECRET_KEYS.ZAI);
1753
+
1754
+ if (!key) {
1755
+ console.warn('Z.AI key not found.');
1756
+ return response.sendStatus(400);
1757
+ }
1758
+
1759
+ console.debug('Z.AI image request:', request.body);
1760
+
1761
+ const generateResponse = await fetch('https://api.z.ai/api/paas/v4/images/generations', {
1762
+ method: 'POST',
1763
+ headers: {
1764
+ 'Content-Type': 'application/json',
1765
+ 'Authorization': `Bearer ${key}`,
1766
+ },
1767
+ body: JSON.stringify({
1768
+ prompt: request.body.prompt,
1769
+ model: request.body.model,
1770
+ quality: request.body.quality,
1771
+ size: request.body.size,
1772
+ }),
1773
+ });
1774
+
1775
+ if (!generateResponse.ok) {
1776
+ const text = await generateResponse.text();
1777
+ console.warn('Z.AI returned an error.', text);
1778
+ return response.sendStatus(500);
1779
+ }
1780
+
1781
+ /** @type {any} */
1782
+ const data = await generateResponse.json();
1783
+ console.debug('Z.AI image response:', data);
1784
+
1785
+ const url = data?.data?.[0]?.url;
1786
+ if (!url || !isValidUrl(url) || !new URL(url).hostname.endsWith('.z.ai')) {
1787
+ console.warn('Z.AI returned an invalid image URL.');
1788
+ return response.sendStatus(500);
1789
+ }
1790
+
1791
+ const imageResponse = await fetch(url);
1792
+ if (!imageResponse.ok) {
1793
+ console.warn('Z.AI image fetch returned an error.');
1794
+ return response.sendStatus(500);
1795
+ }
1796
+
1797
+ const buffer = await imageResponse.arrayBuffer();
1798
+ const image = Buffer.from(buffer).toString('base64');
1799
+ const format = path.extname(url).substring(1).toLowerCase() || 'png';
1800
+
1801
+ return response.send({ image, format });
1802
+ } catch (error) {
1803
+ console.error(error);
1804
+ return response.sendStatus(500);
1805
+ }
1806
+ });
1807
+
1808
+ router.use('/comfy', comfy);
1809
+ router.use('/comfyrunpod', comfyRunPod);
1810
+ router.use('/together', together);
1811
+ router.use('/drawthings', drawthings);
1812
+ router.use('/pollinations', pollinations);
1813
+ router.use('/stability', stability);
1814
+ router.use('/huggingface', huggingface);
1815
+ router.use('/chutes', chutes);
1816
+ router.use('/electronhub', electronhub);
1817
+ router.use('/nanogpt', nanogpt);
1818
+ router.use('/bfl', bfl);
1819
+ router.use('/falai', falai);
1820
+ router.use('/xai', xai);
1821
+ router.use('/aimlapi', aimlapi);
1822
+ router.use('/zai', zai);
src/endpoints/stats.js ADDED
@@ -0,0 +1,469 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+
5
+ import express from 'express';
6
+ import writeFileAtomic from 'write-file-atomic';
7
+
8
+ const readFile = fs.promises.readFile;
9
+ const readdir = fs.promises.readdir;
10
+
11
+ import { getAllUserHandles, getUserDirectories } from '../users.js';
12
+
13
+ const STATS_FILE = 'stats.json';
14
+
15
+ const monthNames = [
16
+ 'January',
17
+ 'February',
18
+ 'March',
19
+ 'April',
20
+ 'May',
21
+ 'June',
22
+ 'July',
23
+ 'August',
24
+ 'September',
25
+ 'October',
26
+ 'November',
27
+ 'December',
28
+ ];
29
+
30
+ /**
31
+ * @type {Map<string, Object>} The stats object for each user.
32
+ */
33
+ const STATS = new Map();
34
+ /**
35
+ * @type {Map<string, number>} The timestamps for each user.
36
+ */
37
+ const TIMESTAMPS = new Map();
38
+
39
+ /**
40
+ * Convert a timestamp to an integer timestamp.
41
+ * This function can handle several different timestamp formats:
42
+ * 1. Date.now timestamps (the number of milliseconds since the Unix Epoch)
43
+ * 2. ST "humanized" timestamps, formatted like `YYYY-MM-DD@HHhMMmSSsMSms`
44
+ * 3. Date strings in the format `Month DD, YYYY H:MMam/pm`
45
+ * 4. ISO 8601 formatted strings
46
+ * 5. Date objects
47
+ *
48
+ * The function returns the timestamp as the number of milliseconds since
49
+ * the Unix Epoch, which can be converted to a JavaScript Date object with new Date().
50
+ *
51
+ * @param {string|number|Date} timestamp - The timestamp to convert.
52
+ * @returns {number} The timestamp in milliseconds since the Unix Epoch, or 0 if the input cannot be parsed.
53
+ *
54
+ * @example
55
+ * // Unix timestamp
56
+ * parseTimestamp(1609459200);
57
+ * // ST humanized timestamp
58
+ * parseTimestamp("2021-01-01 \@00h 00m 00s 000ms");
59
+ * // Date string
60
+ * parseTimestamp("January 1, 2021 12:00am");
61
+ */
62
+ function parseTimestamp(timestamp) {
63
+ if (!timestamp) {
64
+ return 0;
65
+ }
66
+
67
+ // Date object
68
+ if (timestamp instanceof Date) {
69
+ return timestamp.getTime();
70
+ }
71
+
72
+ // Unix time
73
+ if (typeof timestamp === 'number' || /^\d+$/.test(timestamp)) {
74
+ const unixTime = Number(timestamp);
75
+ const isValid = Number.isFinite(unixTime) && !Number.isNaN(unixTime) && unixTime >= 0;
76
+ if (!isValid) return 0;
77
+ return new Date(unixTime).getTime();
78
+ }
79
+
80
+ // ISO 8601 format
81
+ const isoPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
82
+ if (isoPattern.test(timestamp)) {
83
+ return new Date(timestamp).getTime();
84
+ }
85
+
86
+ let dateFormats = [];
87
+
88
+ // meridiem-based format
89
+ const convertFromMeridiemBased = (_, month, day, year, hour, minute, meridiem) => {
90
+ const monthNum = monthNames.indexOf(month) + 1;
91
+ const hour24 = meridiem.toLowerCase() === 'pm' ? (parseInt(hour, 10) % 12) + 12 : parseInt(hour, 10) % 12;
92
+ return `${year}-${monthNum}-${day.padStart(2, '0')}T${hour24.toString().padStart(2, '0')}:${minute.padStart(2, '0')}:00`;
93
+ };
94
+ // June 19, 2023 2:20pm
95
+ dateFormats.push({ callback: convertFromMeridiemBased, pattern: /(\w+)\s(\d{1,2}),\s(\d{4})\s(\d{1,2}):(\d{1,2})(am|pm)/i });
96
+
97
+ // ST "humanized" format patterns
98
+ const convertFromHumanized = (_, year, month, day, hour, min, sec, ms) => {
99
+ ms = typeof ms !== 'undefined' ? `.${ms.padStart(3, '0')}` : '';
100
+ return `${year.padStart(4, '0')}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${hour.padStart(2, '0')}:${min.padStart(2, '0')}:${sec.padStart(2, '0')}${ms}Z`;
101
+ };
102
+ // 2024-07-12@01h31m37s123ms
103
+ dateFormats.push({ callback: convertFromHumanized, pattern: /(\d{4})-(\d{1,2})-(\d{1,2})@(\d{1,2})h(\d{1,2})m(\d{1,2})s(\d{1,3})ms/ });
104
+ // 2024-7-12@01h31m37s
105
+ dateFormats.push({ callback: convertFromHumanized, pattern: /(\d{4})-(\d{1,2})-(\d{1,2})@(\d{1,2})h(\d{1,2})m(\d{1,2})s/ });
106
+ // 2024-6-5 @14h 56m 50s 682ms
107
+ dateFormats.push({ callback: convertFromHumanized, pattern: /(\d{4})-(\d{1,2})-(\d{1,2}) @(\d{1,2})h (\d{1,2})m (\d{1,2})s (\d{1,3})ms/ });
108
+
109
+ for (const x of dateFormats) {
110
+ const rgxMatch = timestamp.match(x.pattern);
111
+ if (!rgxMatch) continue;
112
+ const isoTimestamp = x.callback(...rgxMatch);
113
+ return new Date(isoTimestamp).getTime();
114
+ }
115
+
116
+ return 0;
117
+ }
118
+
119
+ /**
120
+ * Collects and aggregates stats for all characters.
121
+ *
122
+ * @param {string} chatsPath - The path to the directory containing the chat files.
123
+ * @param {string} charactersPath - The path to the directory containing the character files.
124
+ * @returns {Promise<Object>} The aggregated stats object.
125
+ */
126
+ async function collectAndCreateStats(chatsPath, charactersPath) {
127
+ const files = await readdir(charactersPath);
128
+
129
+ const pngFiles = files.filter((file) => file.endsWith('.png'));
130
+
131
+ let processingPromises = pngFiles.map((file) =>
132
+ calculateStats(chatsPath, file),
133
+ );
134
+ const statsArr = await Promise.all(processingPromises);
135
+
136
+ let finalStats = {};
137
+ for (let stat of statsArr) {
138
+ finalStats = { ...finalStats, ...stat };
139
+ }
140
+ // tag with timestamp on when stats were generated
141
+ finalStats.timestamp = Date.now();
142
+ return finalStats;
143
+ }
144
+
145
+ /**
146
+ * Recreates the stats object for a user.
147
+ * @param {string} handle User handle
148
+ * @param {string} chatsPath Path to the directory containing the chat files.
149
+ * @param {string} charactersPath Path to the directory containing the character files.
150
+ */
151
+ export async function recreateStats(handle, chatsPath, charactersPath) {
152
+ console.info('Collecting and creating stats for user:', handle);
153
+ const stats = await collectAndCreateStats(chatsPath, charactersPath);
154
+ STATS.set(handle, stats);
155
+ await saveStatsToFile();
156
+ }
157
+
158
+ /**
159
+ * Loads the stats file into memory. If the file doesn't exist or is invalid,
160
+ * initializes stats by collecting and creating them for each character.
161
+ */
162
+ export async function init() {
163
+ try {
164
+ const userHandles = await getAllUserHandles();
165
+ for (const handle of userHandles) {
166
+ const directories = getUserDirectories(handle);
167
+ try {
168
+ const statsFilePath = path.join(directories.root, STATS_FILE);
169
+ const statsFileContent = await readFile(statsFilePath, 'utf-8');
170
+ STATS.set(handle, JSON.parse(statsFileContent));
171
+ } catch (err) {
172
+ // If the file doesn't exist or is invalid, initialize stats
173
+ if (err.code === 'ENOENT' || err instanceof SyntaxError) {
174
+ await recreateStats(handle, directories.chats, directories.characters);
175
+ } else {
176
+ throw err; // Rethrow the error if it's something we didn't expect
177
+ }
178
+ }
179
+ }
180
+ } catch (err) {
181
+ console.error('Failed to initialize stats:', err);
182
+ }
183
+ // Save stats every 5 minutes
184
+ setInterval(saveStatsToFile, 5 * 60 * 1000);
185
+ }
186
+ /**
187
+ * Saves the current state of charStats to a file, only if the data has changed since the last save.
188
+ */
189
+ async function saveStatsToFile() {
190
+ const userHandles = await getAllUserHandles();
191
+ for (const handle of userHandles) {
192
+ if (!STATS.has(handle)) {
193
+ continue;
194
+ }
195
+ const charStats = STATS.get(handle);
196
+ const lastSaveTimestamp = TIMESTAMPS.get(handle) || 0;
197
+ if (charStats.timestamp > lastSaveTimestamp) {
198
+ try {
199
+ const directories = getUserDirectories(handle);
200
+ const statsFilePath = path.join(directories.root, STATS_FILE);
201
+ await writeFileAtomic(statsFilePath, JSON.stringify(charStats));
202
+ TIMESTAMPS.set(handle, Date.now());
203
+ } catch (error) {
204
+ console.error('Failed to save stats to file.', error);
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Attempts to save charStats to a file and then terminates the process.
212
+ * If an error occurs during the file write, it logs the error before exiting.
213
+ */
214
+ export async function onExit() {
215
+ try {
216
+ await saveStatsToFile();
217
+ } catch (err) {
218
+ console.error('Failed to write stats to file:', err);
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Reads the contents of a file and returns the lines in the file as an array.
224
+ *
225
+ * @param {string} filepath - The path of the file to be read.
226
+ * @returns {Array<string>} - The lines in the file.
227
+ * @throws Will throw an error if the file cannot be read.
228
+ */
229
+ function readAndParseFile(filepath) {
230
+ try {
231
+ let file = fs.readFileSync(filepath, 'utf8');
232
+ let lines = file.split('\n');
233
+ return lines;
234
+ } catch (error) {
235
+ console.error(`Error reading file at ${filepath}: ${error}`);
236
+ return [];
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Calculates the time difference between two dates.
242
+ *
243
+ * @param {string} gen_started - The start time in ISO 8601 format.
244
+ * @param {string} gen_finished - The finish time in ISO 8601 format.
245
+ * @returns {number} - The difference in time in milliseconds.
246
+ */
247
+ function calculateGenTime(gen_started, gen_finished) {
248
+ let startDate = new Date(gen_started);
249
+ let endDate = new Date(gen_finished);
250
+ return Number(endDate) - Number(startDate);
251
+ }
252
+
253
+ /**
254
+ * Counts the number of words in a string.
255
+ *
256
+ * @param {string} str - The string to count words in.
257
+ * @returns {number} - The number of words in the string.
258
+ */
259
+ function countWordsInString(str) {
260
+ const match = str.match(/\b\w+\b/g);
261
+ return match ? match.length : 0;
262
+ }
263
+
264
+ /**
265
+ * calculateStats - Calculate statistics for a given character chat directory.
266
+ *
267
+ * @param {string} chatsPath The directory containing the chat files.
268
+ * @param {string} item The name of the character.
269
+ * @return {object} An object containing the calculated statistics.
270
+ */
271
+ const calculateStats = (chatsPath, item) => {
272
+ const chatDir = path.join(chatsPath, item.replace('.png', ''));
273
+ const stats = {
274
+ total_gen_time: 0,
275
+ user_word_count: 0,
276
+ non_user_word_count: 0,
277
+ user_msg_count: 0,
278
+ non_user_msg_count: 0,
279
+ total_swipe_count: 0,
280
+ chat_size: 0,
281
+ date_last_chat: 0,
282
+ date_first_chat: new Date('9999-12-31T23:59:59.999Z').getTime(),
283
+ };
284
+ let uniqueGenStartTimes = new Set();
285
+
286
+ if (fs.existsSync(chatDir)) {
287
+ const chats = fs.readdirSync(chatDir);
288
+ if (Array.isArray(chats) && chats.length) {
289
+ for (const chat of chats) {
290
+ const result = calculateTotalGenTimeAndWordCount(
291
+ chatDir,
292
+ chat,
293
+ uniqueGenStartTimes,
294
+ );
295
+ stats.total_gen_time += result.totalGenTime || 0;
296
+ stats.user_word_count += result.userWordCount || 0;
297
+ stats.non_user_word_count += result.nonUserWordCount || 0;
298
+ stats.user_msg_count += result.userMsgCount || 0;
299
+ stats.non_user_msg_count += result.nonUserMsgCount || 0;
300
+ stats.total_swipe_count += result.totalSwipeCount || 0;
301
+
302
+ const chatStat = fs.statSync(path.join(chatDir, chat));
303
+ stats.chat_size += chatStat.size;
304
+ stats.date_last_chat = Math.max(
305
+ stats.date_last_chat,
306
+ Math.floor(chatStat.mtimeMs),
307
+ );
308
+ stats.date_first_chat = Math.min(
309
+ stats.date_first_chat,
310
+ result.firstChatTime,
311
+ );
312
+ }
313
+ }
314
+ }
315
+
316
+ return { [item]: stats };
317
+ };
318
+
319
+ /**
320
+ * Sets the current charStats object.
321
+ * @param {string} handle - The user handle.
322
+ * @param {Object} stats - The new charStats object.
323
+ **/
324
+ function setCharStats(handle, stats) {
325
+ stats.timestamp = Date.now();
326
+ STATS.set(handle, stats);
327
+ }
328
+
329
+ /**
330
+ * Calculates the total generation time and word count for a chat with a character.
331
+ *
332
+ * @param {string} chatDir - The directory path where character chat files are stored.
333
+ * @param {string} chat - The name of the chat file.
334
+ * @returns {Object} - An object containing the total generation time, user word count, and non-user word count.
335
+ * @throws Will throw an error if the file cannot be read or parsed.
336
+ */
337
+ function calculateTotalGenTimeAndWordCount(
338
+ chatDir,
339
+ chat,
340
+ uniqueGenStartTimes,
341
+ ) {
342
+ let filepath = path.join(chatDir, chat);
343
+ let lines = readAndParseFile(filepath);
344
+
345
+ let totalGenTime = 0;
346
+ let userWordCount = 0;
347
+ let nonUserWordCount = 0;
348
+ let nonUserMsgCount = 0;
349
+ let userMsgCount = 0;
350
+ let totalSwipeCount = 0;
351
+ let firstChatTime = new Date('9999-12-31T23:59:59.999Z').getTime();
352
+
353
+ for (let line of lines) {
354
+ if (line.length) {
355
+ try {
356
+ let json = JSON.parse(line);
357
+ if (json.mes) {
358
+ let hash = crypto
359
+ .createHash('sha256')
360
+ .update(json.mes)
361
+ .digest('hex');
362
+ if (uniqueGenStartTimes.has(hash)) {
363
+ continue;
364
+ }
365
+ if (hash) {
366
+ uniqueGenStartTimes.add(hash);
367
+ }
368
+ }
369
+
370
+ if (json.gen_started && json.gen_finished) {
371
+ let genTime = calculateGenTime(
372
+ json.gen_started,
373
+ json.gen_finished,
374
+ );
375
+ totalGenTime += genTime;
376
+
377
+ if (json.swipes && !json.swipe_info) {
378
+ // If there are swipes but no swipe_info, estimate the genTime
379
+ totalGenTime += genTime * json.swipes.length;
380
+ }
381
+ }
382
+
383
+ if (json.mes) {
384
+ let wordCount = countWordsInString(json.mes);
385
+ json.is_user
386
+ ? (userWordCount += wordCount)
387
+ : (nonUserWordCount += wordCount);
388
+ json.is_user ? userMsgCount++ : nonUserMsgCount++;
389
+ }
390
+
391
+ if (json.swipes && json.swipes.length > 1) {
392
+ totalSwipeCount += json.swipes.length - 1; // Subtract 1 to not count the first swipe
393
+ for (let i = 1; i < json.swipes.length; i++) {
394
+ // Start from the second swipe
395
+ let swipeText = json.swipes[i];
396
+
397
+ let wordCount = countWordsInString(swipeText);
398
+ json.is_user
399
+ ? (userWordCount += wordCount)
400
+ : (nonUserWordCount += wordCount);
401
+ json.is_user ? userMsgCount++ : nonUserMsgCount++;
402
+ }
403
+ }
404
+
405
+ if (json.swipe_info && json.swipe_info.length > 1) {
406
+ for (let i = 1; i < json.swipe_info.length; i++) {
407
+ // Start from the second swipe
408
+ let swipe = json.swipe_info[i];
409
+ if (swipe.gen_started && swipe.gen_finished) {
410
+ totalGenTime += calculateGenTime(
411
+ swipe.gen_started,
412
+ swipe.gen_finished,
413
+ );
414
+ }
415
+ }
416
+ }
417
+
418
+ // If this is the first user message, set the first chat time
419
+ if (json.is_user) {
420
+ //get min between firstChatTime and timestampToMoment(json.send_date)
421
+ firstChatTime = Math.min(parseTimestamp(json.send_date), firstChatTime);
422
+ }
423
+ } catch (error) {
424
+ console.error(`Error parsing line ${line}: ${error}`);
425
+ }
426
+ }
427
+ }
428
+ return {
429
+ totalGenTime,
430
+ userWordCount,
431
+ nonUserWordCount,
432
+ userMsgCount,
433
+ nonUserMsgCount,
434
+ totalSwipeCount,
435
+ firstChatTime,
436
+ };
437
+ }
438
+
439
+ export const router = express.Router();
440
+
441
+ /**
442
+ * Handle a POST request to get the stats object
443
+ */
444
+ router.post('/get', function (request, response) {
445
+ const stats = STATS.get(request.user.profile.handle) || {};
446
+ response.send(stats);
447
+ });
448
+
449
+ /**
450
+ * Triggers the recreation of statistics from chat files.
451
+ */
452
+ router.post('/recreate', async function (request, response) {
453
+ try {
454
+ await recreateStats(request.user.profile.handle, request.user.directories.chats, request.user.directories.characters);
455
+ return response.sendStatus(200);
456
+ } catch (error) {
457
+ console.error(error);
458
+ return response.sendStatus(500);
459
+ }
460
+ });
461
+
462
+ /**
463
+ * Handle a POST request to update the stats object
464
+ */
465
+ router.post('/update', function (request, response) {
466
+ if (!request.body) return response.sendStatus(400);
467
+ setCharStats(request.user.profile.handle, request.body);
468
+ return response.sendStatus(200);
469
+ });
src/endpoints/themes.js ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+
4
+ import express from 'express';
5
+ import sanitize from 'sanitize-filename';
6
+ import { sync as writeFileAtomicSync } from 'write-file-atomic';
7
+
8
+ export const router = express.Router();
9
+
10
+ router.post('/save', (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', (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.unlinkSync(filename);
33
+ return response.sendStatus(200);
34
+ } catch (error) {
35
+ console.error(error);
36
+ return response.sendStatus(500);
37
+ }
38
+ });
src/endpoints/thumbnails.js ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs';
2
+ import { promises as fsPromises } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import mime from 'mime-types';
6
+ import express from 'express';
7
+ import sanitize from 'sanitize-filename';
8
+ import { Jimp, JimpMime } from '../jimp.js';
9
+ import { sync as writeFileAtomicSync } from 'write-file-atomic';
10
+
11
+ import { getConfigValue, invalidateFirefoxCache } from '../util.js';
12
+
13
+ const thumbnailsEnabled = !!getConfigValue('thumbnails.enabled', true, 'boolean');
14
+ const quality = Math.min(100, Math.max(1, parseInt(getConfigValue('thumbnails.quality', 95, 'number'))));
15
+ const pngFormat = String(getConfigValue('thumbnails.format', 'jpg')).toLowerCase().trim() === 'png';
16
+
17
+ /**
18
+ * @typedef {'bg' | 'avatar' | 'persona'} ThumbnailType
19
+ */
20
+
21
+ /** @type {Record<string, number[]>} */
22
+ export const dimensions = {
23
+ 'bg': getConfigValue('thumbnails.dimensions.bg', [160, 90]),
24
+ 'avatar': getConfigValue('thumbnails.dimensions.avatar', [96, 144]),
25
+ 'persona': getConfigValue('thumbnails.dimensions.persona', [96, 144]),
26
+ };
27
+
28
+ /**
29
+ * Gets a path to thumbnail folder based on the type.
30
+ * @param {import('../users.js').UserDirectoryList} directories User directories
31
+ * @param {ThumbnailType} type Thumbnail type
32
+ * @returns {string} Path to the thumbnails folder
33
+ */
34
+ function getThumbnailFolder(directories, type) {
35
+ let thumbnailFolder;
36
+
37
+ switch (type) {
38
+ case 'bg':
39
+ thumbnailFolder = directories.thumbnailsBg;
40
+ break;
41
+ case 'avatar':
42
+ thumbnailFolder = directories.thumbnailsAvatar;
43
+ break;
44
+ case 'persona':
45
+ thumbnailFolder = directories.thumbnailsPersona;
46
+ break;
47
+ }
48
+
49
+ return thumbnailFolder;
50
+ }
51
+
52
+ /**
53
+ * Gets a path to the original images folder based on the type.
54
+ * @param {import('../users.js').UserDirectoryList} directories User directories
55
+ * @param {ThumbnailType} type Thumbnail type
56
+ * @returns {string} Path to the original images folder
57
+ */
58
+ function getOriginalFolder(directories, type) {
59
+ let originalFolder;
60
+
61
+ switch (type) {
62
+ case 'bg':
63
+ originalFolder = directories.backgrounds;
64
+ break;
65
+ case 'avatar':
66
+ originalFolder = directories.characters;
67
+ break;
68
+ case 'persona':
69
+ originalFolder = directories.avatars;
70
+ break;
71
+ }
72
+
73
+ return originalFolder;
74
+ }
75
+
76
+ /**
77
+ * Removes the generated thumbnail from the disk.
78
+ * @param {import('../users.js').UserDirectoryList} directories User directories
79
+ * @param {ThumbnailType} type Type of the thumbnail
80
+ * @param {string} file Name of the file
81
+ */
82
+ export function invalidateThumbnail(directories, type, file) {
83
+ const folder = getThumbnailFolder(directories, type);
84
+ if (folder === undefined) throw new Error('Invalid thumbnail type');
85
+
86
+ const pathToThumbnail = path.join(folder, sanitize(file));
87
+
88
+ if (fs.existsSync(pathToThumbnail)) {
89
+ fs.unlinkSync(pathToThumbnail);
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Generates a thumbnail for the given file.
95
+ * @param {import('../users.js').UserDirectoryList} directories User directories
96
+ * @param {ThumbnailType} type Type of the thumbnail
97
+ * @param {string} file Name of the file
98
+ * @returns
99
+ */
100
+ async function generateThumbnail(directories, type, file) {
101
+ let thumbnailFolder = getThumbnailFolder(directories, type);
102
+ let originalFolder = getOriginalFolder(directories, type);
103
+ if (thumbnailFolder === undefined || originalFolder === undefined) throw new Error('Invalid thumbnail type');
104
+ const pathToCachedFile = path.join(thumbnailFolder, file);
105
+ const pathToOriginalFile = path.join(originalFolder, file);
106
+
107
+ const cachedFileExists = fs.existsSync(pathToCachedFile);
108
+ const originalFileExists = fs.existsSync(pathToOriginalFile);
109
+
110
+ // to handle cases when original image was updated after thumb creation
111
+ let shouldRegenerate = false;
112
+
113
+ if (cachedFileExists && originalFileExists) {
114
+ const originalStat = fs.statSync(pathToOriginalFile);
115
+ const cachedStat = fs.statSync(pathToCachedFile);
116
+
117
+ if (originalStat.mtimeMs > cachedStat.ctimeMs) {
118
+ //console.warn('Original file changed. Regenerating thumbnail...');
119
+ shouldRegenerate = true;
120
+ }
121
+ }
122
+
123
+ if (cachedFileExists && !shouldRegenerate) {
124
+ return pathToCachedFile;
125
+ }
126
+
127
+ if (!originalFileExists) {
128
+ return null;
129
+ }
130
+
131
+ try {
132
+ let buffer;
133
+
134
+ try {
135
+ const size = dimensions[type];
136
+ const image = await Jimp.read(pathToOriginalFile);
137
+ const width = !isNaN(size?.[0]) && size?.[0] > 0 ? size[0] : image.bitmap.width;
138
+ const height = !isNaN(size?.[1]) && size?.[1] > 0 ? size[1] : image.bitmap.height;
139
+ image.cover({ w: width, h: height });
140
+ buffer = pngFormat
141
+ ? await image.getBuffer(JimpMime.png)
142
+ : await image.getBuffer(JimpMime.jpeg, { quality: quality, jpegColorSpace: 'ycbcr' });
143
+ }
144
+ catch (inner) {
145
+ console.warn(`Thumbnailer can not process the image: ${pathToOriginalFile}. Using original size`, inner);
146
+ buffer = fs.readFileSync(pathToOriginalFile);
147
+ }
148
+
149
+ writeFileAtomicSync(pathToCachedFile, buffer);
150
+ }
151
+ catch (outer) {
152
+ return null;
153
+ }
154
+
155
+ return pathToCachedFile;
156
+ }
157
+
158
+ /**
159
+ * Ensures that the thumbnail cache for backgrounds is valid.
160
+ * @param {import('../users.js').UserDirectoryList[]} directoriesList User directories
161
+ * @returns {Promise<void>} Promise that resolves when the cache is validated
162
+ */
163
+ export async function ensureThumbnailCache(directoriesList) {
164
+ for (const directories of directoriesList) {
165
+ const cacheFiles = fs.readdirSync(directories.thumbnailsBg);
166
+
167
+ // files exist, all ok
168
+ if (cacheFiles.length) {
169
+ continue;
170
+ }
171
+
172
+ console.info('Generating thumbnails cache. Please wait...');
173
+
174
+ const bgFiles = fs.readdirSync(directories.backgrounds);
175
+ const tasks = [];
176
+
177
+ for (const file of bgFiles) {
178
+ tasks.push(generateThumbnail(directories, 'bg', file));
179
+ }
180
+
181
+ await Promise.all(tasks);
182
+ console.info(`Done! Generated: ${bgFiles.length} preview images`);
183
+ }
184
+ }
185
+
186
+ export const router = express.Router();
187
+
188
+ // Important: This route must be mounted as '/thumbnail'. It is used in the client code and saved to chat files.
189
+ router.get('/', async function (request, response) {
190
+ try{
191
+ if (typeof request.query.file !== 'string' || typeof request.query.type !== 'string') {
192
+ return response.sendStatus(400);
193
+ }
194
+
195
+ const type = request.query.type;
196
+ const file = sanitize(request.query.file);
197
+
198
+ if (!type || !file) {
199
+ return response.sendStatus(400);
200
+ }
201
+
202
+ if (!(type === 'bg' || type === 'avatar' || type === 'persona')) {
203
+ return response.sendStatus(400);
204
+ }
205
+
206
+ if (sanitize(file) !== file) {
207
+ console.error('Malicious filename prevented');
208
+ return response.sendStatus(403);
209
+ }
210
+
211
+ if (!thumbnailsEnabled) {
212
+ const folder = getOriginalFolder(request.user.directories, type);
213
+
214
+ if (folder === undefined) {
215
+ return response.sendStatus(400);
216
+ }
217
+
218
+ const pathToOriginalFile = path.join(folder, file);
219
+ if (!fs.existsSync(pathToOriginalFile)) {
220
+ return response.sendStatus(404);
221
+ }
222
+ const contentType = mime.lookup(pathToOriginalFile) || 'image/png';
223
+ const originalFile = await fsPromises.readFile(pathToOriginalFile);
224
+ response.setHeader('Content-Type', contentType);
225
+
226
+ invalidateFirefoxCache(pathToOriginalFile, request, response);
227
+
228
+ return response.send(originalFile);
229
+ }
230
+
231
+ const pathToCachedFile = await generateThumbnail(request.user.directories, type, file);
232
+
233
+ if (!pathToCachedFile) {
234
+ return response.sendStatus(404);
235
+ }
236
+
237
+ if (!fs.existsSync(pathToCachedFile)) {
238
+ return response.sendStatus(404);
239
+ }
240
+
241
+ const contentType = mime.lookup(pathToCachedFile) || 'image/jpeg';
242
+ const cachedFile = await fsPromises.readFile(pathToCachedFile);
243
+ response.setHeader('Content-Type', contentType);
244
+
245
+ invalidateFirefoxCache(file, request, response);
246
+
247
+ return response.send(cachedFile);
248
+ } catch (error) {
249
+ console.error('Failed getting thumbnail', error);
250
+ return response.sendStatus(500);
251
+ }
252
+ });
src/endpoints/tokenizers.js ADDED
@@ -0,0 +1,1128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Buffer } from 'node:buffer';
4
+ import zlib from 'node:zlib';
5
+ import { promisify } from 'node:util';
6
+
7
+ import express from 'express';
8
+ import fetch from 'node-fetch';
9
+ import { sync as writeFileAtomicSync } from 'write-file-atomic';
10
+
11
+ import { Tokenizer } from '@agnai/web-tokenizers';
12
+ import { SentencePieceProcessor } from '@agnai/sentencepiece-js';
13
+ import tiktoken from 'tiktoken';
14
+
15
+ import { convertClaudePrompt } from '../prompt-converters.js';
16
+ import { TEXTGEN_TYPES } from '../constants.js';
17
+ import { setAdditionalHeaders } from '../additional-headers.js';
18
+ import { getConfigValue, isValidUrl } from '../util.js';
19
+
20
+ /**
21
+ * @typedef { (req: import('express').Request, res: import('express').Response) => Promise<any> } TokenizationHandler
22
+ */
23
+
24
+ /**
25
+ * @type {{[key: string]: import('tiktoken').Tiktoken}} Tokenizers cache
26
+ */
27
+ const tokenizersCache = {};
28
+
29
+ /**
30
+ * @type {string[]}
31
+ */
32
+ export const TEXT_COMPLETION_MODELS = [
33
+ 'gpt-3.5-turbo-instruct',
34
+ 'gpt-3.5-turbo-instruct-0914',
35
+ 'text-davinci-003',
36
+ 'text-davinci-002',
37
+ 'text-davinci-001',
38
+ 'text-curie-001',
39
+ 'text-babbage-001',
40
+ 'text-ada-001',
41
+ 'code-davinci-002',
42
+ 'code-davinci-001',
43
+ 'code-cushman-002',
44
+ 'code-cushman-001',
45
+ 'text-davinci-edit-001',
46
+ 'code-davinci-edit-001',
47
+ 'text-embedding-ada-002',
48
+ 'text-similarity-davinci-001',
49
+ 'text-similarity-curie-001',
50
+ 'text-similarity-babbage-001',
51
+ 'text-similarity-ada-001',
52
+ 'text-search-davinci-doc-001',
53
+ 'text-search-curie-doc-001',
54
+ 'text-search-babbage-doc-001',
55
+ 'text-search-ada-doc-001',
56
+ 'code-search-babbage-code-001',
57
+ 'code-search-ada-code-001',
58
+ ];
59
+
60
+ const CHARS_PER_TOKEN = 3.35;
61
+ const IS_DOWNLOAD_ALLOWED = getConfigValue('enableDownloadableTokenizers', true, 'boolean');
62
+ const gunzip = promisify(zlib.gunzip);
63
+
64
+ /**
65
+ * Gets a path to the tokenizer model. Downloads the model if it's a URL.
66
+ * @param {string} model Model URL or path
67
+ * @param {string|undefined} fallbackModel Fallback model path
68
+ * @returns {Promise<string>} Path to the tokenizer model
69
+ */
70
+ async function getPathToTokenizer(model, fallbackModel) {
71
+ if (!isValidUrl(model)) {
72
+ return model;
73
+ }
74
+
75
+ try {
76
+ const url = new URL(model);
77
+
78
+ if (!['https:', 'http:'].includes(url.protocol)) {
79
+ throw new Error('Invalid URL protocol');
80
+ }
81
+
82
+ const fileName = url.pathname.split('/').pop();
83
+
84
+ if (!fileName) {
85
+ throw new Error('Failed to extract the file name from the URL');
86
+ }
87
+
88
+ const CACHE_PATH = path.join(globalThis.DATA_ROOT, '_cache');
89
+ if (!fs.existsSync(CACHE_PATH)) {
90
+ fs.mkdirSync(CACHE_PATH, { recursive: true });
91
+ }
92
+
93
+ // If an uncompressed version exists, return it
94
+ const isCompressed = path.extname(fileName) === '.gz';
95
+ const uncompressedName = path.basename(fileName, '.gz');
96
+ const uncompressedPath = path.join(CACHE_PATH, uncompressedName);
97
+ if (isCompressed && fs.existsSync(uncompressedPath)) {
98
+ return uncompressedPath;
99
+ }
100
+
101
+ const cachedFile = path.join(CACHE_PATH, fileName);
102
+ if (fs.existsSync(cachedFile)) {
103
+ // If the file was downloaded manually
104
+ if (isCompressed) {
105
+ const compressedBuffer = await fs.promises.readFile(cachedFile);
106
+ const decompressedBuffer = await gunzip(compressedBuffer);
107
+ writeFileAtomicSync(uncompressedPath, decompressedBuffer);
108
+ await fs.promises.unlink(cachedFile);
109
+ return uncompressedPath;
110
+ }
111
+ return cachedFile;
112
+ }
113
+
114
+ if (!IS_DOWNLOAD_ALLOWED) {
115
+ throw new Error('Downloading tokenizers is disabled, the model is not cached');
116
+ }
117
+
118
+ console.info('Downloading tokenizer model:', model);
119
+ const response = await fetch(model);
120
+ if (!response.ok) {
121
+ throw new Error(`Failed to fetch the model: ${response.status} ${response.statusText}`);
122
+ }
123
+
124
+ const arrayBuffer = await response.arrayBuffer();
125
+ if (isCompressed) {
126
+ const decompressedBuffer = await gunzip(arrayBuffer);
127
+ writeFileAtomicSync(uncompressedPath, decompressedBuffer);
128
+ return uncompressedPath;
129
+ }
130
+
131
+ writeFileAtomicSync(cachedFile, Buffer.from(arrayBuffer));
132
+ return cachedFile;
133
+ } catch (error) {
134
+ const getLastSegment = str => str?.split('/')?.pop() || '';
135
+ if (fallbackModel) {
136
+ console.error(`Could not get a tokenizer from ${getLastSegment(model)}. Reason: ${error.message}. Using a fallback model: ${getLastSegment(fallbackModel)}.`);
137
+ return fallbackModel;
138
+ }
139
+
140
+ throw new Error(`Failed to instantiate a tokenizer and fallback is not provided. Reason: ${error.message}`);
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Sentencepiece tokenizer for tokenizing text.
146
+ */
147
+ class SentencePieceTokenizer {
148
+ /**
149
+ * @type {import('@agnai/sentencepiece-js').SentencePieceProcessor} Sentencepiece tokenizer instance
150
+ */
151
+ #instance;
152
+ /**
153
+ * @type {string} Path to the tokenizer model
154
+ */
155
+ #model;
156
+ /**
157
+ * @type {string|undefined} Path to the fallback model
158
+ */
159
+ #fallbackModel;
160
+
161
+ /**
162
+ * Creates a new Sentencepiece tokenizer.
163
+ * @param {string} model Path to the tokenizer model
164
+ * @param {string} [fallbackModel] Path to the fallback model
165
+ */
166
+ constructor(model, fallbackModel) {
167
+ this.#model = model;
168
+ this.#fallbackModel = fallbackModel;
169
+ }
170
+
171
+ /**
172
+ * Gets the Sentencepiece tokenizer instance.
173
+ * @returns {Promise<import('@agnai/sentencepiece-js').SentencePieceProcessor|null>} Sentencepiece tokenizer instance
174
+ */
175
+ async get() {
176
+ if (this.#instance) {
177
+ return this.#instance;
178
+ }
179
+
180
+ try {
181
+ const pathToModel = await getPathToTokenizer(this.#model, this.#fallbackModel);
182
+ this.#instance = new SentencePieceProcessor();
183
+ await this.#instance.load(pathToModel);
184
+ console.info('Instantiated the tokenizer for', path.parse(pathToModel).name);
185
+ return this.#instance;
186
+ } catch (error) {
187
+ console.error('Sentencepiece tokenizer failed to load: ' + this.#model, error);
188
+ return null;
189
+ }
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Web tokenizer for tokenizing text.
195
+ */
196
+ class WebTokenizer {
197
+ /**
198
+ * @type {Tokenizer} Web tokenizer instance
199
+ */
200
+ #instance;
201
+ /**
202
+ * @type {string} Path to the tokenizer model
203
+ */
204
+ #model;
205
+ /**
206
+ * @type {string|undefined} Path to the fallback model
207
+ */
208
+ #fallbackModel;
209
+
210
+ /**
211
+ * Creates a new Web tokenizer.
212
+ * @param {string} model Path to the tokenizer model
213
+ * @param {string} [fallbackModel] Path to the fallback model
214
+ */
215
+ constructor(model, fallbackModel) {
216
+ this.#model = model;
217
+ this.#fallbackModel = fallbackModel;
218
+ }
219
+
220
+ /**
221
+ * Gets the Web tokenizer instance.
222
+ * @returns {Promise<Tokenizer|null>} Web tokenizer instance
223
+ */
224
+ async get() {
225
+ if (this.#instance) {
226
+ return this.#instance;
227
+ }
228
+
229
+ try {
230
+ const pathToModel = await getPathToTokenizer(this.#model, this.#fallbackModel);
231
+ const fileBuffer = await fs.promises.readFile(pathToModel);
232
+ this.#instance = await Tokenizer.fromJSON(fileBuffer);
233
+ console.info('Instantiated the tokenizer for', path.parse(pathToModel).name);
234
+ return this.#instance;
235
+ } catch (error) {
236
+ console.error('Web tokenizer failed to load: ' + this.#model, error);
237
+ return null;
238
+ }
239
+ }
240
+ }
241
+
242
+ const spp_llama = new SentencePieceTokenizer('src/tokenizers/llama.model');
243
+ const spp_nerd = new SentencePieceTokenizer('src/tokenizers/nerdstash.model');
244
+ const spp_nerd_v2 = new SentencePieceTokenizer('src/tokenizers/nerdstash_v2.model');
245
+ const spp_mistral = new SentencePieceTokenizer('src/tokenizers/mistral.model');
246
+ const spp_yi = new SentencePieceTokenizer('src/tokenizers/yi.model');
247
+ const spp_gemma = new SentencePieceTokenizer('src/tokenizers/gemma.model');
248
+ const spp_jamba = new SentencePieceTokenizer('src/tokenizers/jamba.model');
249
+ const claude_tokenizer = new WebTokenizer('src/tokenizers/claude.json');
250
+ const llama3_tokenizer = new WebTokenizer('src/tokenizers/llama3.json');
251
+ const commandRTokenizer = new WebTokenizer('https://github.com/TavernIntern/TavernIntern-Tokenizers/raw/main/command-r.json.gz', 'src/tokenizers/llama3.json');
252
+ const commandATokenizer = new WebTokenizer('https://github.com/TavernIntern/TavernIntern-Tokenizers/raw/main/command-a.json.gz', 'src/tokenizers/llama3.json');
253
+ const qwen2Tokenizer = new WebTokenizer('https://github.com/TavernIntern/TavernIntern-Tokenizers/raw/main/qwen2.json.gz', 'src/tokenizers/llama3.json');
254
+ const nemoTokenizer = new WebTokenizer('https://github.com/TavernIntern/TavernIntern-Tokenizers/raw/main/nemo.json.gz', 'src/tokenizers/llama3.json');
255
+ const deepseekTokenizer = new WebTokenizer('https://github.com/TavernIntern/TavernIntern-Tokenizers/raw/main/deepseek.json.gz', 'src/tokenizers/llama3.json');
256
+
257
+ export const sentencepieceTokenizers = [
258
+ 'llama',
259
+ 'nerdstash',
260
+ 'nerdstash_v2',
261
+ 'mistral',
262
+ 'yi',
263
+ 'gemma',
264
+ 'jamba',
265
+ ];
266
+
267
+ export const webTokenizers = [
268
+ 'claude',
269
+ 'llama3',
270
+ 'command-r',
271
+ 'command-a',
272
+ 'qwen2',
273
+ 'nemo',
274
+ 'deepseek',
275
+ ];
276
+
277
+ /**
278
+ * Gets the Sentencepiece tokenizer by the model name.
279
+ * @param {string} model Sentencepiece model name
280
+ * @returns {SentencePieceTokenizer|null} Sentencepiece tokenizer
281
+ */
282
+ export function getSentencepiceTokenizer(model) {
283
+ if (model.includes('llama')) {
284
+ return spp_llama;
285
+ }
286
+
287
+ if (model.includes('nerdstash')) {
288
+ return spp_nerd;
289
+ }
290
+
291
+ if (model.includes('mistral')) {
292
+ return spp_mistral;
293
+ }
294
+
295
+ if (model.includes('nerdstash_v2')) {
296
+ return spp_nerd_v2;
297
+ }
298
+
299
+ if (model.includes('yi')) {
300
+ return spp_yi;
301
+ }
302
+
303
+ if (model.includes('gemma')) {
304
+ return spp_gemma;
305
+ }
306
+
307
+ if (model.includes('jamba')) {
308
+ return spp_jamba;
309
+ }
310
+
311
+ return null;
312
+ }
313
+
314
+ /**
315
+ * Gets the Web tokenizer by the model name.
316
+ * @param {string} model Web tokenizer model name
317
+ * @returns {WebTokenizer|null} Web tokenizer
318
+ */
319
+ export function getWebTokenizer(model) {
320
+ if (model.includes('llama3')) {
321
+ return llama3_tokenizer;
322
+ }
323
+
324
+ if (model.includes('claude')) {
325
+ return claude_tokenizer;
326
+ }
327
+
328
+ if (model.includes('command-r')) {
329
+ return commandRTokenizer;
330
+ }
331
+
332
+ if (model.includes('command-a')) {
333
+ return commandATokenizer;
334
+ }
335
+
336
+ if (model.includes('qwen2')) {
337
+ return qwen2Tokenizer;
338
+ }
339
+
340
+ if (model.includes('nemo')) {
341
+ return nemoTokenizer;
342
+ }
343
+
344
+ if (model.includes('deepseek')) {
345
+ return deepseekTokenizer;
346
+ }
347
+
348
+ return null;
349
+ }
350
+
351
+ /**
352
+ * Counts the token ids for the given text using the Sentencepiece tokenizer.
353
+ * @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer
354
+ * @param {string} text Text to tokenize
355
+ * @returns { Promise<{ids: number[], count: number}> } Tokenization result
356
+ */
357
+ async function countSentencepieceTokens(tokenizer, text) {
358
+ const instance = await tokenizer?.get();
359
+
360
+ // Fallback to strlen estimation
361
+ if (!instance) {
362
+ return {
363
+ ids: [],
364
+ count: Math.ceil(text.length / CHARS_PER_TOKEN),
365
+ };
366
+ }
367
+
368
+ let cleaned = text; // cleanText(text); <-- cleaning text can result in an incorrect tokenization
369
+
370
+ let ids = instance.encodeIds(cleaned);
371
+ return {
372
+ ids,
373
+ count: ids.length,
374
+ };
375
+ }
376
+
377
+ /**
378
+ * Counts the tokens in the given array of objects using the Sentencepiece tokenizer.
379
+ * @param {SentencePieceTokenizer} tokenizer
380
+ * @param {object[]} array Array of objects to tokenize
381
+ * @returns {Promise<number>} Number of tokens
382
+ */
383
+ async function countSentencepieceArrayTokens(tokenizer, array) {
384
+ const jsonBody = array.flatMap(x => Object.values(x)).join('\n\n');
385
+ const result = await countSentencepieceTokens(tokenizer, jsonBody);
386
+ const num_tokens = result.count;
387
+ return num_tokens;
388
+ }
389
+
390
+ async function getTiktokenChunks(tokenizer, ids) {
391
+ const decoder = new TextDecoder();
392
+ const chunks = [];
393
+
394
+ for (let i = 0; i < ids.length; i++) {
395
+ const id = ids[i];
396
+ const chunkTextBytes = await tokenizer.decode(new Uint32Array([id]));
397
+ const chunkText = decoder.decode(chunkTextBytes);
398
+ chunks.push(chunkText);
399
+ }
400
+
401
+ return chunks;
402
+ }
403
+
404
+ /**
405
+ * Gets the token chunks for the given token IDs using the Web tokenizer.
406
+ * @param {Tokenizer} tokenizer Web tokenizer instance
407
+ * @param {number[]} ids Token IDs
408
+ * @returns {string[]} Token chunks
409
+ */
410
+ function getWebTokenizersChunks(tokenizer, ids) {
411
+ const chunks = [];
412
+
413
+ for (let i = 0, lastProcessed = 0; i < ids.length; i++) {
414
+ const chunkIds = ids.slice(lastProcessed, i + 1);
415
+ const chunkText = tokenizer.decode(new Int32Array(chunkIds));
416
+ if (chunkText === '�') {
417
+ continue;
418
+ }
419
+ chunks.push(chunkText);
420
+ lastProcessed = i + 1;
421
+ }
422
+
423
+ return chunks;
424
+ }
425
+
426
+ /**
427
+ * Gets the tokenizer model by the model name.
428
+ * @param {string} requestModel Models to use for tokenization
429
+ * @returns {string} Tokenizer model to use
430
+ */
431
+ export function getTokenizerModel(requestModel) {
432
+ if (requestModel === 'o1' || requestModel.includes('o1-preview') || requestModel.includes('o1-mini') || requestModel.includes('o3-mini')) {
433
+ return 'o1';
434
+ }
435
+
436
+ if (requestModel.includes('gpt-5') || requestModel.includes('o3') || requestModel.includes('o4-mini')) {
437
+ return 'o1';
438
+ }
439
+
440
+ if (requestModel.includes('gpt-4o') || requestModel.includes('chatgpt-4o-latest')) {
441
+ return 'gpt-4o';
442
+ }
443
+
444
+ if (requestModel.includes('gpt-4.1') || requestModel.includes('gpt-4.5')) {
445
+ return 'gpt-4o';
446
+ }
447
+
448
+ if (requestModel.includes('gpt-4-32k')) {
449
+ return 'gpt-4-32k';
450
+ }
451
+
452
+ if (requestModel.includes('gpt-4')) {
453
+ return 'gpt-4';
454
+ }
455
+
456
+ if (requestModel.includes('gpt-3.5-turbo-0301')) {
457
+ return 'gpt-3.5-turbo-0301';
458
+ }
459
+
460
+ if (requestModel.includes('gpt-3.5-turbo')) {
461
+ return 'gpt-3.5-turbo';
462
+ }
463
+
464
+ if (TEXT_COMPLETION_MODELS.includes(requestModel)) {
465
+ return requestModel;
466
+ }
467
+
468
+ if (requestModel.includes('claude')) {
469
+ return 'claude';
470
+ }
471
+
472
+ if (requestModel.includes('llama3') || requestModel.includes('llama-3')) {
473
+ return 'llama3';
474
+ }
475
+
476
+ if (requestModel.includes('llama')) {
477
+ return 'llama';
478
+ }
479
+
480
+ if (requestModel.includes('mistral')) {
481
+ return 'mistral';
482
+ }
483
+
484
+ if (requestModel.includes('yi')) {
485
+ return 'yi';
486
+ }
487
+
488
+ if (requestModel.includes('deepseek')) {
489
+ return 'deepseek';
490
+ }
491
+
492
+ if (requestModel.includes('gemma') || requestModel.includes('gemini') || requestModel.includes('learnlm')) {
493
+ return 'gemma';
494
+ }
495
+
496
+ if (requestModel.includes('jamba')) {
497
+ return 'jamba';
498
+ }
499
+
500
+ if (requestModel.includes('qwen2')) {
501
+ return 'qwen2';
502
+ }
503
+
504
+ if (requestModel.includes('command-r')) {
505
+ return 'command-r';
506
+ }
507
+
508
+ if (requestModel.includes('command-a')) {
509
+ return 'command-a';
510
+ }
511
+
512
+ if (requestModel.includes('nemo')) {
513
+ return 'nemo';
514
+ }
515
+
516
+ // default
517
+ return 'gpt-3.5-turbo';
518
+ }
519
+
520
+ export function getTiktokenTokenizer(model) {
521
+ if (tokenizersCache[model]) {
522
+ return tokenizersCache[model];
523
+ }
524
+
525
+ const tokenizer = tiktoken.encoding_for_model(model);
526
+ console.info('Instantiated the tokenizer for', model);
527
+ tokenizersCache[model] = tokenizer;
528
+ return tokenizer;
529
+ }
530
+
531
+ /**
532
+ * Counts the tokens for the given messages using the WebTokenizer and Claude prompt conversion.
533
+ * @param {Tokenizer} tokenizer Web tokenizer
534
+ * @param {object[]} messages Array of messages
535
+ * @returns {number} Number of tokens
536
+ */
537
+ export function countWebTokenizerTokens(tokenizer, messages) {
538
+ // Should be fine if we use the old conversion method instead of the messages API one i think?
539
+ const convertedPrompt = convertClaudePrompt(messages, false, '', false, false, '', false);
540
+
541
+ // Fallback to strlen estimation
542
+ if (!tokenizer) {
543
+ return Math.ceil(convertedPrompt.length / CHARS_PER_TOKEN);
544
+ }
545
+
546
+ const count = tokenizer.encode(convertedPrompt).length;
547
+ return count;
548
+ }
549
+
550
+ /**
551
+ * Creates an API handler for encoding Sentencepiece tokens.
552
+ * @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer
553
+ * @returns {TokenizationHandler} Handler function
554
+ */
555
+ function createSentencepieceEncodingHandler(tokenizer) {
556
+ /**
557
+ * Request handler for encoding Sentencepiece tokens.
558
+ * @param {import('express').Request} request
559
+ * @param {import('express').Response} response
560
+ */
561
+ return async function (request, response) {
562
+ try {
563
+ if (!request.body) {
564
+ return response.sendStatus(400);
565
+ }
566
+
567
+ const text = request.body.text || '';
568
+ const instance = await tokenizer?.get();
569
+ const { ids, count } = await countSentencepieceTokens(tokenizer, text);
570
+ const chunks = instance?.encodePieces(text);
571
+ return response.send({ ids, count, chunks });
572
+ } catch (error) {
573
+ console.error(error);
574
+ return response.send({ ids: [], count: 0, chunks: [] });
575
+ }
576
+ };
577
+ }
578
+
579
+ /**
580
+ * Creates an API handler for decoding Sentencepiece tokens.
581
+ * @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer
582
+ * @returns {TokenizationHandler} Handler function
583
+ */
584
+ function createSentencepieceDecodingHandler(tokenizer) {
585
+ /**
586
+ * Request handler for decoding Sentencepiece tokens.
587
+ * @param {import('express').Request} request
588
+ * @param {import('express').Response} response
589
+ */
590
+ return async function (request, response) {
591
+ try {
592
+ if (!request.body) {
593
+ return response.sendStatus(400);
594
+ }
595
+
596
+ const ids = request.body.ids || [];
597
+ const instance = await tokenizer?.get();
598
+ if (!instance) throw new Error('Failed to load the Sentencepiece tokenizer');
599
+ const ops = ids.map(id => instance.decodeIds([id]));
600
+ const chunks = await Promise.all(ops);
601
+ const text = chunks.join('');
602
+ return response.send({ text, chunks });
603
+ } catch (error) {
604
+ console.error(error);
605
+ return response.send({ text: '', chunks: [] });
606
+ }
607
+ };
608
+ }
609
+
610
+ /**
611
+ * Creates an API handler for encoding Tiktoken tokens.
612
+ * @param {string} modelId Tiktoken model ID
613
+ * @returns {TokenizationHandler} Handler function
614
+ */
615
+ function createTiktokenEncodingHandler(modelId) {
616
+ /**
617
+ * Request handler for encoding Tiktoken tokens.
618
+ * @param {import('express').Request} request
619
+ * @param {import('express').Response} response
620
+ */
621
+ return async function (request, response) {
622
+ try {
623
+ if (!request.body) {
624
+ return response.sendStatus(400);
625
+ }
626
+
627
+ const text = request.body.text || '';
628
+ const tokenizer = getTiktokenTokenizer(modelId);
629
+ const tokens = Object.values(tokenizer.encode(text));
630
+ const chunks = await getTiktokenChunks(tokenizer, tokens);
631
+ return response.send({ ids: tokens, count: tokens.length, chunks });
632
+ } catch (error) {
633
+ console.error(error);
634
+ return response.send({ ids: [], count: 0, chunks: [] });
635
+ }
636
+ };
637
+ }
638
+
639
+ /**
640
+ * Creates an API handler for decoding Tiktoken tokens.
641
+ * @param {string} modelId Tiktoken model ID
642
+ * @returns {TokenizationHandler} Handler function
643
+ */
644
+ function createTiktokenDecodingHandler(modelId) {
645
+ /**
646
+ * Request handler for decoding Tiktoken tokens.
647
+ * @param {import('express').Request} request
648
+ * @param {import('express').Response} response
649
+ */
650
+ return async function (request, response) {
651
+ try {
652
+ if (!request.body) {
653
+ return response.sendStatus(400);
654
+ }
655
+
656
+ const ids = request.body.ids || [];
657
+ const tokenizer = getTiktokenTokenizer(modelId);
658
+ const textBytes = tokenizer.decode(new Uint32Array(ids));
659
+ const text = new TextDecoder().decode(textBytes);
660
+ return response.send({ text });
661
+ } catch (error) {
662
+ console.error(error);
663
+ return response.send({ text: '' });
664
+ }
665
+ };
666
+ }
667
+
668
+ /**
669
+ * Creates an API handler for encoding WebTokenizer tokens.
670
+ * @param {WebTokenizer} tokenizer WebTokenizer instance
671
+ * @returns {TokenizationHandler} Handler function
672
+ */
673
+ function createWebTokenizerEncodingHandler(tokenizer) {
674
+ /**
675
+ * Request handler for encoding WebTokenizer tokens.
676
+ * @param {import('express').Request} request
677
+ * @param {import('express').Response} response
678
+ */
679
+ return async function (request, response) {
680
+ try {
681
+ if (!request.body) {
682
+ return response.sendStatus(400);
683
+ }
684
+
685
+ const text = request.body.text || '';
686
+ const instance = await tokenizer?.get();
687
+ if (!instance) throw new Error('Failed to load the Web tokenizer');
688
+ const tokens = Array.from(instance.encode(text));
689
+ const chunks = getWebTokenizersChunks(instance, tokens);
690
+ return response.send({ ids: tokens, count: tokens.length, chunks });
691
+ } catch (error) {
692
+ console.error(error);
693
+ return response.send({ ids: [], count: 0, chunks: [] });
694
+ }
695
+ };
696
+ }
697
+
698
+ /**
699
+ * Creates an API handler for decoding WebTokenizer tokens.
700
+ * @param {WebTokenizer} tokenizer WebTokenizer instance
701
+ * @returns {TokenizationHandler} Handler function
702
+ */
703
+ function createWebTokenizerDecodingHandler(tokenizer) {
704
+ /**
705
+ * Request handler for decoding WebTokenizer tokens.
706
+ * @param {import('express').Request} request
707
+ * @param {import('express').Response} response
708
+ * @returns {Promise<any>}
709
+ */
710
+ return async function (request, response) {
711
+ try {
712
+ if (!request.body) {
713
+ return response.sendStatus(400);
714
+ }
715
+
716
+ const ids = request.body.ids || [];
717
+ const instance = await tokenizer?.get();
718
+ if (!instance) throw new Error('Failed to load the Web tokenizer');
719
+ const chunks = getWebTokenizersChunks(instance, ids);
720
+ const text = instance.decode(new Int32Array(ids));
721
+ return response.send({ text, chunks });
722
+ } catch (error) {
723
+ console.error(error);
724
+ return response.send({ text: '', chunks: [] });
725
+ }
726
+ };
727
+ }
728
+
729
+ export const router = express.Router();
730
+
731
+ router.post('/llama/encode', createSentencepieceEncodingHandler(spp_llama));
732
+ router.post('/nerdstash/encode', createSentencepieceEncodingHandler(spp_nerd));
733
+ router.post('/nerdstash_v2/encode', createSentencepieceEncodingHandler(spp_nerd_v2));
734
+ router.post('/mistral/encode', createSentencepieceEncodingHandler(spp_mistral));
735
+ router.post('/yi/encode', createSentencepieceEncodingHandler(spp_yi));
736
+ router.post('/gemma/encode', createSentencepieceEncodingHandler(spp_gemma));
737
+ router.post('/jamba/encode', createSentencepieceEncodingHandler(spp_jamba));
738
+ router.post('/gpt2/encode', createTiktokenEncodingHandler('gpt2'));
739
+ router.post('/claude/encode', createWebTokenizerEncodingHandler(claude_tokenizer));
740
+ router.post('/llama3/encode', createWebTokenizerEncodingHandler(llama3_tokenizer));
741
+ router.post('/qwen2/encode', createWebTokenizerEncodingHandler(qwen2Tokenizer));
742
+ router.post('/command-r/encode', createWebTokenizerEncodingHandler(commandRTokenizer));
743
+ router.post('/command-a/encode', createWebTokenizerEncodingHandler(commandATokenizer));
744
+ router.post('/nemo/encode', createWebTokenizerEncodingHandler(nemoTokenizer));
745
+ router.post('/deepseek/encode', createWebTokenizerEncodingHandler(deepseekTokenizer));
746
+ router.post('/llama/decode', createSentencepieceDecodingHandler(spp_llama));
747
+ router.post('/nerdstash/decode', createSentencepieceDecodingHandler(spp_nerd));
748
+ router.post('/nerdstash_v2/decode', createSentencepieceDecodingHandler(spp_nerd_v2));
749
+ router.post('/mistral/decode', createSentencepieceDecodingHandler(spp_mistral));
750
+ router.post('/yi/decode', createSentencepieceDecodingHandler(spp_yi));
751
+ router.post('/gemma/decode', createSentencepieceDecodingHandler(spp_gemma));
752
+ router.post('/jamba/decode', createSentencepieceDecodingHandler(spp_jamba));
753
+ router.post('/gpt2/decode', createTiktokenDecodingHandler('gpt2'));
754
+ router.post('/claude/decode', createWebTokenizerDecodingHandler(claude_tokenizer));
755
+ router.post('/llama3/decode', createWebTokenizerDecodingHandler(llama3_tokenizer));
756
+ router.post('/qwen2/decode', createWebTokenizerDecodingHandler(qwen2Tokenizer));
757
+ router.post('/command-r/decode', createWebTokenizerDecodingHandler(commandRTokenizer));
758
+ router.post('/command-a/decode', createWebTokenizerDecodingHandler(commandATokenizer));
759
+ router.post('/nemo/decode', createWebTokenizerDecodingHandler(nemoTokenizer));
760
+ router.post('/deepseek/decode', createWebTokenizerDecodingHandler(deepseekTokenizer));
761
+
762
+ router.post('/openai/encode', async function (req, res) {
763
+ try {
764
+ const queryModel = String(req.query.model || '');
765
+
766
+ if (queryModel.includes('llama3') || queryModel.includes('llama-3')) {
767
+ const handler = createWebTokenizerEncodingHandler(llama3_tokenizer);
768
+ return handler(req, res);
769
+ }
770
+
771
+ if (queryModel.includes('llama')) {
772
+ const handler = createSentencepieceEncodingHandler(spp_llama);
773
+ return handler(req, res);
774
+ }
775
+
776
+ if (queryModel.includes('mistral')) {
777
+ const handler = createSentencepieceEncodingHandler(spp_mistral);
778
+ return handler(req, res);
779
+ }
780
+
781
+ if (queryModel.includes('yi')) {
782
+ const handler = createSentencepieceEncodingHandler(spp_yi);
783
+ return handler(req, res);
784
+ }
785
+
786
+ if (queryModel.includes('claude')) {
787
+ const handler = createWebTokenizerEncodingHandler(claude_tokenizer);
788
+ return handler(req, res);
789
+ }
790
+
791
+ if (queryModel.includes('gemma') || queryModel.includes('gemini')) {
792
+ const handler = createSentencepieceEncodingHandler(spp_gemma);
793
+ return handler(req, res);
794
+ }
795
+
796
+ if (queryModel.includes('jamba')) {
797
+ const handler = createSentencepieceEncodingHandler(spp_jamba);
798
+ return handler(req, res);
799
+ }
800
+
801
+ if (queryModel.includes('qwen2')) {
802
+ const handler = createWebTokenizerEncodingHandler(qwen2Tokenizer);
803
+ return handler(req, res);
804
+ }
805
+
806
+ if (queryModel.includes('command-r')) {
807
+ const handler = createWebTokenizerEncodingHandler(commandRTokenizer);
808
+ return handler(req, res);
809
+ }
810
+
811
+ if (queryModel.includes('command-a')) {
812
+ const handler = createWebTokenizerEncodingHandler(commandATokenizer);
813
+ return handler(req, res);
814
+ }
815
+
816
+ if (queryModel.includes('nemo')) {
817
+ const handler = createWebTokenizerEncodingHandler(nemoTokenizer);
818
+ return handler(req, res);
819
+ }
820
+
821
+ if (queryModel.includes('deepseek')) {
822
+ const handler = createWebTokenizerEncodingHandler(deepseekTokenizer);
823
+ return handler(req, res);
824
+ }
825
+
826
+ const model = getTokenizerModel(queryModel);
827
+ const handler = createTiktokenEncodingHandler(model);
828
+ return handler(req, res);
829
+ } catch (error) {
830
+ console.error(error);
831
+ return res.send({ ids: [], count: 0, chunks: [] });
832
+ }
833
+ });
834
+
835
+ router.post('/openai/decode', async function (req, res) {
836
+ try {
837
+ const queryModel = String(req.query.model || '');
838
+
839
+ if (queryModel.includes('llama3') || queryModel.includes('llama-3')) {
840
+ const handler = createWebTokenizerDecodingHandler(llama3_tokenizer);
841
+ return handler(req, res);
842
+ }
843
+
844
+ if (queryModel.includes('llama')) {
845
+ const handler = createSentencepieceDecodingHandler(spp_llama);
846
+ return handler(req, res);
847
+ }
848
+
849
+ if (queryModel.includes('mistral')) {
850
+ const handler = createSentencepieceDecodingHandler(spp_mistral);
851
+ return handler(req, res);
852
+ }
853
+
854
+ if (queryModel.includes('yi')) {
855
+ const handler = createSentencepieceDecodingHandler(spp_yi);
856
+ return handler(req, res);
857
+ }
858
+
859
+ if (queryModel.includes('claude')) {
860
+ const handler = createWebTokenizerDecodingHandler(claude_tokenizer);
861
+ return handler(req, res);
862
+ }
863
+
864
+ if (queryModel.includes('gemma') || queryModel.includes('gemini')) {
865
+ const handler = createSentencepieceDecodingHandler(spp_gemma);
866
+ return handler(req, res);
867
+ }
868
+
869
+ if (queryModel.includes('jamba')) {
870
+ const handler = createSentencepieceDecodingHandler(spp_jamba);
871
+ return handler(req, res);
872
+ }
873
+
874
+ if (queryModel.includes('qwen2')) {
875
+ const handler = createWebTokenizerDecodingHandler(qwen2Tokenizer);
876
+ return handler(req, res);
877
+ }
878
+
879
+ if (queryModel.includes('command-r')) {
880
+ const handler = createWebTokenizerDecodingHandler(commandRTokenizer);
881
+ return handler(req, res);
882
+ }
883
+
884
+ if (queryModel.includes('command-a')) {
885
+ const handler = createWebTokenizerDecodingHandler(commandATokenizer);
886
+ return handler(req, res);
887
+ }
888
+
889
+ if (queryModel.includes('nemo')) {
890
+ const handler = createWebTokenizerDecodingHandler(nemoTokenizer);
891
+ return handler(req, res);
892
+ }
893
+
894
+ if (queryModel.includes('deepseek')) {
895
+ const handler = createWebTokenizerDecodingHandler(deepseekTokenizer);
896
+ return handler(req, res);
897
+ }
898
+
899
+ const model = getTokenizerModel(queryModel);
900
+ const handler = createTiktokenDecodingHandler(model);
901
+ return handler(req, res);
902
+ } catch (error) {
903
+ console.error(error);
904
+ return res.send({ text: '' });
905
+ }
906
+ });
907
+
908
+ router.post('/openai/count', async function (req, res) {
909
+ try {
910
+ if (!req.body) return res.sendStatus(400);
911
+
912
+ let num_tokens = 0;
913
+ const queryModel = String(req.query.model || '');
914
+ const model = getTokenizerModel(queryModel);
915
+
916
+ if (model === 'claude') {
917
+ const instance = await claude_tokenizer.get();
918
+ if (!instance) throw new Error('Failed to load the Claude tokenizer');
919
+ num_tokens = countWebTokenizerTokens(instance, req.body);
920
+ return res.send({ 'token_count': num_tokens });
921
+ }
922
+
923
+ if (model === 'llama3' || model === 'llama-3') {
924
+ const instance = await llama3_tokenizer.get();
925
+ if (!instance) throw new Error('Failed to load the Llama3 tokenizer');
926
+ num_tokens = countWebTokenizerTokens(instance, req.body);
927
+ return res.send({ 'token_count': num_tokens });
928
+ }
929
+
930
+ if (model === 'llama') {
931
+ num_tokens = await countSentencepieceArrayTokens(spp_llama, req.body);
932
+ return res.send({ 'token_count': num_tokens });
933
+ }
934
+
935
+ if (model === 'mistral') {
936
+ num_tokens = await countSentencepieceArrayTokens(spp_mistral, req.body);
937
+ return res.send({ 'token_count': num_tokens });
938
+ }
939
+
940
+ if (model === 'yi') {
941
+ num_tokens = await countSentencepieceArrayTokens(spp_yi, req.body);
942
+ return res.send({ 'token_count': num_tokens });
943
+ }
944
+
945
+ if (model === 'gemma' || model === 'gemini') {
946
+ num_tokens = await countSentencepieceArrayTokens(spp_gemma, req.body);
947
+ return res.send({ 'token_count': num_tokens });
948
+ }
949
+
950
+ if (model === 'jamba') {
951
+ num_tokens = await countSentencepieceArrayTokens(spp_jamba, req.body);
952
+ return res.send({ 'token_count': num_tokens });
953
+ }
954
+
955
+ if (model === 'qwen2') {
956
+ const instance = await qwen2Tokenizer.get();
957
+ if (!instance) throw new Error('Failed to load the Qwen2 tokenizer');
958
+ num_tokens = countWebTokenizerTokens(instance, req.body);
959
+ return res.send({ 'token_count': num_tokens });
960
+ }
961
+
962
+ if (model === 'command-r') {
963
+ const instance = await commandRTokenizer.get();
964
+ if (!instance) throw new Error('Failed to load the Command-R tokenizer');
965
+ num_tokens = countWebTokenizerTokens(instance, req.body);
966
+ return res.send({ 'token_count': num_tokens });
967
+ }
968
+
969
+ if (model === 'command-a') {
970
+ const instance = await commandATokenizer.get();
971
+ if (!instance) throw new Error('Failed to load the Command-A tokenizer');
972
+ num_tokens = countWebTokenizerTokens(instance, req.body);
973
+ return res.send({ 'token_count': num_tokens });
974
+ }
975
+
976
+ if (model === 'nemo') {
977
+ const instance = await nemoTokenizer.get();
978
+ if (!instance) throw new Error('Failed to load the Nemo tokenizer');
979
+ num_tokens = countWebTokenizerTokens(instance, req.body);
980
+ return res.send({ 'token_count': num_tokens });
981
+ }
982
+
983
+ if (model === 'deepseek') {
984
+ const instance = await deepseekTokenizer.get();
985
+ if (!instance) throw new Error('Failed to load the DeepSeek tokenizer');
986
+ num_tokens = countWebTokenizerTokens(instance, req.body);
987
+ return res.send({ 'token_count': num_tokens });
988
+ }
989
+
990
+ const tokensPerName = queryModel.includes('gpt-3.5-turbo-0301') ? -1 : 1;
991
+ const tokensPerMessage = queryModel.includes('gpt-3.5-turbo-0301') ? 4 : 3;
992
+ const tokensPadding = 3;
993
+
994
+ const tokenizer = getTiktokenTokenizer(model);
995
+
996
+ for (const msg of req.body) {
997
+ try {
998
+ num_tokens += tokensPerMessage;
999
+ for (const [key, value] of Object.entries(msg)) {
1000
+ num_tokens += tokenizer.encode(value).length;
1001
+ if (key == 'name') {
1002
+ num_tokens += tokensPerName;
1003
+ }
1004
+ }
1005
+ } catch {
1006
+ console.warn('Error tokenizing message:', msg);
1007
+ }
1008
+ }
1009
+ num_tokens += tokensPadding;
1010
+
1011
+ // NB: Since 2023-10-14, the GPT-3.5 Turbo 0301 model shoves in 7-9 extra tokens to every message.
1012
+ // More details: https://community.openai.com/t/gpt-3-5-turbo-0301-showing-different-behavior-suddenly/431326/14
1013
+ if (queryModel.includes('gpt-3.5-turbo-0301')) {
1014
+ num_tokens += 9;
1015
+ }
1016
+
1017
+ // not needed for cached tokenizers
1018
+ //tokenizer.free();
1019
+
1020
+ res.send({ 'token_count': num_tokens });
1021
+ } catch (error) {
1022
+ console.error('An error counting tokens, using fallback estimation method', error);
1023
+ const jsonBody = JSON.stringify(req.body);
1024
+ const num_tokens = Math.ceil(jsonBody.length / CHARS_PER_TOKEN);
1025
+ res.send({ 'token_count': num_tokens });
1026
+ }
1027
+ });
1028
+
1029
+ router.post('/remote/kobold/count', async function (request, response) {
1030
+ if (!request.body) {
1031
+ return response.sendStatus(400);
1032
+ }
1033
+ const text = String(request.body.text) || '';
1034
+ const baseUrl = String(request.body.url);
1035
+
1036
+ try {
1037
+ const args = {
1038
+ method: 'POST',
1039
+ body: JSON.stringify({ 'prompt': text }),
1040
+ headers: { 'Content-Type': 'application/json' },
1041
+ };
1042
+
1043
+ let url = String(baseUrl).replace(/\/$/, '');
1044
+ url += '/extra/tokencount';
1045
+
1046
+ const result = await fetch(url, args);
1047
+
1048
+ if (!result.ok) {
1049
+ console.warn(`API returned error: ${result.status} ${result.statusText}`);
1050
+ return response.send({ error: true });
1051
+ }
1052
+
1053
+ /** @type {any} */
1054
+ const data = await result.json();
1055
+ const count = data['value'];
1056
+ const ids = data['ids'] ?? [];
1057
+ return response.send({ count, ids });
1058
+ } catch (error) {
1059
+ console.error(error);
1060
+ return response.send({ error: true });
1061
+ }
1062
+ });
1063
+
1064
+ router.post('/remote/textgenerationwebui/encode', async function (request, response) {
1065
+ if (!request.body) {
1066
+ return response.sendStatus(400);
1067
+ }
1068
+ const text = String(request.body.text) || '';
1069
+ const baseUrl = String(request.body.url);
1070
+ const vllmModel = String(request.body.vllm_model) || '';
1071
+ const aphroditeModel = String(request.body.aphrodite_model) || '';
1072
+
1073
+ try {
1074
+ const args = {
1075
+ method: 'POST',
1076
+ headers: { 'Content-Type': 'application/json' },
1077
+ };
1078
+
1079
+ setAdditionalHeaders(request, args, baseUrl);
1080
+
1081
+ // Convert to string + remove trailing slash + /v1 suffix
1082
+ let url = String(baseUrl).replace(/\/$/, '').replace(/\/v1$/, '');
1083
+
1084
+ switch (request.body.api_type) {
1085
+ case TEXTGEN_TYPES.TABBY:
1086
+ url += '/v1/token/encode';
1087
+ args.body = JSON.stringify({ 'text': text });
1088
+ break;
1089
+ case TEXTGEN_TYPES.KOBOLDCPP:
1090
+ url += '/api/extra/tokencount';
1091
+ args.body = JSON.stringify({ 'prompt': text });
1092
+ break;
1093
+ case TEXTGEN_TYPES.LLAMACPP:
1094
+ url += '/tokenize';
1095
+ args.body = JSON.stringify({ 'content': text });
1096
+ break;
1097
+ case TEXTGEN_TYPES.VLLM:
1098
+ url += '/tokenize';
1099
+ args.body = JSON.stringify({ 'model': vllmModel, 'prompt': text });
1100
+ break;
1101
+ case TEXTGEN_TYPES.APHRODITE:
1102
+ url += '/v1/tokenize';
1103
+ args.body = JSON.stringify({ 'model': aphroditeModel, 'prompt': text });
1104
+ break;
1105
+ default:
1106
+ url += '/v1/internal/encode';
1107
+ args.body = JSON.stringify({ 'text': text });
1108
+ break;
1109
+ }
1110
+
1111
+ const result = await fetch(url, args);
1112
+
1113
+ if (!result.ok) {
1114
+ console.warn(`API returned error: ${result.status} ${result.statusText}`);
1115
+ return response.send({ error: true });
1116
+ }
1117
+
1118
+ /** @type {any} */
1119
+ const data = await result.json();
1120
+ const count = (data?.length ?? data?.count ?? data?.value ?? data?.tokens?.length);
1121
+ const ids = (data?.tokens ?? data?.ids ?? []);
1122
+
1123
+ return response.send({ count, ids });
1124
+ } catch (error) {
1125
+ console.error(error);
1126
+ return response.send({ error: true });
1127
+ }
1128
+ });