Spaces:
Running
Running
Upload 72 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- src/additional-headers.js +242 -0
- src/character-card-parser.js +100 -0
- src/constants.js +442 -0
- src/endpoints/anthropic.js +68 -0
- src/endpoints/assets.js +370 -0
- src/endpoints/avatars.js +62 -0
- src/endpoints/azure.js +92 -0
- src/endpoints/backends/chat-completions.js +1106 -0
- src/endpoints/backends/kobold.js +241 -0
- src/endpoints/backends/scale-alt.js +101 -0
- src/endpoints/backends/text-completions.js +641 -0
- src/endpoints/backgrounds.js +76 -0
- src/endpoints/caption.js +32 -0
- src/endpoints/characters.js +1230 -0
- src/endpoints/chats.js +461 -0
- src/endpoints/classify.js +58 -0
- src/endpoints/content-manager.js +725 -0
- src/endpoints/extensions.js +244 -0
- src/endpoints/files.js +101 -0
- src/endpoints/google.js +71 -0
- src/endpoints/groups.js +135 -0
- src/endpoints/horde.js +382 -0
- src/endpoints/images.js +93 -0
- src/endpoints/moving-ui.js +21 -0
- src/endpoints/novelai.js +381 -0
- src/endpoints/openai.js +329 -0
- src/endpoints/presets.js +129 -0
- src/endpoints/quick-replies.js +35 -0
- src/endpoints/search.js +246 -0
- src/endpoints/secrets.js +230 -0
- src/endpoints/settings.js +360 -0
- src/endpoints/speech.js +82 -0
- src/endpoints/sprites.js +266 -0
- src/endpoints/stable-diffusion.js +1042 -0
- src/endpoints/stats.js +474 -0
- src/endpoints/themes.js +40 -0
- src/endpoints/thumbnails.js +235 -0
- src/endpoints/tokenizers.js +885 -0
- src/endpoints/translate.js +398 -0
- src/endpoints/users-admin.js +255 -0
- src/endpoints/users-private.js +257 -0
- src/endpoints/users-public.js +199 -0
- src/endpoints/vectors.js +491 -0
- src/endpoints/worldinfo.js +126 -0
- src/express-common.js +28 -0
- src/middleware/basicAuth.js +37 -0
- src/middleware/multerMonkeyPatch.js +30 -0
- src/middleware/whitelist.js +86 -0
- src/plugin-loader.js +223 -0
- src/polyfill.js +10 -0
src/additional-headers.js
ADDED
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const { TEXTGEN_TYPES, OPENROUTER_HEADERS, FEATHERLESS_HEADERS } = require('./constants');
|
2 |
+
const { SECRET_KEYS, readSecret } = require('./endpoints/secrets');
|
3 |
+
const { getConfigValue } = require('./util');
|
4 |
+
|
5 |
+
/**
|
6 |
+
* Gets the headers for the Mancer API.
|
7 |
+
* @param {import('./users').UserDirectoryList} directories User directories
|
8 |
+
* @returns {object} Headers for the request
|
9 |
+
*/
|
10 |
+
function getMancerHeaders(directories) {
|
11 |
+
const apiKey = readSecret(directories, SECRET_KEYS.MANCER);
|
12 |
+
|
13 |
+
return apiKey ? ({
|
14 |
+
'X-API-KEY': apiKey,
|
15 |
+
'Authorization': `Bearer ${apiKey}`,
|
16 |
+
}) : {};
|
17 |
+
}
|
18 |
+
|
19 |
+
/**
|
20 |
+
* Gets the headers for the TogetherAI API.
|
21 |
+
* @param {import('./users').UserDirectoryList} directories User directories
|
22 |
+
* @returns {object} Headers for the request
|
23 |
+
*/
|
24 |
+
function getTogetherAIHeaders(directories) {
|
25 |
+
const apiKey = readSecret(directories, SECRET_KEYS.TOGETHERAI);
|
26 |
+
|
27 |
+
return apiKey ? ({
|
28 |
+
'Authorization': `Bearer ${apiKey}`,
|
29 |
+
}) : {};
|
30 |
+
}
|
31 |
+
|
32 |
+
/**
|
33 |
+
* Gets the headers for the InfermaticAI API.
|
34 |
+
* @param {import('./users').UserDirectoryList} directories User directories
|
35 |
+
* @returns {object} Headers for the request
|
36 |
+
*/
|
37 |
+
function getInfermaticAIHeaders(directories) {
|
38 |
+
const apiKey = readSecret(directories, SECRET_KEYS.INFERMATICAI);
|
39 |
+
|
40 |
+
return apiKey ? ({
|
41 |
+
'Authorization': `Bearer ${apiKey}`,
|
42 |
+
}) : {};
|
43 |
+
}
|
44 |
+
|
45 |
+
/**
|
46 |
+
* Gets the headers for the DreamGen API.
|
47 |
+
* @param {import('./users').UserDirectoryList} directories User directories
|
48 |
+
* @returns {object} Headers for the request
|
49 |
+
*/
|
50 |
+
function getDreamGenHeaders(directories) {
|
51 |
+
const apiKey = readSecret(directories, SECRET_KEYS.DREAMGEN);
|
52 |
+
|
53 |
+
return apiKey ? ({
|
54 |
+
'Authorization': `Bearer ${apiKey}`,
|
55 |
+
}) : {};
|
56 |
+
}
|
57 |
+
|
58 |
+
/**
|
59 |
+
* Gets the headers for the OpenRouter API.
|
60 |
+
* @param {import('./users').UserDirectoryList} directories User directories
|
61 |
+
* @returns {object} Headers for the request
|
62 |
+
*/
|
63 |
+
function getOpenRouterHeaders(directories) {
|
64 |
+
const apiKey = readSecret(directories, SECRET_KEYS.OPENROUTER);
|
65 |
+
const baseHeaders = { ...OPENROUTER_HEADERS };
|
66 |
+
|
67 |
+
return apiKey ? Object.assign(baseHeaders, { 'Authorization': `Bearer ${apiKey}` }) : baseHeaders;
|
68 |
+
}
|
69 |
+
|
70 |
+
/**
|
71 |
+
* Gets the headers for the vLLM API.
|
72 |
+
* @param {import('./users').UserDirectoryList} directories User directories
|
73 |
+
* @returns {object} Headers for the request
|
74 |
+
*/
|
75 |
+
function getVllmHeaders(directories) {
|
76 |
+
const apiKey = readSecret(directories, SECRET_KEYS.VLLM);
|
77 |
+
|
78 |
+
return apiKey ? ({
|
79 |
+
'Authorization': `Bearer ${apiKey}`,
|
80 |
+
}) : {};
|
81 |
+
}
|
82 |
+
|
83 |
+
/**
|
84 |
+
* Gets the headers for the Aphrodite API.
|
85 |
+
* @param {import('./users').UserDirectoryList} directories User directories
|
86 |
+
* @returns {object} Headers for the request
|
87 |
+
*/
|
88 |
+
function getAphroditeHeaders(directories) {
|
89 |
+
const apiKey = readSecret(directories, SECRET_KEYS.APHRODITE);
|
90 |
+
|
91 |
+
return apiKey ? ({
|
92 |
+
'X-API-KEY': apiKey,
|
93 |
+
'Authorization': `Bearer ${apiKey}`,
|
94 |
+
}) : {};
|
95 |
+
}
|
96 |
+
|
97 |
+
/**
|
98 |
+
* Gets the headers for the Tabby API.
|
99 |
+
* @param {import('./users').UserDirectoryList} directories User directories
|
100 |
+
* @returns {object} Headers for the request
|
101 |
+
*/
|
102 |
+
function getTabbyHeaders(directories) {
|
103 |
+
const apiKey = readSecret(directories, SECRET_KEYS.TABBY);
|
104 |
+
|
105 |
+
return apiKey ? ({
|
106 |
+
'x-api-key': apiKey,
|
107 |
+
'Authorization': `Bearer ${apiKey}`,
|
108 |
+
}) : {};
|
109 |
+
}
|
110 |
+
|
111 |
+
/**
|
112 |
+
* Gets the headers for the LlamaCPP API.
|
113 |
+
* @param {import('./users').UserDirectoryList} directories User directories
|
114 |
+
* @returns {object} Headers for the request
|
115 |
+
*/
|
116 |
+
function getLlamaCppHeaders(directories) {
|
117 |
+
const apiKey = readSecret(directories, SECRET_KEYS.LLAMACPP);
|
118 |
+
|
119 |
+
return apiKey ? ({
|
120 |
+
'Authorization': `Bearer ${apiKey}`,
|
121 |
+
}) : {};
|
122 |
+
}
|
123 |
+
|
124 |
+
/**
|
125 |
+
* Gets the headers for the Ooba API.
|
126 |
+
* @param {import('./users').UserDirectoryList} directories
|
127 |
+
* @returns {object} Headers for the request
|
128 |
+
*/
|
129 |
+
function getOobaHeaders(directories) {
|
130 |
+
const apiKey = readSecret(directories, SECRET_KEYS.OOBA);
|
131 |
+
|
132 |
+
return apiKey ? ({
|
133 |
+
'Authorization': `Bearer ${apiKey}`,
|
134 |
+
}) : {};
|
135 |
+
}
|
136 |
+
|
137 |
+
/**
|
138 |
+
* Gets the headers for the KoboldCpp API.
|
139 |
+
* @param {import('./users').UserDirectoryList} directories
|
140 |
+
* @returns {object} Headers for the request
|
141 |
+
*/
|
142 |
+
function getKoboldCppHeaders(directories) {
|
143 |
+
const apiKey = readSecret(directories, SECRET_KEYS.KOBOLDCPP);
|
144 |
+
|
145 |
+
return apiKey ? ({
|
146 |
+
'Authorization': `Bearer ${apiKey}`,
|
147 |
+
}) : {};
|
148 |
+
}
|
149 |
+
|
150 |
+
/**
|
151 |
+
* Gets the headers for the Featherless API.
|
152 |
+
* @param {import('./users').UserDirectoryList} directories
|
153 |
+
* @returns {object} Headers for the request
|
154 |
+
*/
|
155 |
+
function getFeatherlessHeaders(directories) {
|
156 |
+
const apiKey = readSecret(directories, SECRET_KEYS.FEATHERLESS);
|
157 |
+
const baseHeaders = { ...FEATHERLESS_HEADERS };
|
158 |
+
|
159 |
+
return apiKey ? Object.assign(baseHeaders, { 'Authorization': `Bearer ${apiKey}` }) : baseHeaders;
|
160 |
+
}
|
161 |
+
|
162 |
+
/**
|
163 |
+
* Gets the headers for the HuggingFace API.
|
164 |
+
* @param {import('./users').UserDirectoryList} directories
|
165 |
+
* @returns {object} Headers for the request
|
166 |
+
*/
|
167 |
+
function getHuggingFaceHeaders(directories) {
|
168 |
+
const apiKey = readSecret(directories, SECRET_KEYS.HUGGINGFACE);
|
169 |
+
|
170 |
+
return apiKey ? ({
|
171 |
+
'Authorization': `Bearer ${apiKey}`,
|
172 |
+
}) : {};
|
173 |
+
}
|
174 |
+
|
175 |
+
function getOverrideHeaders(urlHost) {
|
176 |
+
const requestOverrides = getConfigValue('requestOverrides', []);
|
177 |
+
const overrideHeaders = requestOverrides?.find((e) => e.hosts?.includes(urlHost))?.headers;
|
178 |
+
if (overrideHeaders && urlHost) {
|
179 |
+
return overrideHeaders;
|
180 |
+
} else {
|
181 |
+
return {};
|
182 |
+
}
|
183 |
+
}
|
184 |
+
|
185 |
+
/**
|
186 |
+
* Sets additional headers for the request.
|
187 |
+
* @param {import('express').Request} request Original request body
|
188 |
+
* @param {object} args New request arguments
|
189 |
+
* @param {string|null} server API server for new request
|
190 |
+
*/
|
191 |
+
function setAdditionalHeaders(request, args, server) {
|
192 |
+
setAdditionalHeadersByType(args.headers, request.body.api_type, server, request.user.directories);
|
193 |
+
}
|
194 |
+
|
195 |
+
/**
|
196 |
+
*
|
197 |
+
* @param {object} requestHeaders Request headers
|
198 |
+
* @param {string} type API type
|
199 |
+
* @param {string|null} server API server for new request
|
200 |
+
* @param {import('./users').UserDirectoryList} directories User directories
|
201 |
+
*/
|
202 |
+
function setAdditionalHeadersByType(requestHeaders, type, server, directories) {
|
203 |
+
const headerGetters = {
|
204 |
+
[TEXTGEN_TYPES.MANCER]: getMancerHeaders,
|
205 |
+
[TEXTGEN_TYPES.VLLM]: getVllmHeaders,
|
206 |
+
[TEXTGEN_TYPES.APHRODITE]: getAphroditeHeaders,
|
207 |
+
[TEXTGEN_TYPES.TABBY]: getTabbyHeaders,
|
208 |
+
[TEXTGEN_TYPES.TOGETHERAI]: getTogetherAIHeaders,
|
209 |
+
[TEXTGEN_TYPES.OOBA]: getOobaHeaders,
|
210 |
+
[TEXTGEN_TYPES.INFERMATICAI]: getInfermaticAIHeaders,
|
211 |
+
[TEXTGEN_TYPES.DREAMGEN]: getDreamGenHeaders,
|
212 |
+
[TEXTGEN_TYPES.OPENROUTER]: getOpenRouterHeaders,
|
213 |
+
[TEXTGEN_TYPES.KOBOLDCPP]: getKoboldCppHeaders,
|
214 |
+
[TEXTGEN_TYPES.LLAMACPP]: getLlamaCppHeaders,
|
215 |
+
[TEXTGEN_TYPES.FEATHERLESS]: getFeatherlessHeaders,
|
216 |
+
[TEXTGEN_TYPES.HUGGINGFACE]: getHuggingFaceHeaders,
|
217 |
+
};
|
218 |
+
|
219 |
+
const getHeaders = headerGetters[type];
|
220 |
+
const headers = getHeaders ? getHeaders(directories) : {};
|
221 |
+
|
222 |
+
if (typeof server === 'string' && server.length > 0) {
|
223 |
+
try {
|
224 |
+
const url = new URL(server);
|
225 |
+
const overrideHeaders = getOverrideHeaders(url.host);
|
226 |
+
|
227 |
+
if (overrideHeaders && Object.keys(overrideHeaders).length > 0) {
|
228 |
+
Object.assign(headers, overrideHeaders);
|
229 |
+
}
|
230 |
+
} catch {
|
231 |
+
// Do nothing
|
232 |
+
}
|
233 |
+
}
|
234 |
+
|
235 |
+
Object.assign(requestHeaders, headers);
|
236 |
+
}
|
237 |
+
|
238 |
+
module.exports = {
|
239 |
+
getOverrideHeaders,
|
240 |
+
setAdditionalHeaders,
|
241 |
+
setAdditionalHeadersByType,
|
242 |
+
};
|
src/character-card-parser.js
ADDED
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const fs = require('fs');
|
2 |
+
|
3 |
+
const encode = require('png-chunks-encode');
|
4 |
+
const extract = require('png-chunks-extract');
|
5 |
+
const PNGtext = require('png-chunk-text');
|
6 |
+
|
7 |
+
/**
|
8 |
+
* Writes Character metadata to a PNG image buffer.
|
9 |
+
* Writes only 'chara', 'ccv3' is not supported and removed not to create a mismatch.
|
10 |
+
* @param {Buffer} image PNG image buffer
|
11 |
+
* @param {string} data Character data to write
|
12 |
+
* @returns {Buffer} PNG image buffer with metadata
|
13 |
+
*/
|
14 |
+
const write = (image, data) => {
|
15 |
+
const chunks = extract(image);
|
16 |
+
const tEXtChunks = chunks.filter(chunk => chunk.name === 'tEXt');
|
17 |
+
|
18 |
+
// Remove existing tEXt chunks
|
19 |
+
for (const tEXtChunk of tEXtChunks) {
|
20 |
+
const data = PNGtext.decode(tEXtChunk.data);
|
21 |
+
if (data.keyword.toLowerCase() === 'chara' || data.keyword.toLowerCase() === 'ccv3') {
|
22 |
+
chunks.splice(chunks.indexOf(tEXtChunk), 1);
|
23 |
+
}
|
24 |
+
}
|
25 |
+
|
26 |
+
// Add new v2 chunk before the IEND chunk
|
27 |
+
const base64EncodedData = Buffer.from(data, 'utf8').toString('base64');
|
28 |
+
chunks.splice(-1, 0, PNGtext.encode('chara', base64EncodedData));
|
29 |
+
|
30 |
+
// Try adding v3 chunk before the IEND chunk
|
31 |
+
try {
|
32 |
+
//change v2 format to v3
|
33 |
+
const v3Data = JSON.parse(data);
|
34 |
+
v3Data.spec = 'chara_card_v3';
|
35 |
+
v3Data.spec_version = '3.0';
|
36 |
+
|
37 |
+
const base64EncodedData = Buffer.from(JSON.stringify(v3Data), 'utf8').toString('base64');
|
38 |
+
chunks.splice(-1, 0, PNGtext.encode('ccv3', base64EncodedData));
|
39 |
+
} catch (error) { }
|
40 |
+
|
41 |
+
const newBuffer = Buffer.from(encode(chunks));
|
42 |
+
return newBuffer;
|
43 |
+
};
|
44 |
+
|
45 |
+
/**
|
46 |
+
* Reads Character metadata from a PNG image buffer.
|
47 |
+
* Supports both V2 (chara) and V3 (ccv3). V3 (ccv3) takes precedence.
|
48 |
+
* @param {Buffer} image PNG image buffer
|
49 |
+
* @returns {string} Character data
|
50 |
+
*/
|
51 |
+
const read = (image) => {
|
52 |
+
const chunks = extract(image);
|
53 |
+
|
54 |
+
const textChunks = chunks.filter((chunk) => chunk.name === 'tEXt').map((chunk) => PNGtext.decode(chunk.data));
|
55 |
+
|
56 |
+
if (textChunks.length === 0) {
|
57 |
+
console.error('PNG metadata does not contain any text chunks.');
|
58 |
+
throw new Error('No PNG metadata.');
|
59 |
+
}
|
60 |
+
|
61 |
+
const ccv3Index = textChunks.findIndex((chunk) => chunk.keyword.toLowerCase() === 'ccv3');
|
62 |
+
|
63 |
+
if (ccv3Index > -1) {
|
64 |
+
return Buffer.from(textChunks[ccv3Index].text, 'base64').toString('utf8');
|
65 |
+
}
|
66 |
+
|
67 |
+
const charaIndex = textChunks.findIndex((chunk) => chunk.keyword.toLowerCase() === 'chara');
|
68 |
+
|
69 |
+
if (charaIndex > -1) {
|
70 |
+
return Buffer.from(textChunks[charaIndex].text, 'base64').toString('utf8');
|
71 |
+
}
|
72 |
+
|
73 |
+
console.error('PNG metadata does not contain any character data.');
|
74 |
+
throw new Error('No PNG metadata.');
|
75 |
+
};
|
76 |
+
|
77 |
+
/**
|
78 |
+
* Parses a card image and returns the character metadata.
|
79 |
+
* @param {string} cardUrl Path to the card image
|
80 |
+
* @param {string} format File format
|
81 |
+
* @returns {string} Character data
|
82 |
+
*/
|
83 |
+
const parse = (cardUrl, format) => {
|
84 |
+
let fileFormat = format === undefined ? 'png' : format;
|
85 |
+
|
86 |
+
switch (fileFormat) {
|
87 |
+
case 'png': {
|
88 |
+
const buffer = fs.readFileSync(cardUrl);
|
89 |
+
return read(buffer);
|
90 |
+
}
|
91 |
+
}
|
92 |
+
|
93 |
+
throw new Error('Unsupported format');
|
94 |
+
};
|
95 |
+
|
96 |
+
module.exports = {
|
97 |
+
parse,
|
98 |
+
write,
|
99 |
+
read,
|
100 |
+
};
|
src/constants.js
ADDED
@@ -0,0 +1,442 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const PUBLIC_DIRECTORIES = {
|
2 |
+
images: 'public/img/',
|
3 |
+
backups: 'backups/',
|
4 |
+
sounds: 'public/sounds',
|
5 |
+
extensions: 'public/scripts/extensions',
|
6 |
+
};
|
7 |
+
|
8 |
+
const DEFAULT_AVATAR = '/img/ai4.png';
|
9 |
+
const SETTINGS_FILE = 'settings.json';
|
10 |
+
|
11 |
+
/**
|
12 |
+
* @type {import('./users').UserDirectoryList}
|
13 |
+
* @readonly
|
14 |
+
* @enum {string}
|
15 |
+
*/
|
16 |
+
const USER_DIRECTORY_TEMPLATE = Object.freeze({
|
17 |
+
root: '',
|
18 |
+
thumbnails: 'thumbnails',
|
19 |
+
thumbnailsBg: 'thumbnails/bg',
|
20 |
+
thumbnailsAvatar: 'thumbnails/avatar',
|
21 |
+
worlds: 'worlds',
|
22 |
+
user: 'user',
|
23 |
+
avatars: 'User Avatars',
|
24 |
+
userImages: 'user/images',
|
25 |
+
groups: 'groups',
|
26 |
+
groupChats: 'group chats',
|
27 |
+
chats: 'chats',
|
28 |
+
characters: 'characters',
|
29 |
+
backgrounds: 'backgrounds',
|
30 |
+
novelAI_Settings: 'NovelAI Settings',
|
31 |
+
koboldAI_Settings: 'KoboldAI Settings',
|
32 |
+
openAI_Settings: 'OpenAI Settings',
|
33 |
+
textGen_Settings: 'TextGen Settings',
|
34 |
+
themes: 'themes',
|
35 |
+
movingUI: 'movingUI',
|
36 |
+
extensions: 'extensions',
|
37 |
+
instruct: 'instruct',
|
38 |
+
context: 'context',
|
39 |
+
quickreplies: 'QuickReplies',
|
40 |
+
assets: 'assets',
|
41 |
+
comfyWorkflows: 'user/workflows',
|
42 |
+
files: 'user/files',
|
43 |
+
vectors: 'vectors',
|
44 |
+
backups: 'backups',
|
45 |
+
});
|
46 |
+
|
47 |
+
/**
|
48 |
+
* @type {import('./users').User}
|
49 |
+
* @readonly
|
50 |
+
*/
|
51 |
+
const DEFAULT_USER = Object.freeze({
|
52 |
+
handle: 'default-user',
|
53 |
+
name: 'User',
|
54 |
+
created: Date.now(),
|
55 |
+
password: '',
|
56 |
+
admin: true,
|
57 |
+
enabled: true,
|
58 |
+
salt: '',
|
59 |
+
});
|
60 |
+
|
61 |
+
const UNSAFE_EXTENSIONS = [
|
62 |
+
'.php',
|
63 |
+
'.exe',
|
64 |
+
'.com',
|
65 |
+
'.dll',
|
66 |
+
'.pif',
|
67 |
+
'.application',
|
68 |
+
'.gadget',
|
69 |
+
'.msi',
|
70 |
+
'.jar',
|
71 |
+
'.cmd',
|
72 |
+
'.bat',
|
73 |
+
'.reg',
|
74 |
+
'.sh',
|
75 |
+
'.py',
|
76 |
+
'.js',
|
77 |
+
'.jse',
|
78 |
+
'.jsp',
|
79 |
+
'.pdf',
|
80 |
+
'.html',
|
81 |
+
'.htm',
|
82 |
+
'.hta',
|
83 |
+
'.vb',
|
84 |
+
'.vbs',
|
85 |
+
'.vbe',
|
86 |
+
'.cpl',
|
87 |
+
'.msc',
|
88 |
+
'.scr',
|
89 |
+
'.sql',
|
90 |
+
'.iso',
|
91 |
+
'.img',
|
92 |
+
'.dmg',
|
93 |
+
'.ps1',
|
94 |
+
'.ps1xml',
|
95 |
+
'.ps2',
|
96 |
+
'.ps2xml',
|
97 |
+
'.psc1',
|
98 |
+
'.psc2',
|
99 |
+
'.msh',
|
100 |
+
'.msh1',
|
101 |
+
'.msh2',
|
102 |
+
'.mshxml',
|
103 |
+
'.msh1xml',
|
104 |
+
'.msh2xml',
|
105 |
+
'.scf',
|
106 |
+
'.lnk',
|
107 |
+
'.inf',
|
108 |
+
'.reg',
|
109 |
+
'.doc',
|
110 |
+
'.docm',
|
111 |
+
'.docx',
|
112 |
+
'.dot',
|
113 |
+
'.dotm',
|
114 |
+
'.dotx',
|
115 |
+
'.xls',
|
116 |
+
'.xlsm',
|
117 |
+
'.xlsx',
|
118 |
+
'.xlt',
|
119 |
+
'.xltm',
|
120 |
+
'.xltx',
|
121 |
+
'.xlam',
|
122 |
+
'.ppt',
|
123 |
+
'.pptm',
|
124 |
+
'.pptx',
|
125 |
+
'.pot',
|
126 |
+
'.potm',
|
127 |
+
'.potx',
|
128 |
+
'.ppam',
|
129 |
+
'.ppsx',
|
130 |
+
'.ppsm',
|
131 |
+
'.pps',
|
132 |
+
'.ppam',
|
133 |
+
'.sldx',
|
134 |
+
'.sldm',
|
135 |
+
'.ws',
|
136 |
+
];
|
137 |
+
|
138 |
+
const GEMINI_SAFETY = [
|
139 |
+
{
|
140 |
+
category: 'HARM_CATEGORY_HARASSMENT',
|
141 |
+
threshold: 'BLOCK_NONE',
|
142 |
+
},
|
143 |
+
{
|
144 |
+
category: 'HARM_CATEGORY_HATE_SPEECH',
|
145 |
+
threshold: 'BLOCK_NONE',
|
146 |
+
},
|
147 |
+
{
|
148 |
+
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
|
149 |
+
threshold: 'BLOCK_NONE',
|
150 |
+
},
|
151 |
+
{
|
152 |
+
category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
|
153 |
+
threshold: 'BLOCK_NONE',
|
154 |
+
},
|
155 |
+
];
|
156 |
+
|
157 |
+
const BISON_SAFETY = [
|
158 |
+
{
|
159 |
+
category: 'HARM_CATEGORY_DEROGATORY',
|
160 |
+
threshold: 'BLOCK_NONE',
|
161 |
+
},
|
162 |
+
{
|
163 |
+
category: 'HARM_CATEGORY_TOXICITY',
|
164 |
+
threshold: 'BLOCK_NONE',
|
165 |
+
},
|
166 |
+
{
|
167 |
+
category: 'HARM_CATEGORY_VIOLENCE',
|
168 |
+
threshold: 'BLOCK_NONE',
|
169 |
+
},
|
170 |
+
{
|
171 |
+
category: 'HARM_CATEGORY_SEXUAL',
|
172 |
+
threshold: 'BLOCK_NONE',
|
173 |
+
},
|
174 |
+
{
|
175 |
+
category: 'HARM_CATEGORY_MEDICAL',
|
176 |
+
threshold: 'BLOCK_NONE',
|
177 |
+
},
|
178 |
+
{
|
179 |
+
category: 'HARM_CATEGORY_DANGEROUS',
|
180 |
+
threshold: 'BLOCK_NONE',
|
181 |
+
},
|
182 |
+
];
|
183 |
+
|
184 |
+
const CHAT_COMPLETION_SOURCES = {
|
185 |
+
OPENAI: 'openai',
|
186 |
+
WINDOWAI: 'windowai',
|
187 |
+
CLAUDE: 'claude',
|
188 |
+
SCALE: 'scale',
|
189 |
+
OPENROUTER: 'openrouter',
|
190 |
+
AI21: 'ai21',
|
191 |
+
MAKERSUITE: 'makersuite',
|
192 |
+
MISTRALAI: 'mistralai',
|
193 |
+
CUSTOM: 'custom',
|
194 |
+
COHERE: 'cohere',
|
195 |
+
PERPLEXITY: 'perplexity',
|
196 |
+
GROQ: 'groq',
|
197 |
+
ZEROONEAI: '01ai',
|
198 |
+
BLOCKENTROPY: 'blockentropy',
|
199 |
+
};
|
200 |
+
|
201 |
+
/**
|
202 |
+
* Path to multer file uploads under the data root.
|
203 |
+
*/
|
204 |
+
const UPLOADS_DIRECTORY = '_uploads';
|
205 |
+
|
206 |
+
// TODO: this is copied from the client code; there should be a way to de-duplicate it eventually
|
207 |
+
const TEXTGEN_TYPES = {
|
208 |
+
OOBA: 'ooba',
|
209 |
+
MANCER: 'mancer',
|
210 |
+
VLLM: 'vllm',
|
211 |
+
APHRODITE: 'aphrodite',
|
212 |
+
TABBY: 'tabby',
|
213 |
+
KOBOLDCPP: 'koboldcpp',
|
214 |
+
TOGETHERAI: 'togetherai',
|
215 |
+
LLAMACPP: 'llamacpp',
|
216 |
+
OLLAMA: 'ollama',
|
217 |
+
INFERMATICAI: 'infermaticai',
|
218 |
+
DREAMGEN: 'dreamgen',
|
219 |
+
OPENROUTER: 'openrouter',
|
220 |
+
FEATHERLESS: 'featherless',
|
221 |
+
HUGGINGFACE: 'huggingface',
|
222 |
+
};
|
223 |
+
|
224 |
+
const INFERMATICAI_KEYS = [
|
225 |
+
'model',
|
226 |
+
'prompt',
|
227 |
+
'max_tokens',
|
228 |
+
'temperature',
|
229 |
+
'top_p',
|
230 |
+
'top_k',
|
231 |
+
'repetition_penalty',
|
232 |
+
'stream',
|
233 |
+
'stop',
|
234 |
+
'presence_penalty',
|
235 |
+
'frequency_penalty',
|
236 |
+
'min_p',
|
237 |
+
'seed',
|
238 |
+
'ignore_eos',
|
239 |
+
'n',
|
240 |
+
'best_of',
|
241 |
+
'min_tokens',
|
242 |
+
'spaces_between_special_tokens',
|
243 |
+
'skip_special_tokens',
|
244 |
+
'logprobs',
|
245 |
+
];
|
246 |
+
|
247 |
+
const FEATHERLESS_KEYS = [
|
248 |
+
'model',
|
249 |
+
'prompt',
|
250 |
+
'best_of',
|
251 |
+
'echo',
|
252 |
+
'frequency_penalty',
|
253 |
+
'logit_bias',
|
254 |
+
'logprobs',
|
255 |
+
'max_tokens',
|
256 |
+
'n',
|
257 |
+
'presence_penalty',
|
258 |
+
'seed',
|
259 |
+
'stop',
|
260 |
+
'stream',
|
261 |
+
'suffix',
|
262 |
+
'temperature',
|
263 |
+
'top_p',
|
264 |
+
'user',
|
265 |
+
|
266 |
+
'use_beam_search',
|
267 |
+
'top_k',
|
268 |
+
'min_p',
|
269 |
+
'repetition_penalty',
|
270 |
+
'length_penalty',
|
271 |
+
'early_stopping',
|
272 |
+
'stop_token_ids',
|
273 |
+
'ignore_eos',
|
274 |
+
'min_tokens',
|
275 |
+
'skip_special_tokens',
|
276 |
+
'spaces_between_special_tokens',
|
277 |
+
'truncate_prompt_tokens',
|
278 |
+
|
279 |
+
'include_stop_str_in_output',
|
280 |
+
'response_format',
|
281 |
+
'guided_json',
|
282 |
+
'guided_regex',
|
283 |
+
'guided_choice',
|
284 |
+
'guided_grammar',
|
285 |
+
'guided_decoding_backend',
|
286 |
+
'guided_whitespace_pattern',
|
287 |
+
];
|
288 |
+
|
289 |
+
|
290 |
+
// https://dreamgen.com/docs/api#openai-text
|
291 |
+
const DREAMGEN_KEYS = [
|
292 |
+
'model',
|
293 |
+
'prompt',
|
294 |
+
'max_tokens',
|
295 |
+
'temperature',
|
296 |
+
'top_p',
|
297 |
+
'top_k',
|
298 |
+
'min_p',
|
299 |
+
'repetition_penalty',
|
300 |
+
'frequency_penalty',
|
301 |
+
'presence_penalty',
|
302 |
+
'stop',
|
303 |
+
'stream',
|
304 |
+
'minimum_message_content_tokens',
|
305 |
+
];
|
306 |
+
|
307 |
+
// https://docs.together.ai/reference/completions
|
308 |
+
const TOGETHERAI_KEYS = [
|
309 |
+
'model',
|
310 |
+
'prompt',
|
311 |
+
'max_tokens',
|
312 |
+
'temperature',
|
313 |
+
'top_p',
|
314 |
+
'top_k',
|
315 |
+
'repetition_penalty',
|
316 |
+
'min_p',
|
317 |
+
'presence_penalty',
|
318 |
+
'frequency_penalty',
|
319 |
+
'stream',
|
320 |
+
'stop',
|
321 |
+
];
|
322 |
+
|
323 |
+
// https://github.com/jmorganca/ollama/blob/main/docs/api.md#request-with-options
|
324 |
+
const OLLAMA_KEYS = [
|
325 |
+
'num_predict',
|
326 |
+
'num_ctx',
|
327 |
+
'stop',
|
328 |
+
'temperature',
|
329 |
+
'repeat_penalty',
|
330 |
+
'presence_penalty',
|
331 |
+
'frequency_penalty',
|
332 |
+
'top_k',
|
333 |
+
'top_p',
|
334 |
+
'tfs_z',
|
335 |
+
'typical_p',
|
336 |
+
'seed',
|
337 |
+
'repeat_last_n',
|
338 |
+
'mirostat',
|
339 |
+
'mirostat_tau',
|
340 |
+
'mirostat_eta',
|
341 |
+
'min_p',
|
342 |
+
];
|
343 |
+
|
344 |
+
const AVATAR_WIDTH = 512;
|
345 |
+
const AVATAR_HEIGHT = 768;
|
346 |
+
|
347 |
+
const OPENROUTER_HEADERS = {
|
348 |
+
'HTTP-Referer': 'https://sillytavern.app',
|
349 |
+
'X-Title': 'SillyTavern',
|
350 |
+
};
|
351 |
+
|
352 |
+
const FEATHERLESS_HEADERS = {
|
353 |
+
'HTTP-Referer': 'https://sillytavern.app',
|
354 |
+
'X-Title': 'SillyTavern',
|
355 |
+
};
|
356 |
+
|
357 |
+
const OPENROUTER_KEYS = [
|
358 |
+
'max_tokens',
|
359 |
+
'temperature',
|
360 |
+
'top_k',
|
361 |
+
'top_p',
|
362 |
+
'presence_penalty',
|
363 |
+
'frequency_penalty',
|
364 |
+
'repetition_penalty',
|
365 |
+
'min_p',
|
366 |
+
'top_a',
|
367 |
+
'seed',
|
368 |
+
'logit_bias',
|
369 |
+
'model',
|
370 |
+
'stream',
|
371 |
+
'prompt',
|
372 |
+
'stop',
|
373 |
+
'provider',
|
374 |
+
];
|
375 |
+
|
376 |
+
// https://github.com/vllm-project/vllm/blob/0f8a91401c89ac0a8018def3756829611b57727f/vllm/entrypoints/openai/protocol.py#L220
|
377 |
+
const VLLM_KEYS = [
|
378 |
+
'model',
|
379 |
+
'prompt',
|
380 |
+
'best_of',
|
381 |
+
'echo',
|
382 |
+
'frequency_penalty',
|
383 |
+
'logit_bias',
|
384 |
+
'logprobs',
|
385 |
+
'max_tokens',
|
386 |
+
'n',
|
387 |
+
'presence_penalty',
|
388 |
+
'seed',
|
389 |
+
'stop',
|
390 |
+
'stream',
|
391 |
+
'suffix',
|
392 |
+
'temperature',
|
393 |
+
'top_p',
|
394 |
+
'user',
|
395 |
+
|
396 |
+
'use_beam_search',
|
397 |
+
'top_k',
|
398 |
+
'min_p',
|
399 |
+
'repetition_penalty',
|
400 |
+
'length_penalty',
|
401 |
+
'early_stopping',
|
402 |
+
'stop_token_ids',
|
403 |
+
'ignore_eos',
|
404 |
+
'min_tokens',
|
405 |
+
'skip_special_tokens',
|
406 |
+
'spaces_between_special_tokens',
|
407 |
+
'truncate_prompt_tokens',
|
408 |
+
|
409 |
+
'include_stop_str_in_output',
|
410 |
+
'response_format',
|
411 |
+
'guided_json',
|
412 |
+
'guided_regex',
|
413 |
+
'guided_choice',
|
414 |
+
'guided_grammar',
|
415 |
+
'guided_decoding_backend',
|
416 |
+
'guided_whitespace_pattern',
|
417 |
+
];
|
418 |
+
|
419 |
+
module.exports = {
|
420 |
+
DEFAULT_USER,
|
421 |
+
DEFAULT_AVATAR,
|
422 |
+
SETTINGS_FILE,
|
423 |
+
PUBLIC_DIRECTORIES,
|
424 |
+
USER_DIRECTORY_TEMPLATE,
|
425 |
+
UNSAFE_EXTENSIONS,
|
426 |
+
UPLOADS_DIRECTORY,
|
427 |
+
GEMINI_SAFETY,
|
428 |
+
BISON_SAFETY,
|
429 |
+
TEXTGEN_TYPES,
|
430 |
+
CHAT_COMPLETION_SOURCES,
|
431 |
+
AVATAR_WIDTH,
|
432 |
+
AVATAR_HEIGHT,
|
433 |
+
TOGETHERAI_KEYS,
|
434 |
+
OLLAMA_KEYS,
|
435 |
+
INFERMATICAI_KEYS,
|
436 |
+
DREAMGEN_KEYS,
|
437 |
+
OPENROUTER_HEADERS,
|
438 |
+
OPENROUTER_KEYS,
|
439 |
+
VLLM_KEYS,
|
440 |
+
FEATHERLESS_KEYS,
|
441 |
+
FEATHERLESS_HEADERS,
|
442 |
+
};
|
src/endpoints/anthropic.js
ADDED
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const { readSecret, SECRET_KEYS } = require('./secrets');
|
2 |
+
const fetch = require('node-fetch').default;
|
3 |
+
const express = require('express');
|
4 |
+
const { jsonParser } = require('../express-common');
|
5 |
+
|
6 |
+
const router = express.Router();
|
7 |
+
|
8 |
+
router.post('/caption-image', jsonParser, async (request, response) => {
|
9 |
+
try {
|
10 |
+
const mimeType = request.body.image.split(';')[0].split(':')[1];
|
11 |
+
const base64Data = request.body.image.split(',')[1];
|
12 |
+
const baseUrl = request.body.reverse_proxy ? request.body.reverse_proxy : 'https://api.anthropic.com/v1';
|
13 |
+
const url = `${baseUrl}/messages`;
|
14 |
+
const body = {
|
15 |
+
model: request.body.model,
|
16 |
+
messages: [
|
17 |
+
{
|
18 |
+
'role': 'user', 'content': [
|
19 |
+
{
|
20 |
+
'type': 'image',
|
21 |
+
'source': {
|
22 |
+
'type': 'base64',
|
23 |
+
'media_type': mimeType,
|
24 |
+
'data': base64Data,
|
25 |
+
},
|
26 |
+
},
|
27 |
+
{ 'type': 'text', 'text': request.body.prompt },
|
28 |
+
],
|
29 |
+
},
|
30 |
+
],
|
31 |
+
max_tokens: 4096,
|
32 |
+
};
|
33 |
+
|
34 |
+
console.log('Multimodal captioning request', body);
|
35 |
+
|
36 |
+
const result = await fetch(url, {
|
37 |
+
body: JSON.stringify(body),
|
38 |
+
method: 'POST',
|
39 |
+
headers: {
|
40 |
+
'Content-Type': 'application/json',
|
41 |
+
'anthropic-version': '2023-06-01',
|
42 |
+
'x-api-key': request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.CLAUDE),
|
43 |
+
},
|
44 |
+
timeout: 0,
|
45 |
+
});
|
46 |
+
|
47 |
+
if (!result.ok) {
|
48 |
+
const text = await result.text();
|
49 |
+
console.log(`Claude API returned error: ${result.status} ${result.statusText}`, text);
|
50 |
+
return response.status(result.status).send({ error: true });
|
51 |
+
}
|
52 |
+
|
53 |
+
const generateResponseJson = await result.json();
|
54 |
+
const caption = generateResponseJson.content[0].text;
|
55 |
+
console.log('Claude response:', generateResponseJson);
|
56 |
+
|
57 |
+
if (!caption) {
|
58 |
+
return response.status(500).send('No caption found');
|
59 |
+
}
|
60 |
+
|
61 |
+
return response.json({ caption });
|
62 |
+
} catch (error) {
|
63 |
+
console.error(error);
|
64 |
+
response.status(500).send('Internal server error');
|
65 |
+
}
|
66 |
+
});
|
67 |
+
|
68 |
+
module.exports = { router };
|
src/endpoints/assets.js
ADDED
@@ -0,0 +1,370 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const path = require('path');
|
2 |
+
const fs = require('fs');
|
3 |
+
const mime = require('mime-types');
|
4 |
+
const express = require('express');
|
5 |
+
const sanitize = require('sanitize-filename');
|
6 |
+
const fetch = require('node-fetch').default;
|
7 |
+
const { finished } = require('stream/promises');
|
8 |
+
const { UNSAFE_EXTENSIONS } = require('../constants');
|
9 |
+
const { jsonParser } = require('../express-common');
|
10 |
+
const { clientRelativePath } = require('../util');
|
11 |
+
|
12 |
+
const VALID_CATEGORIES = ['bgm', 'ambient', 'blip', 'live2d', 'vrm', 'character', 'temp'];
|
13 |
+
|
14 |
+
/**
|
15 |
+
* Validates the input filename for the asset.
|
16 |
+
* @param {string} inputFilename Input filename
|
17 |
+
* @returns {{error: boolean, message?: string}} Whether validation failed, and why if so
|
18 |
+
*/
|
19 |
+
function validateAssetFileName(inputFilename) {
|
20 |
+
if (!/^[a-zA-Z0-9_\-.]+$/.test(inputFilename)) {
|
21 |
+
return {
|
22 |
+
error: true,
|
23 |
+
message: 'Illegal character in filename; only alphanumeric, \'_\', \'-\' are accepted.',
|
24 |
+
};
|
25 |
+
}
|
26 |
+
|
27 |
+
const inputExtension = path.extname(inputFilename).toLowerCase();
|
28 |
+
if (UNSAFE_EXTENSIONS.some(ext => ext === inputExtension)) {
|
29 |
+
return {
|
30 |
+
error: true,
|
31 |
+
message: 'Forbidden file extension.',
|
32 |
+
};
|
33 |
+
}
|
34 |
+
|
35 |
+
if (inputFilename.startsWith('.')) {
|
36 |
+
return {
|
37 |
+
error: true,
|
38 |
+
message: 'Filename cannot start with \'.\'',
|
39 |
+
};
|
40 |
+
}
|
41 |
+
|
42 |
+
if (sanitize(inputFilename) !== inputFilename) {
|
43 |
+
return {
|
44 |
+
error: true,
|
45 |
+
message: 'Reserved or long filename.',
|
46 |
+
};
|
47 |
+
}
|
48 |
+
|
49 |
+
return { error: false };
|
50 |
+
}
|
51 |
+
|
52 |
+
/**
|
53 |
+
* Recursive function to get files
|
54 |
+
* @param {string} dir - The directory to search for files
|
55 |
+
* @param {string[]} files - The array of files to return
|
56 |
+
* @returns {string[]} - The array of files
|
57 |
+
*/
|
58 |
+
function getFiles(dir, files = []) {
|
59 |
+
if (!fs.existsSync(dir)) return files;
|
60 |
+
|
61 |
+
// Get an array of all files and directories in the passed directory using fs.readdirSync
|
62 |
+
const fileList = fs.readdirSync(dir, { withFileTypes: true });
|
63 |
+
// Create the full path of the file/directory by concatenating the passed directory and file/directory name
|
64 |
+
for (const file of fileList) {
|
65 |
+
const name = path.join(dir, file.name);
|
66 |
+
// Check if the current file/directory is a directory using fs.statSync
|
67 |
+
if (file.isDirectory()) {
|
68 |
+
// If it is a directory, recursively call the getFiles function with the directory path and the files array
|
69 |
+
getFiles(name, files);
|
70 |
+
} else {
|
71 |
+
// If it is a file, push the full path to the files array
|
72 |
+
files.push(name);
|
73 |
+
}
|
74 |
+
}
|
75 |
+
return files;
|
76 |
+
}
|
77 |
+
|
78 |
+
/**
|
79 |
+
* Ensure that the asset folders exist.
|
80 |
+
* @param {import('../users').UserDirectoryList} directories - The user's directories
|
81 |
+
*/
|
82 |
+
function ensureFoldersExist(directories) {
|
83 |
+
const folderPath = path.join(directories.assets);
|
84 |
+
|
85 |
+
for (const category of VALID_CATEGORIES) {
|
86 |
+
const assetCategoryPath = path.join(folderPath, category);
|
87 |
+
if (fs.existsSync(assetCategoryPath) && !fs.statSync(assetCategoryPath).isDirectory()) {
|
88 |
+
fs.unlinkSync(assetCategoryPath);
|
89 |
+
}
|
90 |
+
if (!fs.existsSync(assetCategoryPath)) {
|
91 |
+
fs.mkdirSync(assetCategoryPath, { recursive: true });
|
92 |
+
}
|
93 |
+
}
|
94 |
+
}
|
95 |
+
|
96 |
+
const router = express.Router();
|
97 |
+
|
98 |
+
/**
|
99 |
+
* HTTP POST handler function to retrieve name of all files of a given folder path.
|
100 |
+
*
|
101 |
+
* @param {Object} request - HTTP Request object. Require folder path in query
|
102 |
+
* @param {Object} response - HTTP Response object will contain a list of file path.
|
103 |
+
*
|
104 |
+
* @returns {void}
|
105 |
+
*/
|
106 |
+
router.post('/get', jsonParser, async (request, response) => {
|
107 |
+
const folderPath = path.join(request.user.directories.assets);
|
108 |
+
let output = {};
|
109 |
+
|
110 |
+
try {
|
111 |
+
if (fs.existsSync(folderPath) && fs.statSync(folderPath).isDirectory()) {
|
112 |
+
|
113 |
+
ensureFoldersExist(request.user.directories);
|
114 |
+
|
115 |
+
const folders = fs.readdirSync(folderPath, { withFileTypes: true })
|
116 |
+
.filter(file => file.isDirectory());
|
117 |
+
|
118 |
+
for (const { name: folder } of folders) {
|
119 |
+
if (folder == 'temp')
|
120 |
+
continue;
|
121 |
+
|
122 |
+
// Live2d assets
|
123 |
+
if (folder == 'live2d') {
|
124 |
+
output[folder] = [];
|
125 |
+
const live2d_folder = path.normalize(path.join(folderPath, folder));
|
126 |
+
const files = getFiles(live2d_folder);
|
127 |
+
//console.debug("FILE FOUND:",files)
|
128 |
+
for (let file of files) {
|
129 |
+
if (file.includes('model') && file.endsWith('.json')) {
|
130 |
+
//console.debug("Asset live2d model found:",file)
|
131 |
+
output[folder].push(clientRelativePath(request.user.directories.root, file));
|
132 |
+
}
|
133 |
+
}
|
134 |
+
continue;
|
135 |
+
}
|
136 |
+
|
137 |
+
// VRM assets
|
138 |
+
if (folder == 'vrm') {
|
139 |
+
output[folder] = { 'model': [], 'animation': [] };
|
140 |
+
// Extract models
|
141 |
+
const vrm_model_folder = path.normalize(path.join(folderPath, 'vrm', 'model'));
|
142 |
+
let files = getFiles(vrm_model_folder);
|
143 |
+
//console.debug("FILE FOUND:",files)
|
144 |
+
for (let file of files) {
|
145 |
+
if (!file.endsWith('.placeholder')) {
|
146 |
+
//console.debug("Asset VRM model found:",file)
|
147 |
+
output['vrm']['model'].push(clientRelativePath(request.user.directories.root, file));
|
148 |
+
}
|
149 |
+
}
|
150 |
+
|
151 |
+
// Extract models
|
152 |
+
const vrm_animation_folder = path.normalize(path.join(folderPath, 'vrm', 'animation'));
|
153 |
+
files = getFiles(vrm_animation_folder);
|
154 |
+
//console.debug("FILE FOUND:",files)
|
155 |
+
for (let file of files) {
|
156 |
+
if (!file.endsWith('.placeholder')) {
|
157 |
+
//console.debug("Asset VRM animation found:",file)
|
158 |
+
output['vrm']['animation'].push(clientRelativePath(request.user.directories.root, file));
|
159 |
+
}
|
160 |
+
}
|
161 |
+
continue;
|
162 |
+
}
|
163 |
+
|
164 |
+
// Other assets (bgm/ambient/blip)
|
165 |
+
const files = fs.readdirSync(path.join(folderPath, folder))
|
166 |
+
.filter(filename => {
|
167 |
+
return filename != '.placeholder';
|
168 |
+
});
|
169 |
+
output[folder] = [];
|
170 |
+
for (const file of files) {
|
171 |
+
output[folder].push(`assets/${folder}/${file}`);
|
172 |
+
}
|
173 |
+
}
|
174 |
+
}
|
175 |
+
}
|
176 |
+
catch (err) {
|
177 |
+
console.log(err);
|
178 |
+
}
|
179 |
+
return response.send(output);
|
180 |
+
});
|
181 |
+
|
182 |
+
/**
|
183 |
+
* HTTP POST handler function to download the requested asset.
|
184 |
+
*
|
185 |
+
* @param {Object} request - HTTP Request object, expects a url, a category and a filename.
|
186 |
+
* @param {Object} response - HTTP Response only gives status.
|
187 |
+
*
|
188 |
+
* @returns {void}
|
189 |
+
*/
|
190 |
+
router.post('/download', jsonParser, async (request, response) => {
|
191 |
+
const url = request.body.url;
|
192 |
+
const inputCategory = request.body.category;
|
193 |
+
|
194 |
+
// Check category
|
195 |
+
let category = null;
|
196 |
+
for (let i of VALID_CATEGORIES)
|
197 |
+
if (i == inputCategory)
|
198 |
+
category = i;
|
199 |
+
|
200 |
+
if (category === null) {
|
201 |
+
console.debug('Bad request: unsupported asset category.');
|
202 |
+
return response.sendStatus(400);
|
203 |
+
}
|
204 |
+
|
205 |
+
// Validate filename
|
206 |
+
ensureFoldersExist(request.user.directories);
|
207 |
+
const validation = validateAssetFileName(request.body.filename);
|
208 |
+
if (validation.error)
|
209 |
+
return response.status(400).send(validation.message);
|
210 |
+
|
211 |
+
const temp_path = path.join(request.user.directories.assets, 'temp', request.body.filename);
|
212 |
+
const file_path = path.join(request.user.directories.assets, category, request.body.filename);
|
213 |
+
console.debug('Request received to download', url, 'to', file_path);
|
214 |
+
|
215 |
+
try {
|
216 |
+
// Download to temp
|
217 |
+
const res = await fetch(url);
|
218 |
+
if (!res.ok || res.body === null) {
|
219 |
+
throw new Error(`Unexpected response ${res.statusText}`);
|
220 |
+
}
|
221 |
+
const destination = path.resolve(temp_path);
|
222 |
+
// Delete if previous download failed
|
223 |
+
if (fs.existsSync(temp_path)) {
|
224 |
+
fs.unlink(temp_path, (err) => {
|
225 |
+
if (err) throw err;
|
226 |
+
});
|
227 |
+
}
|
228 |
+
const fileStream = fs.createWriteStream(destination, { flags: 'wx' });
|
229 |
+
// @ts-ignore
|
230 |
+
await finished(res.body.pipe(fileStream));
|
231 |
+
|
232 |
+
if (category === 'character') {
|
233 |
+
const fileContent = fs.readFileSync(temp_path);
|
234 |
+
const contentType = mime.lookup(temp_path) || 'application/octet-stream';
|
235 |
+
response.setHeader('Content-Type', contentType);
|
236 |
+
response.send(fileContent);
|
237 |
+
fs.rmSync(temp_path);
|
238 |
+
return;
|
239 |
+
}
|
240 |
+
|
241 |
+
// Move into asset place
|
242 |
+
console.debug('Download finished, moving file from', temp_path, 'to', file_path);
|
243 |
+
fs.copyFileSync(temp_path, file_path);
|
244 |
+
fs.rmSync(temp_path);
|
245 |
+
response.sendStatus(200);
|
246 |
+
}
|
247 |
+
catch (error) {
|
248 |
+
console.log(error);
|
249 |
+
response.sendStatus(500);
|
250 |
+
}
|
251 |
+
});
|
252 |
+
|
253 |
+
/**
|
254 |
+
* HTTP POST handler function to delete the requested asset.
|
255 |
+
*
|
256 |
+
* @param {Object} request - HTTP Request object, expects a category and a filename
|
257 |
+
* @param {Object} response - HTTP Response only gives stats.
|
258 |
+
*
|
259 |
+
* @returns {void}
|
260 |
+
*/
|
261 |
+
router.post('/delete', jsonParser, async (request, response) => {
|
262 |
+
const inputCategory = request.body.category;
|
263 |
+
|
264 |
+
// Check category
|
265 |
+
let category = null;
|
266 |
+
for (let i of VALID_CATEGORIES)
|
267 |
+
if (i == inputCategory)
|
268 |
+
category = i;
|
269 |
+
|
270 |
+
if (category === null) {
|
271 |
+
console.debug('Bad request: unsupported asset category.');
|
272 |
+
return response.sendStatus(400);
|
273 |
+
}
|
274 |
+
|
275 |
+
// Validate filename
|
276 |
+
const validation = validateAssetFileName(request.body.filename);
|
277 |
+
if (validation.error)
|
278 |
+
return response.status(400).send(validation.message);
|
279 |
+
|
280 |
+
const file_path = path.join(request.user.directories.assets, category, request.body.filename);
|
281 |
+
console.debug('Request received to delete', category, file_path);
|
282 |
+
|
283 |
+
try {
|
284 |
+
// Delete if previous download failed
|
285 |
+
if (fs.existsSync(file_path)) {
|
286 |
+
fs.unlink(file_path, (err) => {
|
287 |
+
if (err) throw err;
|
288 |
+
});
|
289 |
+
console.debug('Asset deleted.');
|
290 |
+
}
|
291 |
+
else {
|
292 |
+
console.debug('Asset not found.');
|
293 |
+
response.sendStatus(400);
|
294 |
+
}
|
295 |
+
// Move into asset place
|
296 |
+
response.sendStatus(200);
|
297 |
+
}
|
298 |
+
catch (error) {
|
299 |
+
console.log(error);
|
300 |
+
response.sendStatus(500);
|
301 |
+
}
|
302 |
+
});
|
303 |
+
|
304 |
+
///////////////////////////////
|
305 |
+
/**
|
306 |
+
* HTTP POST handler function to retrieve a character background music list.
|
307 |
+
*
|
308 |
+
* @param {Object} request - HTTP Request object, expects a character name in the query.
|
309 |
+
* @param {Object} response - HTTP Response object will contain a list of audio file path.
|
310 |
+
*
|
311 |
+
* @returns {void}
|
312 |
+
*/
|
313 |
+
router.post('/character', jsonParser, async (request, response) => {
|
314 |
+
if (request.query.name === undefined) return response.sendStatus(400);
|
315 |
+
// For backwards compatibility, don't reject invalid character names, just sanitize them
|
316 |
+
const name = sanitize(request.query.name.toString());
|
317 |
+
const inputCategory = request.query.category;
|
318 |
+
|
319 |
+
// Check category
|
320 |
+
let category = null;
|
321 |
+
for (let i of VALID_CATEGORIES)
|
322 |
+
if (i == inputCategory)
|
323 |
+
category = i;
|
324 |
+
|
325 |
+
if (category === null) {
|
326 |
+
console.debug('Bad request: unsupported asset category.');
|
327 |
+
return response.sendStatus(400);
|
328 |
+
}
|
329 |
+
|
330 |
+
const folderPath = path.join(request.user.directories.characters, name, category);
|
331 |
+
|
332 |
+
let output = [];
|
333 |
+
try {
|
334 |
+
if (fs.existsSync(folderPath) && fs.statSync(folderPath).isDirectory()) {
|
335 |
+
|
336 |
+
// Live2d assets
|
337 |
+
if (category == 'live2d') {
|
338 |
+
const folders = fs.readdirSync(folderPath, { withFileTypes: true });
|
339 |
+
for (const folderInfo of folders) {
|
340 |
+
if (!folderInfo.isDirectory()) continue;
|
341 |
+
|
342 |
+
const modelFolder = folderInfo.name;
|
343 |
+
const live2dModelPath = path.join(folderPath, modelFolder);
|
344 |
+
for (let file of fs.readdirSync(live2dModelPath)) {
|
345 |
+
//console.debug("Character live2d model found:", file)
|
346 |
+
if (file.includes('model') && file.endsWith('.json'))
|
347 |
+
output.push(path.join('characters', name, category, modelFolder, file));
|
348 |
+
}
|
349 |
+
}
|
350 |
+
return response.send(output);
|
351 |
+
}
|
352 |
+
|
353 |
+
// Other assets
|
354 |
+
const files = fs.readdirSync(folderPath)
|
355 |
+
.filter(filename => {
|
356 |
+
return filename != '.placeholder';
|
357 |
+
});
|
358 |
+
|
359 |
+
for (let i of files)
|
360 |
+
output.push(`/characters/${name}/${category}/${i}`);
|
361 |
+
}
|
362 |
+
return response.send(output);
|
363 |
+
}
|
364 |
+
catch (err) {
|
365 |
+
console.log(err);
|
366 |
+
return response.sendStatus(500);
|
367 |
+
}
|
368 |
+
});
|
369 |
+
|
370 |
+
module.exports = { router, validateAssetFileName };
|
src/endpoints/avatars.js
ADDED
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const express = require('express');
|
2 |
+
const path = require('path');
|
3 |
+
const fs = require('fs');
|
4 |
+
const sanitize = require('sanitize-filename');
|
5 |
+
const writeFileAtomicSync = require('write-file-atomic').sync;
|
6 |
+
const { jsonParser, urlencodedParser } = require('../express-common');
|
7 |
+
const { AVATAR_WIDTH, AVATAR_HEIGHT } = require('../constants');
|
8 |
+
const { getImages, tryParse } = require('../util');
|
9 |
+
|
10 |
+
// image processing related library imports
|
11 |
+
const jimp = require('jimp');
|
12 |
+
|
13 |
+
const router = express.Router();
|
14 |
+
|
15 |
+
router.post('/get', jsonParser, function (request, response) {
|
16 |
+
var images = getImages(request.user.directories.avatars);
|
17 |
+
response.send(JSON.stringify(images));
|
18 |
+
});
|
19 |
+
|
20 |
+
router.post('/delete', jsonParser, function (request, response) {
|
21 |
+
if (!request.body) return response.sendStatus(400);
|
22 |
+
|
23 |
+
if (request.body.avatar !== sanitize(request.body.avatar)) {
|
24 |
+
console.error('Malicious avatar name prevented');
|
25 |
+
return response.sendStatus(403);
|
26 |
+
}
|
27 |
+
|
28 |
+
const fileName = path.join(request.user.directories.avatars, sanitize(request.body.avatar));
|
29 |
+
|
30 |
+
if (fs.existsSync(fileName)) {
|
31 |
+
fs.rmSync(fileName);
|
32 |
+
return response.send({ result: 'ok' });
|
33 |
+
}
|
34 |
+
|
35 |
+
return response.sendStatus(404);
|
36 |
+
});
|
37 |
+
|
38 |
+
router.post('/upload', urlencodedParser, async (request, response) => {
|
39 |
+
if (!request.file) return response.sendStatus(400);
|
40 |
+
|
41 |
+
try {
|
42 |
+
const pathToUpload = path.join(request.file.destination, request.file.filename);
|
43 |
+
const crop = tryParse(request.query.crop);
|
44 |
+
let rawImg = await jimp.read(pathToUpload);
|
45 |
+
|
46 |
+
if (typeof crop == 'object' && [crop.x, crop.y, crop.width, crop.height].every(x => typeof x === 'number')) {
|
47 |
+
rawImg = rawImg.crop(crop.x, crop.y, crop.width, crop.height);
|
48 |
+
}
|
49 |
+
|
50 |
+
const image = await rawImg.cover(AVATAR_WIDTH, AVATAR_HEIGHT).getBufferAsync(jimp.MIME_PNG);
|
51 |
+
|
52 |
+
const filename = request.body.overwrite_name || `${Date.now()}.png`;
|
53 |
+
const pathToNewFile = path.join(request.user.directories.avatars, filename);
|
54 |
+
writeFileAtomicSync(pathToNewFile, image);
|
55 |
+
fs.rmSync(pathToUpload);
|
56 |
+
return response.send({ path: filename });
|
57 |
+
} catch (err) {
|
58 |
+
return response.status(400).send('Is not a valid image');
|
59 |
+
}
|
60 |
+
});
|
61 |
+
|
62 |
+
module.exports = { router };
|
src/endpoints/azure.js
ADDED
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const { readSecret, SECRET_KEYS } = require('./secrets');
|
2 |
+
const fetch = require('node-fetch').default;
|
3 |
+
const express = require('express');
|
4 |
+
const { jsonParser } = require('../express-common');
|
5 |
+
|
6 |
+
const router = express.Router();
|
7 |
+
|
8 |
+
router.post('/list', jsonParser, async (req, res) => {
|
9 |
+
try {
|
10 |
+
const key = readSecret(req.user.directories, SECRET_KEYS.AZURE_TTS);
|
11 |
+
|
12 |
+
if (!key) {
|
13 |
+
console.error('Azure TTS API Key not set');
|
14 |
+
return res.sendStatus(403);
|
15 |
+
}
|
16 |
+
|
17 |
+
const region = req.body.region;
|
18 |
+
|
19 |
+
if (!region) {
|
20 |
+
console.error('Azure TTS region not set');
|
21 |
+
return res.sendStatus(400);
|
22 |
+
}
|
23 |
+
|
24 |
+
const url = `https://${region}.tts.speech.microsoft.com/cognitiveservices/voices/list`;
|
25 |
+
|
26 |
+
const response = await fetch(url, {
|
27 |
+
method: 'GET',
|
28 |
+
headers: {
|
29 |
+
'Ocp-Apim-Subscription-Key': key,
|
30 |
+
},
|
31 |
+
});
|
32 |
+
|
33 |
+
if (!response.ok) {
|
34 |
+
console.error('Azure Request failed', response.status, response.statusText);
|
35 |
+
return res.sendStatus(500);
|
36 |
+
}
|
37 |
+
|
38 |
+
const voices = await response.json();
|
39 |
+
return res.json(voices);
|
40 |
+
} catch (error) {
|
41 |
+
console.error('Azure Request failed', error);
|
42 |
+
return res.sendStatus(500);
|
43 |
+
}
|
44 |
+
});
|
45 |
+
|
46 |
+
router.post('/generate', jsonParser, async (req, res) => {
|
47 |
+
try {
|
48 |
+
const key = readSecret(req.user.directories, SECRET_KEYS.AZURE_TTS);
|
49 |
+
|
50 |
+
if (!key) {
|
51 |
+
console.error('Azure TTS API Key not set');
|
52 |
+
return res.sendStatus(403);
|
53 |
+
}
|
54 |
+
|
55 |
+
const { text, voice, region } = req.body;
|
56 |
+
if (!text || !voice || !region) {
|
57 |
+
console.error('Missing required parameters');
|
58 |
+
return res.sendStatus(400);
|
59 |
+
}
|
60 |
+
|
61 |
+
const url = `https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`;
|
62 |
+
const lang = String(voice).split('-').slice(0, 2).join('-');
|
63 |
+
const escapedText = String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
64 |
+
const ssml = `<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='${lang}'><voice xml:lang='${lang}' name='${voice}'>${escapedText}</voice></speak>`;
|
65 |
+
|
66 |
+
const response = await fetch(url, {
|
67 |
+
method: 'POST',
|
68 |
+
headers: {
|
69 |
+
'Ocp-Apim-Subscription-Key': key,
|
70 |
+
'Content-Type': 'application/ssml+xml',
|
71 |
+
'X-Microsoft-OutputFormat': 'ogg-48khz-16bit-mono-opus',
|
72 |
+
},
|
73 |
+
body: ssml,
|
74 |
+
});
|
75 |
+
|
76 |
+
if (!response.ok) {
|
77 |
+
console.error('Azure Request failed', response.status, response.statusText);
|
78 |
+
return res.sendStatus(500);
|
79 |
+
}
|
80 |
+
|
81 |
+
const audio = await response.buffer();
|
82 |
+
res.set('Content-Type', 'audio/ogg');
|
83 |
+
return res.send(audio);
|
84 |
+
} catch (error) {
|
85 |
+
console.error('Azure Request failed', error);
|
86 |
+
return res.sendStatus(500);
|
87 |
+
}
|
88 |
+
});
|
89 |
+
|
90 |
+
module.exports = {
|
91 |
+
router,
|
92 |
+
};
|
src/endpoints/backends/chat-completions.js
ADDED
@@ -0,0 +1,1106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const express = require('express');
|
2 |
+
const fetch = require('node-fetch').default;
|
3 |
+
const Readable = require('stream').Readable;
|
4 |
+
|
5 |
+
const { jsonParser } = require('../../express-common');
|
6 |
+
const { CHAT_COMPLETION_SOURCES, GEMINI_SAFETY, BISON_SAFETY, OPENROUTER_HEADERS } = require('../../constants');
|
7 |
+
const { forwardFetchResponse, getConfigValue, tryParse, uuidv4, mergeObjectWithYaml, excludeKeysByYaml, color } = require('../../util');
|
8 |
+
const { convertClaudeMessages, convertGooglePrompt, convertTextCompletionPrompt, convertCohereMessages, convertMistralMessages, convertCohereTools } = require('../../prompt-converters');
|
9 |
+
|
10 |
+
const { readSecret, SECRET_KEYS } = require('../secrets');
|
11 |
+
const { getTokenizerModel, getSentencepiceTokenizer, getTiktokenTokenizer, sentencepieceTokenizers, TEXT_COMPLETION_MODELS } = require('../tokenizers');
|
12 |
+
|
13 |
+
const API_OPENAI = 'https://api.openai.com/v1';
|
14 |
+
const API_CLAUDE = 'https://api.anthropic.com/v1';
|
15 |
+
const API_MISTRAL = 'https://api.mistral.ai/v1';
|
16 |
+
const API_COHERE = 'https://api.cohere.ai/v1';
|
17 |
+
const API_PERPLEXITY = 'https://api.perplexity.ai';
|
18 |
+
const API_GROQ = 'https://api.groq.com/openai/v1';
|
19 |
+
const API_MAKERSUITE = 'https://generativelanguage.googleapis.com';
|
20 |
+
const API_01AI = 'https://api.01.ai/v1';
|
21 |
+
const API_BLOCKENTROPY = 'https://api.blockentropy.ai/v1';
|
22 |
+
|
23 |
+
/**
|
24 |
+
* Applies a post-processing step to the generated messages.
|
25 |
+
* @param {object[]} messages Messages to post-process
|
26 |
+
* @param {string} type Prompt conversion type
|
27 |
+
* @param {string} charName Character name
|
28 |
+
* @param {string} userName User name
|
29 |
+
* @returns
|
30 |
+
*/
|
31 |
+
function postProcessPrompt(messages, type, charName, userName) {
|
32 |
+
switch (type) {
|
33 |
+
case 'claude':
|
34 |
+
return convertClaudeMessages(messages, '', false, '', charName, userName).messages;
|
35 |
+
default:
|
36 |
+
return messages;
|
37 |
+
}
|
38 |
+
}
|
39 |
+
|
40 |
+
/**
|
41 |
+
* Ollama strikes back. Special boy #2's steaming routine.
|
42 |
+
* Wrap this abomination into proper SSE stream, again.
|
43 |
+
* @param {import('node-fetch').Response} jsonStream JSON stream
|
44 |
+
* @param {import('express').Request} request Express request
|
45 |
+
* @param {import('express').Response} response Express response
|
46 |
+
* @returns {Promise<any>} Nothing valuable
|
47 |
+
*/
|
48 |
+
async function parseCohereStream(jsonStream, request, response) {
|
49 |
+
try {
|
50 |
+
let partialData = '';
|
51 |
+
jsonStream.body.on('data', (data) => {
|
52 |
+
const chunk = data.toString();
|
53 |
+
partialData += chunk;
|
54 |
+
while (true) {
|
55 |
+
let json;
|
56 |
+
try {
|
57 |
+
json = JSON.parse(partialData);
|
58 |
+
} catch (e) {
|
59 |
+
break;
|
60 |
+
}
|
61 |
+
if (json.message) {
|
62 |
+
const message = json.message || 'Unknown error';
|
63 |
+
const chunk = { error: { message: message } };
|
64 |
+
response.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
65 |
+
partialData = '';
|
66 |
+
break;
|
67 |
+
} else if (json.event_type === 'text-generation') {
|
68 |
+
const text = json.text || '';
|
69 |
+
const chunk = { choices: [{ text }] };
|
70 |
+
response.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
71 |
+
partialData = '';
|
72 |
+
} else {
|
73 |
+
partialData = '';
|
74 |
+
break;
|
75 |
+
}
|
76 |
+
}
|
77 |
+
});
|
78 |
+
|
79 |
+
request.socket.on('close', function () {
|
80 |
+
if (jsonStream.body instanceof Readable) jsonStream.body.destroy();
|
81 |
+
response.end();
|
82 |
+
});
|
83 |
+
|
84 |
+
jsonStream.body.on('end', () => {
|
85 |
+
console.log('Streaming request finished');
|
86 |
+
response.write('data: [DONE]\n\n');
|
87 |
+
response.end();
|
88 |
+
});
|
89 |
+
} catch (error) {
|
90 |
+
console.log('Error forwarding streaming response:', error);
|
91 |
+
if (!response.headersSent) {
|
92 |
+
return response.status(500).send({ error: true });
|
93 |
+
} else {
|
94 |
+
return response.end();
|
95 |
+
}
|
96 |
+
}
|
97 |
+
}
|
98 |
+
|
99 |
+
/**
|
100 |
+
* Sends a request to Claude API.
|
101 |
+
* @param {express.Request} request Express request
|
102 |
+
* @param {express.Response} response Express response
|
103 |
+
*/
|
104 |
+
async function sendClaudeRequest(request, response) {
|
105 |
+
const apiUrl = new URL(request.body.reverse_proxy || API_CLAUDE).toString();
|
106 |
+
const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.CLAUDE);
|
107 |
+
const divider = '-'.repeat(process.stdout.columns);
|
108 |
+
const enableSystemPromptCache = getConfigValue('claude.enableSystemPromptCache', false);
|
109 |
+
|
110 |
+
if (!apiKey) {
|
111 |
+
console.log(color.red(`Claude API key is missing.\n${divider}`));
|
112 |
+
return response.status(400).send({ error: true });
|
113 |
+
}
|
114 |
+
|
115 |
+
try {
|
116 |
+
const controller = new AbortController();
|
117 |
+
request.socket.removeAllListeners('close');
|
118 |
+
request.socket.on('close', function () {
|
119 |
+
controller.abort();
|
120 |
+
});
|
121 |
+
const additionalHeaders = {};
|
122 |
+
const useSystemPrompt = (request.body.model.startsWith('claude-2') || request.body.model.startsWith('claude-3')) && request.body.claude_use_sysprompt;
|
123 |
+
const convertedPrompt = convertClaudeMessages(request.body.messages, request.body.assistant_prefill, useSystemPrompt, request.body.human_sysprompt_message, request.body.char_name, request.body.user_name);
|
124 |
+
// Add custom stop sequences
|
125 |
+
const stopSequences = [];
|
126 |
+
if (Array.isArray(request.body.stop)) {
|
127 |
+
stopSequences.push(...request.body.stop);
|
128 |
+
}
|
129 |
+
|
130 |
+
const requestBody = {
|
131 |
+
messages: convertedPrompt.messages,
|
132 |
+
model: request.body.model,
|
133 |
+
max_tokens: request.body.max_tokens,
|
134 |
+
stop_sequences: stopSequences,
|
135 |
+
temperature: request.body.temperature,
|
136 |
+
top_p: request.body.top_p,
|
137 |
+
top_k: request.body.top_k,
|
138 |
+
stream: request.body.stream,
|
139 |
+
};
|
140 |
+
if (useSystemPrompt) {
|
141 |
+
requestBody.system = enableSystemPromptCache
|
142 |
+
? [{ type: 'text', text: convertedPrompt.systemPrompt, cache_control: { type: 'ephemeral' } }]
|
143 |
+
: convertedPrompt.systemPrompt;
|
144 |
+
}
|
145 |
+
if (Array.isArray(request.body.tools) && request.body.tools.length > 0) {
|
146 |
+
// Claude doesn't do prefills on function calls, and doesn't allow empty messages
|
147 |
+
if (convertedPrompt.messages.length && convertedPrompt.messages[convertedPrompt.messages.length - 1].role === 'assistant') {
|
148 |
+
convertedPrompt.messages.push({ role: 'user', content: '.' });
|
149 |
+
}
|
150 |
+
additionalHeaders['anthropic-beta'] = 'tools-2024-05-16';
|
151 |
+
requestBody.tool_choice = { type: request.body.tool_choice === 'required' ? 'any' : 'auto' };
|
152 |
+
requestBody.tools = request.body.tools
|
153 |
+
.filter(tool => tool.type === 'function')
|
154 |
+
.map(tool => tool.function)
|
155 |
+
.map(fn => ({ name: fn.name, description: fn.description, input_schema: fn.parameters }));
|
156 |
+
}
|
157 |
+
if (enableSystemPromptCache) {
|
158 |
+
additionalHeaders['anthropic-beta'] = 'prompt-caching-2024-07-31';
|
159 |
+
}
|
160 |
+
console.log('Claude request:', requestBody);
|
161 |
+
|
162 |
+
const generateResponse = await fetch(apiUrl + '/messages', {
|
163 |
+
method: 'POST',
|
164 |
+
signal: controller.signal,
|
165 |
+
body: JSON.stringify(requestBody),
|
166 |
+
headers: {
|
167 |
+
'Content-Type': 'application/json',
|
168 |
+
'anthropic-version': '2023-06-01',
|
169 |
+
'x-api-key': apiKey,
|
170 |
+
...additionalHeaders,
|
171 |
+
},
|
172 |
+
timeout: 0,
|
173 |
+
});
|
174 |
+
|
175 |
+
if (request.body.stream) {
|
176 |
+
// Pipe remote SSE stream to Express response
|
177 |
+
forwardFetchResponse(generateResponse, response);
|
178 |
+
} else {
|
179 |
+
if (!generateResponse.ok) {
|
180 |
+
console.log(color.red(`Claude API returned error: ${generateResponse.status} ${generateResponse.statusText}\n${await generateResponse.text()}\n${divider}`));
|
181 |
+
return response.status(generateResponse.status).send({ error: true });
|
182 |
+
}
|
183 |
+
|
184 |
+
const generateResponseJson = await generateResponse.json();
|
185 |
+
const responseText = generateResponseJson.content[0].text;
|
186 |
+
console.log('Claude response:', generateResponseJson);
|
187 |
+
|
188 |
+
// Wrap it back to OAI format + save the original content
|
189 |
+
const reply = { choices: [{ 'message': { 'content': responseText } }], content: generateResponseJson.content };
|
190 |
+
return response.send(reply);
|
191 |
+
}
|
192 |
+
} catch (error) {
|
193 |
+
console.log(color.red(`Error communicating with Claude: ${error}\n${divider}`));
|
194 |
+
if (!response.headersSent) {
|
195 |
+
return response.status(500).send({ error: true });
|
196 |
+
}
|
197 |
+
}
|
198 |
+
}
|
199 |
+
|
200 |
+
/**
|
201 |
+
* Sends a request to Scale Spellbook API.
|
202 |
+
* @param {import("express").Request} request Express request
|
203 |
+
* @param {import("express").Response} response Express response
|
204 |
+
*/
|
205 |
+
async function sendScaleRequest(request, response) {
|
206 |
+
const apiUrl = new URL(request.body.api_url_scale).toString();
|
207 |
+
const apiKey = readSecret(request.user.directories, SECRET_KEYS.SCALE);
|
208 |
+
|
209 |
+
if (!apiKey) {
|
210 |
+
console.log('Scale API key is missing.');
|
211 |
+
return response.status(400).send({ error: true });
|
212 |
+
}
|
213 |
+
|
214 |
+
const requestPrompt = convertTextCompletionPrompt(request.body.messages);
|
215 |
+
console.log('Scale request:', requestPrompt);
|
216 |
+
|
217 |
+
try {
|
218 |
+
const controller = new AbortController();
|
219 |
+
request.socket.removeAllListeners('close');
|
220 |
+
request.socket.on('close', function () {
|
221 |
+
controller.abort();
|
222 |
+
});
|
223 |
+
|
224 |
+
const generateResponse = await fetch(apiUrl, {
|
225 |
+
method: 'POST',
|
226 |
+
body: JSON.stringify({ input: { input: requestPrompt } }),
|
227 |
+
headers: {
|
228 |
+
'Content-Type': 'application/json',
|
229 |
+
'Authorization': `Basic ${apiKey}`,
|
230 |
+
},
|
231 |
+
timeout: 0,
|
232 |
+
});
|
233 |
+
|
234 |
+
if (!generateResponse.ok) {
|
235 |
+
console.log(`Scale API returned error: ${generateResponse.status} ${generateResponse.statusText} ${await generateResponse.text()}`);
|
236 |
+
return response.status(generateResponse.status).send({ error: true });
|
237 |
+
}
|
238 |
+
|
239 |
+
const generateResponseJson = await generateResponse.json();
|
240 |
+
console.log('Scale response:', generateResponseJson);
|
241 |
+
|
242 |
+
const reply = { choices: [{ 'message': { 'content': generateResponseJson.output } }] };
|
243 |
+
return response.send(reply);
|
244 |
+
} catch (error) {
|
245 |
+
console.log(error);
|
246 |
+
if (!response.headersSent) {
|
247 |
+
return response.status(500).send({ error: true });
|
248 |
+
}
|
249 |
+
}
|
250 |
+
}
|
251 |
+
|
252 |
+
/**
|
253 |
+
* Sends a request to Google AI API.
|
254 |
+
* @param {express.Request} request Express request
|
255 |
+
* @param {express.Response} response Express response
|
256 |
+
*/
|
257 |
+
async function sendMakerSuiteRequest(request, response) {
|
258 |
+
const apiUrl = new URL(request.body.reverse_proxy || API_MAKERSUITE);
|
259 |
+
const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE);
|
260 |
+
|
261 |
+
if (!request.body.reverse_proxy && !apiKey) {
|
262 |
+
console.log('Google AI Studio API key is missing.');
|
263 |
+
return response.status(400).send({ error: true });
|
264 |
+
}
|
265 |
+
|
266 |
+
const model = String(request.body.model);
|
267 |
+
const isGemini = model.includes('gemini');
|
268 |
+
const isText = model.includes('text');
|
269 |
+
const stream = Boolean(request.body.stream) && isGemini;
|
270 |
+
|
271 |
+
const generationConfig = {
|
272 |
+
stopSequences: request.body.stop,
|
273 |
+
candidateCount: 1,
|
274 |
+
maxOutputTokens: request.body.max_tokens,
|
275 |
+
temperature: request.body.temperature,
|
276 |
+
topP: request.body.top_p,
|
277 |
+
topK: request.body.top_k || undefined,
|
278 |
+
};
|
279 |
+
|
280 |
+
function getGeminiBody() {
|
281 |
+
const should_use_system_prompt = (model.includes('gemini-1.5-flash') || model.includes('gemini-1.5-pro')) && request.body.use_makersuite_sysprompt;
|
282 |
+
const prompt = convertGooglePrompt(request.body.messages, model, should_use_system_prompt, request.body.char_name, request.body.user_name);
|
283 |
+
let body = {
|
284 |
+
contents: prompt.contents,
|
285 |
+
safetySettings: GEMINI_SAFETY,
|
286 |
+
generationConfig: generationConfig,
|
287 |
+
};
|
288 |
+
|
289 |
+
if (should_use_system_prompt) {
|
290 |
+
body.system_instruction = prompt.system_instruction;
|
291 |
+
}
|
292 |
+
|
293 |
+
return body;
|
294 |
+
}
|
295 |
+
|
296 |
+
function getBisonBody() {
|
297 |
+
const prompt = isText
|
298 |
+
? ({ text: convertTextCompletionPrompt(request.body.messages) })
|
299 |
+
: ({ messages: convertGooglePrompt(request.body.messages, model).contents });
|
300 |
+
|
301 |
+
/** @type {any} Shut the lint up */
|
302 |
+
const bisonBody = {
|
303 |
+
...generationConfig,
|
304 |
+
safetySettings: BISON_SAFETY,
|
305 |
+
candidate_count: 1, // lewgacy spelling
|
306 |
+
prompt: prompt,
|
307 |
+
};
|
308 |
+
|
309 |
+
if (!isText) {
|
310 |
+
delete bisonBody.stopSequences;
|
311 |
+
delete bisonBody.maxOutputTokens;
|
312 |
+
delete bisonBody.safetySettings;
|
313 |
+
|
314 |
+
if (Array.isArray(prompt.messages)) {
|
315 |
+
for (const msg of prompt.messages) {
|
316 |
+
msg.author = msg.role;
|
317 |
+
msg.content = msg.parts[0].text;
|
318 |
+
delete msg.parts;
|
319 |
+
delete msg.role;
|
320 |
+
}
|
321 |
+
}
|
322 |
+
}
|
323 |
+
|
324 |
+
delete bisonBody.candidateCount;
|
325 |
+
return bisonBody;
|
326 |
+
}
|
327 |
+
|
328 |
+
const body = isGemini ? getGeminiBody() : getBisonBody();
|
329 |
+
console.log('Google AI Studio request:', body);
|
330 |
+
|
331 |
+
try {
|
332 |
+
const controller = new AbortController();
|
333 |
+
request.socket.removeAllListeners('close');
|
334 |
+
request.socket.on('close', function () {
|
335 |
+
controller.abort();
|
336 |
+
});
|
337 |
+
|
338 |
+
const apiVersion = isGemini ? 'v1beta' : 'v1beta2';
|
339 |
+
const responseType = isGemini
|
340 |
+
? (stream ? 'streamGenerateContent' : 'generateContent')
|
341 |
+
: (isText ? 'generateText' : 'generateMessage');
|
342 |
+
|
343 |
+
const generateResponse = await fetch(`${apiUrl.origin}/${apiVersion}/models/${model}:${responseType}?key=${apiKey}${stream ? '&alt=sse' : ''}`, {
|
344 |
+
body: JSON.stringify(body),
|
345 |
+
method: 'POST',
|
346 |
+
headers: {
|
347 |
+
'Content-Type': 'application/json',
|
348 |
+
},
|
349 |
+
signal: controller.signal,
|
350 |
+
timeout: 0,
|
351 |
+
});
|
352 |
+
// have to do this because of their busted ass streaming endpoint
|
353 |
+
if (stream) {
|
354 |
+
try {
|
355 |
+
// Pipe remote SSE stream to Express response
|
356 |
+
forwardFetchResponse(generateResponse, response);
|
357 |
+
} catch (error) {
|
358 |
+
console.log('Error forwarding streaming response:', error);
|
359 |
+
if (!response.headersSent) {
|
360 |
+
return response.status(500).send({ error: true });
|
361 |
+
}
|
362 |
+
}
|
363 |
+
} else {
|
364 |
+
if (!generateResponse.ok) {
|
365 |
+
console.log(`Google AI Studio API returned error: ${generateResponse.status} ${generateResponse.statusText} ${await generateResponse.text()}`);
|
366 |
+
return response.status(generateResponse.status).send({ error: true });
|
367 |
+
}
|
368 |
+
|
369 |
+
const generateResponseJson = await generateResponse.json();
|
370 |
+
|
371 |
+
const candidates = generateResponseJson?.candidates;
|
372 |
+
if (!candidates || candidates.length === 0) {
|
373 |
+
let message = 'Google AI Studio API returned no candidate';
|
374 |
+
console.log(message, generateResponseJson);
|
375 |
+
if (generateResponseJson?.promptFeedback?.blockReason) {
|
376 |
+
message += `\nPrompt was blocked due to : ${generateResponseJson.promptFeedback.blockReason}`;
|
377 |
+
}
|
378 |
+
return response.send({ error: { message } });
|
379 |
+
}
|
380 |
+
|
381 |
+
const responseContent = candidates[0].content ?? candidates[0].output;
|
382 |
+
const responseText = typeof responseContent === 'string' ? responseContent : responseContent?.parts?.[0]?.text;
|
383 |
+
if (!responseText) {
|
384 |
+
let message = 'Google AI Studio Candidate text empty';
|
385 |
+
console.log(message, generateResponseJson);
|
386 |
+
return response.send({ error: { message } });
|
387 |
+
}
|
388 |
+
|
389 |
+
console.log('Google AI Studio response:', responseText);
|
390 |
+
|
391 |
+
// Wrap it back to OAI format
|
392 |
+
const reply = { choices: [{ 'message': { 'content': responseText } }] };
|
393 |
+
return response.send(reply);
|
394 |
+
}
|
395 |
+
} catch (error) {
|
396 |
+
console.log('Error communicating with Google AI Studio API: ', error);
|
397 |
+
if (!response.headersSent) {
|
398 |
+
return response.status(500).send({ error: true });
|
399 |
+
}
|
400 |
+
}
|
401 |
+
}
|
402 |
+
|
403 |
+
/**
|
404 |
+
* Sends a request to AI21 API.
|
405 |
+
* @param {express.Request} request Express request
|
406 |
+
* @param {express.Response} response Express response
|
407 |
+
*/
|
408 |
+
async function sendAI21Request(request, response) {
|
409 |
+
if (!request.body) return response.sendStatus(400);
|
410 |
+
const controller = new AbortController();
|
411 |
+
console.log(request.body.messages);
|
412 |
+
request.socket.removeAllListeners('close');
|
413 |
+
request.socket.on('close', function () {
|
414 |
+
controller.abort();
|
415 |
+
});
|
416 |
+
const options = {
|
417 |
+
method: 'POST',
|
418 |
+
headers: {
|
419 |
+
accept: 'application/json',
|
420 |
+
'content-type': 'application/json',
|
421 |
+
Authorization: `Bearer ${readSecret(request.user.directories, SECRET_KEYS.AI21)}`,
|
422 |
+
},
|
423 |
+
body: JSON.stringify({
|
424 |
+
numResults: 1,
|
425 |
+
maxTokens: request.body.max_tokens,
|
426 |
+
minTokens: 0,
|
427 |
+
temperature: request.body.temperature,
|
428 |
+
topP: request.body.top_p,
|
429 |
+
stopSequences: request.body.stop_tokens,
|
430 |
+
topKReturn: request.body.top_k,
|
431 |
+
frequencyPenalty: {
|
432 |
+
scale: request.body.frequency_penalty * 100,
|
433 |
+
applyToWhitespaces: false,
|
434 |
+
applyToPunctuations: false,
|
435 |
+
applyToNumbers: false,
|
436 |
+
applyToStopwords: false,
|
437 |
+
applyToEmojis: false,
|
438 |
+
},
|
439 |
+
presencePenalty: {
|
440 |
+
scale: request.body.presence_penalty,
|
441 |
+
applyToWhitespaces: false,
|
442 |
+
applyToPunctuations: false,
|
443 |
+
applyToNumbers: false,
|
444 |
+
applyToStopwords: false,
|
445 |
+
applyToEmojis: false,
|
446 |
+
},
|
447 |
+
countPenalty: {
|
448 |
+
scale: request.body.count_pen,
|
449 |
+
applyToWhitespaces: false,
|
450 |
+
applyToPunctuations: false,
|
451 |
+
applyToNumbers: false,
|
452 |
+
applyToStopwords: false,
|
453 |
+
applyToEmojis: false,
|
454 |
+
},
|
455 |
+
prompt: request.body.messages,
|
456 |
+
}),
|
457 |
+
signal: controller.signal,
|
458 |
+
};
|
459 |
+
|
460 |
+
fetch(`https://api.ai21.com/studio/v1/${request.body.model}/complete`, options)
|
461 |
+
.then(r => r.json())
|
462 |
+
.then(r => {
|
463 |
+
if (r.completions === undefined) {
|
464 |
+
console.log(r);
|
465 |
+
} else {
|
466 |
+
console.log(r.completions[0].data.text);
|
467 |
+
}
|
468 |
+
const reply = { choices: [{ 'message': { 'content': r.completions?.[0]?.data?.text } }] };
|
469 |
+
return response.send(reply);
|
470 |
+
})
|
471 |
+
.catch(err => {
|
472 |
+
console.error(err);
|
473 |
+
return response.send({ error: true });
|
474 |
+
});
|
475 |
+
|
476 |
+
}
|
477 |
+
|
478 |
+
/**
|
479 |
+
* Sends a request to MistralAI API.
|
480 |
+
* @param {express.Request} request Express request
|
481 |
+
* @param {express.Response} response Express response
|
482 |
+
*/
|
483 |
+
async function sendMistralAIRequest(request, response) {
|
484 |
+
const apiUrl = new URL(request.body.reverse_proxy || API_MISTRAL).toString();
|
485 |
+
const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MISTRALAI);
|
486 |
+
|
487 |
+
if (!apiKey) {
|
488 |
+
console.log('MistralAI API key is missing.');
|
489 |
+
return response.status(400).send({ error: true });
|
490 |
+
}
|
491 |
+
|
492 |
+
try {
|
493 |
+
const messages = convertMistralMessages(request.body.messages, request.body.char_name, request.body.user_name);
|
494 |
+
const controller = new AbortController();
|
495 |
+
request.socket.removeAllListeners('close');
|
496 |
+
request.socket.on('close', function () {
|
497 |
+
controller.abort();
|
498 |
+
});
|
499 |
+
|
500 |
+
const requestBody = {
|
501 |
+
'model': request.body.model,
|
502 |
+
'messages': messages,
|
503 |
+
'temperature': request.body.temperature,
|
504 |
+
'top_p': request.body.top_p,
|
505 |
+
'max_tokens': request.body.max_tokens,
|
506 |
+
'stream': request.body.stream,
|
507 |
+
'safe_prompt': request.body.safe_prompt,
|
508 |
+
'random_seed': request.body.seed === -1 ? undefined : request.body.seed,
|
509 |
+
};
|
510 |
+
|
511 |
+
if (Array.isArray(request.body.tools) && request.body.tools.length > 0) {
|
512 |
+
requestBody['tools'] = request.body.tools;
|
513 |
+
requestBody['tool_choice'] = request.body.tool_choice === 'required' ? 'any' : 'auto';
|
514 |
+
}
|
515 |
+
|
516 |
+
const config = {
|
517 |
+
method: 'POST',
|
518 |
+
headers: {
|
519 |
+
'Content-Type': 'application/json',
|
520 |
+
'Authorization': 'Bearer ' + apiKey,
|
521 |
+
},
|
522 |
+
body: JSON.stringify(requestBody),
|
523 |
+
signal: controller.signal,
|
524 |
+
timeout: 0,
|
525 |
+
};
|
526 |
+
|
527 |
+
console.log('MisralAI request:', requestBody);
|
528 |
+
|
529 |
+
const generateResponse = await fetch(apiUrl + '/chat/completions', config);
|
530 |
+
if (request.body.stream) {
|
531 |
+
forwardFetchResponse(generateResponse, response);
|
532 |
+
} else {
|
533 |
+
if (!generateResponse.ok) {
|
534 |
+
console.log(`MistralAI API returned error: ${generateResponse.status} ${generateResponse.statusText} ${await generateResponse.text()}`);
|
535 |
+
// a 401 unauthorized response breaks the frontend auth, so return a 500 instead. prob a better way of dealing with this.
|
536 |
+
// 401s are already handled by the streaming processor and dont pop up an error toast, that should probably be fixed too.
|
537 |
+
return response.status(generateResponse.status === 401 ? 500 : generateResponse.status).send({ error: true });
|
538 |
+
}
|
539 |
+
const generateResponseJson = await generateResponse.json();
|
540 |
+
console.log('MistralAI response:', generateResponseJson);
|
541 |
+
return response.send(generateResponseJson);
|
542 |
+
}
|
543 |
+
} catch (error) {
|
544 |
+
console.log('Error communicating with MistralAI API: ', error);
|
545 |
+
if (!response.headersSent) {
|
546 |
+
response.send({ error: true });
|
547 |
+
} else {
|
548 |
+
response.end();
|
549 |
+
}
|
550 |
+
}
|
551 |
+
}
|
552 |
+
|
553 |
+
/**
|
554 |
+
* Sends a request to Cohere API.
|
555 |
+
* @param {express.Request} request Express request
|
556 |
+
* @param {express.Response} response Express response
|
557 |
+
*/
|
558 |
+
async function sendCohereRequest(request, response) {
|
559 |
+
const apiKey = readSecret(request.user.directories, SECRET_KEYS.COHERE);
|
560 |
+
const controller = new AbortController();
|
561 |
+
request.socket.removeAllListeners('close');
|
562 |
+
request.socket.on('close', function () {
|
563 |
+
controller.abort();
|
564 |
+
});
|
565 |
+
|
566 |
+
if (!apiKey) {
|
567 |
+
console.log('Cohere API key is missing.');
|
568 |
+
return response.status(400).send({ error: true });
|
569 |
+
}
|
570 |
+
|
571 |
+
try {
|
572 |
+
const convertedHistory = convertCohereMessages(request.body.messages, request.body.char_name, request.body.user_name);
|
573 |
+
const connectors = [];
|
574 |
+
const tools = [];
|
575 |
+
|
576 |
+
if (request.body.websearch) {
|
577 |
+
connectors.push({
|
578 |
+
id: 'web-search',
|
579 |
+
});
|
580 |
+
}
|
581 |
+
|
582 |
+
if (Array.isArray(request.body.tools) && request.body.tools.length > 0) {
|
583 |
+
tools.push(...convertCohereTools(request.body.tools));
|
584 |
+
// Can't have both connectors and tools in the same request
|
585 |
+
connectors.splice(0, connectors.length);
|
586 |
+
}
|
587 |
+
|
588 |
+
// https://docs.cohere.com/reference/chat
|
589 |
+
const requestBody = {
|
590 |
+
stream: Boolean(request.body.stream),
|
591 |
+
model: request.body.model,
|
592 |
+
message: convertedHistory.userPrompt,
|
593 |
+
preamble: convertedHistory.systemPrompt,
|
594 |
+
chat_history: convertedHistory.chatHistory,
|
595 |
+
temperature: request.body.temperature,
|
596 |
+
max_tokens: request.body.max_tokens,
|
597 |
+
k: request.body.top_k,
|
598 |
+
p: request.body.top_p,
|
599 |
+
seed: request.body.seed,
|
600 |
+
stop_sequences: request.body.stop,
|
601 |
+
frequency_penalty: request.body.frequency_penalty,
|
602 |
+
presence_penalty: request.body.presence_penalty,
|
603 |
+
prompt_truncation: 'AUTO_PRESERVE_ORDER',
|
604 |
+
connectors: connectors,
|
605 |
+
documents: [],
|
606 |
+
tools: tools,
|
607 |
+
search_queries_only: false,
|
608 |
+
};
|
609 |
+
|
610 |
+
console.log('Cohere request:', requestBody);
|
611 |
+
|
612 |
+
const config = {
|
613 |
+
method: 'POST',
|
614 |
+
headers: {
|
615 |
+
'Content-Type': 'application/json',
|
616 |
+
'Authorization': 'Bearer ' + apiKey,
|
617 |
+
},
|
618 |
+
body: JSON.stringify(requestBody),
|
619 |
+
signal: controller.signal,
|
620 |
+
timeout: 0,
|
621 |
+
};
|
622 |
+
|
623 |
+
const apiUrl = API_COHERE + '/chat';
|
624 |
+
|
625 |
+
if (request.body.stream) {
|
626 |
+
const stream = await fetch(apiUrl, config);
|
627 |
+
parseCohereStream(stream, request, response);
|
628 |
+
} else {
|
629 |
+
const generateResponse = await fetch(apiUrl, config);
|
630 |
+
if (!generateResponse.ok) {
|
631 |
+
console.log(`Cohere API returned error: ${generateResponse.status} ${generateResponse.statusText} ${await generateResponse.text()}`);
|
632 |
+
// a 401 unauthorized response breaks the frontend auth, so return a 500 instead. prob a better way of dealing with this.
|
633 |
+
// 401s are already handled by the streaming processor and dont pop up an error toast, that should probably be fixed too.
|
634 |
+
return response.status(generateResponse.status === 401 ? 500 : generateResponse.status).send({ error: true });
|
635 |
+
}
|
636 |
+
const generateResponseJson = await generateResponse.json();
|
637 |
+
console.log('Cohere response:', generateResponseJson);
|
638 |
+
return response.send(generateResponseJson);
|
639 |
+
}
|
640 |
+
} catch (error) {
|
641 |
+
console.log('Error communicating with Cohere API: ', error);
|
642 |
+
if (!response.headersSent) {
|
643 |
+
response.send({ error: true });
|
644 |
+
} else {
|
645 |
+
response.end();
|
646 |
+
}
|
647 |
+
}
|
648 |
+
}
|
649 |
+
|
650 |
+
const router = express.Router();
|
651 |
+
|
652 |
+
router.post('/status', jsonParser, async function (request, response_getstatus_openai) {
|
653 |
+
if (!request.body) return response_getstatus_openai.sendStatus(400);
|
654 |
+
|
655 |
+
let api_url;
|
656 |
+
let api_key_openai;
|
657 |
+
let headers;
|
658 |
+
|
659 |
+
if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.OPENAI) {
|
660 |
+
api_url = new URL(request.body.reverse_proxy || API_OPENAI).toString();
|
661 |
+
api_key_openai = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.OPENAI);
|
662 |
+
headers = {};
|
663 |
+
} else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.OPENROUTER) {
|
664 |
+
api_url = 'https://openrouter.ai/api/v1';
|
665 |
+
api_key_openai = readSecret(request.user.directories, SECRET_KEYS.OPENROUTER);
|
666 |
+
// OpenRouter needs to pass the Referer and X-Title: https://openrouter.ai/docs#requests
|
667 |
+
headers = { ...OPENROUTER_HEADERS };
|
668 |
+
} else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.MISTRALAI) {
|
669 |
+
api_url = new URL(request.body.reverse_proxy || API_MISTRAL).toString();
|
670 |
+
api_key_openai = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MISTRALAI);
|
671 |
+
headers = {};
|
672 |
+
} else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.CUSTOM) {
|
673 |
+
api_url = request.body.custom_url;
|
674 |
+
api_key_openai = readSecret(request.user.directories, SECRET_KEYS.CUSTOM);
|
675 |
+
headers = {};
|
676 |
+
mergeObjectWithYaml(headers, request.body.custom_include_headers);
|
677 |
+
} else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.COHERE) {
|
678 |
+
api_url = API_COHERE;
|
679 |
+
api_key_openai = readSecret(request.user.directories, SECRET_KEYS.COHERE);
|
680 |
+
headers = {};
|
681 |
+
} else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.ZEROONEAI) {
|
682 |
+
api_url = API_01AI;
|
683 |
+
api_key_openai = readSecret(request.user.directories, SECRET_KEYS.ZEROONEAI);
|
684 |
+
headers = {};
|
685 |
+
} else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.BLOCKENTROPY) {
|
686 |
+
api_url = API_BLOCKENTROPY;
|
687 |
+
api_key_openai = readSecret(request.user.directories, SECRET_KEYS.BLOCKENTROPY);
|
688 |
+
headers = {};
|
689 |
+
} else {
|
690 |
+
console.log('This chat completion source is not supported yet.');
|
691 |
+
return response_getstatus_openai.status(400).send({ error: true });
|
692 |
+
}
|
693 |
+
|
694 |
+
if (!api_key_openai && !request.body.reverse_proxy && request.body.chat_completion_source !== CHAT_COMPLETION_SOURCES.CUSTOM) {
|
695 |
+
console.log('OpenAI API key is missing.');
|
696 |
+
return response_getstatus_openai.status(400).send({ error: true });
|
697 |
+
}
|
698 |
+
|
699 |
+
try {
|
700 |
+
const response = await fetch(api_url + '/models', {
|
701 |
+
method: 'GET',
|
702 |
+
headers: {
|
703 |
+
'Authorization': 'Bearer ' + api_key_openai,
|
704 |
+
...headers,
|
705 |
+
},
|
706 |
+
});
|
707 |
+
|
708 |
+
if (response.ok) {
|
709 |
+
const data = await response.json();
|
710 |
+
response_getstatus_openai.send(data);
|
711 |
+
|
712 |
+
if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.COHERE && Array.isArray(data?.models)) {
|
713 |
+
data.data = data.models.map(model => ({ id: model.name, ...model }));
|
714 |
+
}
|
715 |
+
|
716 |
+
if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.OPENROUTER && Array.isArray(data?.data)) {
|
717 |
+
let models = [];
|
718 |
+
|
719 |
+
data.data.forEach(model => {
|
720 |
+
const context_length = model.context_length;
|
721 |
+
const tokens_dollar = Number(1 / (1000 * model.pricing?.prompt));
|
722 |
+
const tokens_rounded = (Math.round(tokens_dollar * 1000) / 1000).toFixed(0);
|
723 |
+
models[model.id] = {
|
724 |
+
tokens_per_dollar: tokens_rounded + 'k',
|
725 |
+
context_length: context_length,
|
726 |
+
};
|
727 |
+
});
|
728 |
+
|
729 |
+
console.log('Available OpenRouter models:', models);
|
730 |
+
} else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.MISTRALAI) {
|
731 |
+
const models = data?.data;
|
732 |
+
console.log(models);
|
733 |
+
} else {
|
734 |
+
const models = data?.data;
|
735 |
+
|
736 |
+
if (Array.isArray(models)) {
|
737 |
+
const modelIds = models.filter(x => x && typeof x === 'object').map(x => x.id).sort();
|
738 |
+
console.log('Available OpenAI models:', modelIds);
|
739 |
+
} else {
|
740 |
+
console.log('OpenAI endpoint did not return a list of models.');
|
741 |
+
}
|
742 |
+
}
|
743 |
+
}
|
744 |
+
else {
|
745 |
+
console.log('OpenAI status check failed. Either Access Token is incorrect or API endpoint is down.');
|
746 |
+
response_getstatus_openai.send({ error: true, can_bypass: true, data: { data: [] } });
|
747 |
+
}
|
748 |
+
} catch (e) {
|
749 |
+
console.error(e);
|
750 |
+
|
751 |
+
if (!response_getstatus_openai.headersSent) {
|
752 |
+
response_getstatus_openai.send({ error: true });
|
753 |
+
} else {
|
754 |
+
response_getstatus_openai.end();
|
755 |
+
}
|
756 |
+
}
|
757 |
+
});
|
758 |
+
|
759 |
+
router.post('/bias', jsonParser, async function (request, response) {
|
760 |
+
if (!request.body || !Array.isArray(request.body))
|
761 |
+
return response.sendStatus(400);
|
762 |
+
|
763 |
+
try {
|
764 |
+
const result = {};
|
765 |
+
const model = getTokenizerModel(String(request.query.model || ''));
|
766 |
+
|
767 |
+
// no bias for claude
|
768 |
+
if (model == 'claude') {
|
769 |
+
return response.send(result);
|
770 |
+
}
|
771 |
+
|
772 |
+
let encodeFunction;
|
773 |
+
|
774 |
+
if (sentencepieceTokenizers.includes(model)) {
|
775 |
+
const tokenizer = getSentencepiceTokenizer(model);
|
776 |
+
const instance = await tokenizer?.get();
|
777 |
+
if (!instance) {
|
778 |
+
console.warn('Tokenizer not initialized:', model);
|
779 |
+
return response.send({});
|
780 |
+
}
|
781 |
+
encodeFunction = (text) => new Uint32Array(instance.encodeIds(text));
|
782 |
+
} else {
|
783 |
+
const tokenizer = getTiktokenTokenizer(model);
|
784 |
+
encodeFunction = (tokenizer.encode.bind(tokenizer));
|
785 |
+
}
|
786 |
+
|
787 |
+
for (const entry of request.body) {
|
788 |
+
if (!entry || !entry.text) {
|
789 |
+
continue;
|
790 |
+
}
|
791 |
+
|
792 |
+
try {
|
793 |
+
const tokens = getEntryTokens(entry.text, encodeFunction);
|
794 |
+
|
795 |
+
for (const token of tokens) {
|
796 |
+
result[token] = entry.value;
|
797 |
+
}
|
798 |
+
} catch {
|
799 |
+
console.warn('Tokenizer failed to encode:', entry.text);
|
800 |
+
}
|
801 |
+
}
|
802 |
+
|
803 |
+
// not needed for cached tokenizers
|
804 |
+
//tokenizer.free();
|
805 |
+
return response.send(result);
|
806 |
+
|
807 |
+
/**
|
808 |
+
* Gets tokenids for a given entry
|
809 |
+
* @param {string} text Entry text
|
810 |
+
* @param {(string) => Uint32Array} encode Function to encode text to token ids
|
811 |
+
* @returns {Uint32Array} Array of token ids
|
812 |
+
*/
|
813 |
+
function getEntryTokens(text, encode) {
|
814 |
+
// Get raw token ids from JSON array
|
815 |
+
if (text.trim().startsWith('[') && text.trim().endsWith(']')) {
|
816 |
+
try {
|
817 |
+
const json = JSON.parse(text);
|
818 |
+
if (Array.isArray(json) && json.every(x => typeof x === 'number')) {
|
819 |
+
return new Uint32Array(json);
|
820 |
+
}
|
821 |
+
} catch {
|
822 |
+
// ignore
|
823 |
+
}
|
824 |
+
}
|
825 |
+
|
826 |
+
// Otherwise, get token ids from tokenizer
|
827 |
+
return encode(text);
|
828 |
+
}
|
829 |
+
} catch (error) {
|
830 |
+
console.error(error);
|
831 |
+
return response.send({});
|
832 |
+
}
|
833 |
+
});
|
834 |
+
|
835 |
+
|
836 |
+
router.post('/generate', jsonParser, function (request, response) {
|
837 |
+
if (!request.body) return response.status(400).send({ error: true });
|
838 |
+
|
839 |
+
switch (request.body.chat_completion_source) {
|
840 |
+
case CHAT_COMPLETION_SOURCES.CLAUDE: return sendClaudeRequest(request, response);
|
841 |
+
case CHAT_COMPLETION_SOURCES.SCALE: return sendScaleRequest(request, response);
|
842 |
+
case CHAT_COMPLETION_SOURCES.AI21: return sendAI21Request(request, response);
|
843 |
+
case CHAT_COMPLETION_SOURCES.MAKERSUITE: return sendMakerSuiteRequest(request, response);
|
844 |
+
case CHAT_COMPLETION_SOURCES.MISTRALAI: return sendMistralAIRequest(request, response);
|
845 |
+
case CHAT_COMPLETION_SOURCES.COHERE: return sendCohereRequest(request, response);
|
846 |
+
}
|
847 |
+
|
848 |
+
let apiUrl;
|
849 |
+
let apiKey;
|
850 |
+
let headers;
|
851 |
+
let bodyParams;
|
852 |
+
const isTextCompletion = Boolean(request.body.model && TEXT_COMPLETION_MODELS.includes(request.body.model)) || typeof request.body.messages === 'string';
|
853 |
+
|
854 |
+
if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.OPENAI) {
|
855 |
+
apiUrl = new URL(request.body.reverse_proxy || API_OPENAI).toString();
|
856 |
+
apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.OPENAI);
|
857 |
+
headers = {};
|
858 |
+
bodyParams = {
|
859 |
+
logprobs: request.body.logprobs,
|
860 |
+
top_logprobs: undefined,
|
861 |
+
};
|
862 |
+
|
863 |
+
// Adjust logprobs params for Chat Completions API, which expects { top_logprobs: number; logprobs: boolean; }
|
864 |
+
if (!isTextCompletion && bodyParams.logprobs > 0) {
|
865 |
+
bodyParams.top_logprobs = bodyParams.logprobs;
|
866 |
+
bodyParams.logprobs = true;
|
867 |
+
}
|
868 |
+
|
869 |
+
if (getConfigValue('openai.randomizeUserId', false)) {
|
870 |
+
bodyParams['user'] = uuidv4();
|
871 |
+
}
|
872 |
+
} else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.OPENROUTER) {
|
873 |
+
apiUrl = 'https://openrouter.ai/api/v1';
|
874 |
+
apiKey = readSecret(request.user.directories, SECRET_KEYS.OPENROUTER);
|
875 |
+
// OpenRouter needs to pass the Referer and X-Title: https://openrouter.ai/docs#requests
|
876 |
+
headers = { ...OPENROUTER_HEADERS };
|
877 |
+
bodyParams = { 'transforms': ['middle-out'] };
|
878 |
+
|
879 |
+
if (request.body.min_p !== undefined) {
|
880 |
+
bodyParams['min_p'] = request.body.min_p;
|
881 |
+
}
|
882 |
+
|
883 |
+
if (request.body.top_a !== undefined) {
|
884 |
+
bodyParams['top_a'] = request.body.top_a;
|
885 |
+
}
|
886 |
+
|
887 |
+
if (request.body.repetition_penalty !== undefined) {
|
888 |
+
bodyParams['repetition_penalty'] = request.body.repetition_penalty;
|
889 |
+
}
|
890 |
+
|
891 |
+
if (Array.isArray(request.body.provider) && request.body.provider.length > 0) {
|
892 |
+
bodyParams['provider'] = {
|
893 |
+
allow_fallbacks: request.body.allow_fallbacks ?? true,
|
894 |
+
order: request.body.provider ?? [],
|
895 |
+
};
|
896 |
+
}
|
897 |
+
|
898 |
+
if (request.body.use_fallback) {
|
899 |
+
bodyParams['route'] = 'fallback';
|
900 |
+
}
|
901 |
+
} else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.CUSTOM) {
|
902 |
+
apiUrl = request.body.custom_url;
|
903 |
+
apiKey = readSecret(request.user.directories, SECRET_KEYS.CUSTOM);
|
904 |
+
headers = {};
|
905 |
+
bodyParams = {
|
906 |
+
logprobs: request.body.logprobs,
|
907 |
+
top_logprobs: undefined,
|
908 |
+
};
|
909 |
+
|
910 |
+
// Adjust logprobs params for Chat Completions API, which expects { top_logprobs: number; logprobs: boolean; }
|
911 |
+
if (!isTextCompletion && bodyParams.logprobs > 0) {
|
912 |
+
bodyParams.top_logprobs = bodyParams.logprobs;
|
913 |
+
bodyParams.logprobs = true;
|
914 |
+
}
|
915 |
+
|
916 |
+
mergeObjectWithYaml(bodyParams, request.body.custom_include_body);
|
917 |
+
mergeObjectWithYaml(headers, request.body.custom_include_headers);
|
918 |
+
|
919 |
+
if (request.body.custom_prompt_post_processing) {
|
920 |
+
console.log('Applying custom prompt post-processing of type', request.body.custom_prompt_post_processing);
|
921 |
+
request.body.messages = postProcessPrompt(
|
922 |
+
request.body.messages,
|
923 |
+
request.body.custom_prompt_post_processing,
|
924 |
+
request.body.char_name,
|
925 |
+
request.body.user_name);
|
926 |
+
}
|
927 |
+
} else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.PERPLEXITY) {
|
928 |
+
apiUrl = API_PERPLEXITY;
|
929 |
+
apiKey = readSecret(request.user.directories, SECRET_KEYS.PERPLEXITY);
|
930 |
+
headers = {};
|
931 |
+
bodyParams = {};
|
932 |
+
request.body.messages = postProcessPrompt(request.body.messages, 'claude', request.body.char_name, request.body.user_name);
|
933 |
+
} else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.GROQ) {
|
934 |
+
apiUrl = API_GROQ;
|
935 |
+
apiKey = readSecret(request.user.directories, SECRET_KEYS.GROQ);
|
936 |
+
headers = {};
|
937 |
+
bodyParams = {};
|
938 |
+
|
939 |
+
// 'required' tool choice is not supported by Groq
|
940 |
+
if (request.body.tool_choice === 'required') {
|
941 |
+
if (Array.isArray(request.body.tools) && request.body.tools.length > 0) {
|
942 |
+
request.body.tool_choice = request.body.tools.length > 1
|
943 |
+
? 'auto' :
|
944 |
+
{ type: 'function', function: { name: request.body.tools[0]?.function?.name } };
|
945 |
+
|
946 |
+
} else {
|
947 |
+
request.body.tool_choice = 'none';
|
948 |
+
}
|
949 |
+
}
|
950 |
+
} else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.ZEROONEAI) {
|
951 |
+
apiUrl = API_01AI;
|
952 |
+
apiKey = readSecret(request.user.directories, SECRET_KEYS.ZEROONEAI);
|
953 |
+
headers = {};
|
954 |
+
bodyParams = {};
|
955 |
+
} else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.BLOCKENTROPY) {
|
956 |
+
apiUrl = API_BLOCKENTROPY;
|
957 |
+
apiKey = readSecret(request.user.directories, SECRET_KEYS.BLOCKENTROPY);
|
958 |
+
headers = {};
|
959 |
+
bodyParams = {};
|
960 |
+
} else {
|
961 |
+
console.log('This chat completion source is not supported yet.');
|
962 |
+
return response.status(400).send({ error: true });
|
963 |
+
}
|
964 |
+
|
965 |
+
if (!apiKey && !request.body.reverse_proxy && request.body.chat_completion_source !== CHAT_COMPLETION_SOURCES.CUSTOM) {
|
966 |
+
console.log('OpenAI API key is missing.');
|
967 |
+
return response.status(400).send({ error: true });
|
968 |
+
}
|
969 |
+
|
970 |
+
// Add custom stop sequences
|
971 |
+
if (Array.isArray(request.body.stop) && request.body.stop.length > 0) {
|
972 |
+
bodyParams['stop'] = request.body.stop;
|
973 |
+
}
|
974 |
+
|
975 |
+
const textPrompt = isTextCompletion ? convertTextCompletionPrompt(request.body.messages) : '';
|
976 |
+
const endpointUrl = isTextCompletion && request.body.chat_completion_source !== CHAT_COMPLETION_SOURCES.OPENROUTER ?
|
977 |
+
`${apiUrl}/completions` :
|
978 |
+
`${apiUrl}/chat/completions`;
|
979 |
+
|
980 |
+
const controller = new AbortController();
|
981 |
+
request.socket.removeAllListeners('close');
|
982 |
+
request.socket.on('close', function () {
|
983 |
+
controller.abort();
|
984 |
+
});
|
985 |
+
|
986 |
+
if (!isTextCompletion) {
|
987 |
+
bodyParams['tools'] = request.body.tools;
|
988 |
+
bodyParams['tool_choice'] = request.body.tool_choice;
|
989 |
+
}
|
990 |
+
|
991 |
+
const requestBody = {
|
992 |
+
'messages': isTextCompletion === false ? request.body.messages : undefined,
|
993 |
+
'prompt': isTextCompletion === true ? textPrompt : undefined,
|
994 |
+
'model': request.body.model,
|
995 |
+
'temperature': request.body.temperature,
|
996 |
+
'max_tokens': request.body.max_tokens,
|
997 |
+
'stream': request.body.stream,
|
998 |
+
'presence_penalty': request.body.presence_penalty,
|
999 |
+
'frequency_penalty': request.body.frequency_penalty,
|
1000 |
+
'top_p': request.body.top_p,
|
1001 |
+
'top_k': request.body.top_k,
|
1002 |
+
'stop': isTextCompletion === false ? request.body.stop : undefined,
|
1003 |
+
'logit_bias': request.body.logit_bias,
|
1004 |
+
'seed': request.body.seed,
|
1005 |
+
'n': request.body.n,
|
1006 |
+
...bodyParams,
|
1007 |
+
};
|
1008 |
+
|
1009 |
+
if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.CUSTOM) {
|
1010 |
+
excludeKeysByYaml(requestBody, request.body.custom_exclude_body);
|
1011 |
+
}
|
1012 |
+
|
1013 |
+
/** @type {import('node-fetch').RequestInit} */
|
1014 |
+
const config = {
|
1015 |
+
method: 'post',
|
1016 |
+
headers: {
|
1017 |
+
'Content-Type': 'application/json',
|
1018 |
+
'Authorization': 'Bearer ' + apiKey,
|
1019 |
+
...headers,
|
1020 |
+
},
|
1021 |
+
body: JSON.stringify(requestBody),
|
1022 |
+
signal: controller.signal,
|
1023 |
+
timeout: 0,
|
1024 |
+
};
|
1025 |
+
|
1026 |
+
console.log(requestBody);
|
1027 |
+
|
1028 |
+
makeRequest(config, response, request);
|
1029 |
+
|
1030 |
+
/**
|
1031 |
+
* Makes a fetch request to the OpenAI API endpoint.
|
1032 |
+
* @param {import('node-fetch').RequestInit} config Fetch config
|
1033 |
+
* @param {express.Response} response Express response
|
1034 |
+
* @param {express.Request} request Express request
|
1035 |
+
* @param {Number} retries Number of retries left
|
1036 |
+
* @param {Number} timeout Request timeout in ms
|
1037 |
+
*/
|
1038 |
+
async function makeRequest(config, response, request, retries = 5, timeout = 5000) {
|
1039 |
+
try {
|
1040 |
+
const fetchResponse = await fetch(endpointUrl, config);
|
1041 |
+
|
1042 |
+
if (request.body.stream) {
|
1043 |
+
console.log('Streaming request in progress');
|
1044 |
+
forwardFetchResponse(fetchResponse, response);
|
1045 |
+
return;
|
1046 |
+
}
|
1047 |
+
|
1048 |
+
if (fetchResponse.ok) {
|
1049 |
+
let json = await fetchResponse.json();
|
1050 |
+
response.send(json);
|
1051 |
+
console.log(json);
|
1052 |
+
console.log(json?.choices?.[0]?.message);
|
1053 |
+
} else if (fetchResponse.status === 429 && retries > 0) {
|
1054 |
+
console.log(`Out of quota, retrying in ${Math.round(timeout / 1000)}s`);
|
1055 |
+
setTimeout(() => {
|
1056 |
+
timeout *= 2;
|
1057 |
+
makeRequest(config, response, request, retries - 1, timeout);
|
1058 |
+
}, timeout);
|
1059 |
+
} else {
|
1060 |
+
await handleErrorResponse(fetchResponse);
|
1061 |
+
}
|
1062 |
+
} catch (error) {
|
1063 |
+
console.log('Generation failed', error);
|
1064 |
+
if (!response.headersSent) {
|
1065 |
+
response.send({ error: true });
|
1066 |
+
} else {
|
1067 |
+
response.end();
|
1068 |
+
}
|
1069 |
+
}
|
1070 |
+
}
|
1071 |
+
|
1072 |
+
/**
|
1073 |
+
* @param {import("node-fetch").Response} errorResponse
|
1074 |
+
*/
|
1075 |
+
async function handleErrorResponse(errorResponse) {
|
1076 |
+
const responseText = await errorResponse.text();
|
1077 |
+
const errorData = tryParse(responseText);
|
1078 |
+
|
1079 |
+
const statusMessages = {
|
1080 |
+
400: 'Bad request',
|
1081 |
+
401: 'Unauthorized',
|
1082 |
+
402: 'Credit limit reached',
|
1083 |
+
403: 'Forbidden',
|
1084 |
+
404: 'Not found',
|
1085 |
+
429: 'Too many requests',
|
1086 |
+
451: 'Unavailable for legal reasons',
|
1087 |
+
502: 'Bad gateway',
|
1088 |
+
};
|
1089 |
+
|
1090 |
+
const message = errorData?.error?.message || statusMessages[errorResponse.status] || 'Unknown error occurred';
|
1091 |
+
const quota_error = errorResponse.status === 429 && errorData?.error?.type === 'insufficient_quota';
|
1092 |
+
console.log(message);
|
1093 |
+
|
1094 |
+
if (!response.headersSent) {
|
1095 |
+
response.send({ error: { message }, quota_error: quota_error });
|
1096 |
+
} else if (!response.writableEnded) {
|
1097 |
+
response.write(errorResponse);
|
1098 |
+
} else {
|
1099 |
+
response.end();
|
1100 |
+
}
|
1101 |
+
}
|
1102 |
+
});
|
1103 |
+
|
1104 |
+
module.exports = {
|
1105 |
+
router,
|
1106 |
+
};
|
src/endpoints/backends/kobold.js
ADDED
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const express = require('express');
|
2 |
+
const fetch = require('node-fetch').default;
|
3 |
+
const fs = require('fs');
|
4 |
+
|
5 |
+
const { jsonParser, urlencodedParser } = require('../../express-common');
|
6 |
+
const { forwardFetchResponse, delay } = require('../../util');
|
7 |
+
const { getOverrideHeaders, setAdditionalHeaders, setAdditionalHeadersByType } = require('../../additional-headers');
|
8 |
+
const { TEXTGEN_TYPES } = require('../../constants');
|
9 |
+
|
10 |
+
const router = express.Router();
|
11 |
+
|
12 |
+
router.post('/generate', jsonParser, async function (request, response_generate) {
|
13 |
+
if (!request.body) return response_generate.sendStatus(400);
|
14 |
+
|
15 |
+
if (request.body.api_server.indexOf('localhost') != -1) {
|
16 |
+
request.body.api_server = request.body.api_server.replace('localhost', '127.0.0.1');
|
17 |
+
}
|
18 |
+
|
19 |
+
const request_prompt = request.body.prompt;
|
20 |
+
const controller = new AbortController();
|
21 |
+
request.socket.removeAllListeners('close');
|
22 |
+
request.socket.on('close', async function () {
|
23 |
+
if (request.body.can_abort && !response_generate.writableEnded) {
|
24 |
+
try {
|
25 |
+
console.log('Aborting Kobold generation...');
|
26 |
+
// send abort signal to koboldcpp
|
27 |
+
const abortResponse = await fetch(`${request.body.api_server}/extra/abort`, {
|
28 |
+
method: 'POST',
|
29 |
+
});
|
30 |
+
|
31 |
+
if (!abortResponse.ok) {
|
32 |
+
console.log('Error sending abort request to Kobold:', abortResponse.status);
|
33 |
+
}
|
34 |
+
} catch (error) {
|
35 |
+
console.log(error);
|
36 |
+
}
|
37 |
+
}
|
38 |
+
controller.abort();
|
39 |
+
});
|
40 |
+
|
41 |
+
let this_settings = {
|
42 |
+
prompt: request_prompt,
|
43 |
+
use_story: false,
|
44 |
+
use_memory: false,
|
45 |
+
use_authors_note: false,
|
46 |
+
use_world_info: false,
|
47 |
+
max_context_length: request.body.max_context_length,
|
48 |
+
max_length: request.body.max_length,
|
49 |
+
};
|
50 |
+
|
51 |
+
if (!request.body.gui_settings) {
|
52 |
+
this_settings = {
|
53 |
+
prompt: request_prompt,
|
54 |
+
use_story: false,
|
55 |
+
use_memory: false,
|
56 |
+
use_authors_note: false,
|
57 |
+
use_world_info: false,
|
58 |
+
max_context_length: request.body.max_context_length,
|
59 |
+
max_length: request.body.max_length,
|
60 |
+
rep_pen: request.body.rep_pen,
|
61 |
+
rep_pen_range: request.body.rep_pen_range,
|
62 |
+
rep_pen_slope: request.body.rep_pen_slope,
|
63 |
+
temperature: request.body.temperature,
|
64 |
+
tfs: request.body.tfs,
|
65 |
+
top_a: request.body.top_a,
|
66 |
+
top_k: request.body.top_k,
|
67 |
+
top_p: request.body.top_p,
|
68 |
+
min_p: request.body.min_p,
|
69 |
+
typical: request.body.typical,
|
70 |
+
sampler_order: request.body.sampler_order,
|
71 |
+
singleline: !!request.body.singleline,
|
72 |
+
use_default_badwordsids: request.body.use_default_badwordsids,
|
73 |
+
mirostat: request.body.mirostat,
|
74 |
+
mirostat_eta: request.body.mirostat_eta,
|
75 |
+
mirostat_tau: request.body.mirostat_tau,
|
76 |
+
grammar: request.body.grammar,
|
77 |
+
sampler_seed: request.body.sampler_seed,
|
78 |
+
};
|
79 |
+
if (request.body.stop_sequence) {
|
80 |
+
this_settings['stop_sequence'] = request.body.stop_sequence;
|
81 |
+
}
|
82 |
+
}
|
83 |
+
|
84 |
+
console.log(this_settings);
|
85 |
+
const args = {
|
86 |
+
body: JSON.stringify(this_settings),
|
87 |
+
headers: Object.assign(
|
88 |
+
{ 'Content-Type': 'application/json' },
|
89 |
+
getOverrideHeaders((new URL(request.body.api_server))?.host),
|
90 |
+
),
|
91 |
+
signal: controller.signal,
|
92 |
+
};
|
93 |
+
|
94 |
+
const MAX_RETRIES = 50;
|
95 |
+
const delayAmount = 2500;
|
96 |
+
for (let i = 0; i < MAX_RETRIES; i++) {
|
97 |
+
try {
|
98 |
+
const url = request.body.streaming ? `${request.body.api_server}/extra/generate/stream` : `${request.body.api_server}/v1/generate`;
|
99 |
+
const response = await fetch(url, { method: 'POST', timeout: 0, ...args });
|
100 |
+
|
101 |
+
if (request.body.streaming) {
|
102 |
+
// Pipe remote SSE stream to Express response
|
103 |
+
forwardFetchResponse(response, response_generate);
|
104 |
+
return;
|
105 |
+
} else {
|
106 |
+
if (!response.ok) {
|
107 |
+
const errorText = await response.text();
|
108 |
+
console.log(`Kobold returned error: ${response.status} ${response.statusText} ${errorText}`);
|
109 |
+
|
110 |
+
try {
|
111 |
+
const errorJson = JSON.parse(errorText);
|
112 |
+
const message = errorJson?.detail?.msg || errorText;
|
113 |
+
return response_generate.status(400).send({ error: { message } });
|
114 |
+
} catch {
|
115 |
+
return response_generate.status(400).send({ error: { message: errorText } });
|
116 |
+
}
|
117 |
+
}
|
118 |
+
|
119 |
+
const data = await response.json();
|
120 |
+
console.log('Endpoint response:', data);
|
121 |
+
return response_generate.send(data);
|
122 |
+
}
|
123 |
+
} catch (error) {
|
124 |
+
// response
|
125 |
+
switch (error?.status) {
|
126 |
+
case 403:
|
127 |
+
case 503: // retry in case of temporary service issue, possibly caused by a queue failure?
|
128 |
+
console.debug(`KoboldAI is busy. Retry attempt ${i + 1} of ${MAX_RETRIES}...`);
|
129 |
+
await delay(delayAmount);
|
130 |
+
break;
|
131 |
+
default:
|
132 |
+
if ('status' in error) {
|
133 |
+
console.log('Status Code from Kobold:', error.status);
|
134 |
+
}
|
135 |
+
return response_generate.send({ error: true });
|
136 |
+
}
|
137 |
+
}
|
138 |
+
}
|
139 |
+
|
140 |
+
console.log('Max retries exceeded. Giving up.');
|
141 |
+
return response_generate.send({ error: true });
|
142 |
+
});
|
143 |
+
|
144 |
+
router.post('/status', jsonParser, async function (request, response) {
|
145 |
+
if (!request.body) return response.sendStatus(400);
|
146 |
+
let api_server = request.body.api_server;
|
147 |
+
if (api_server.indexOf('localhost') != -1) {
|
148 |
+
api_server = api_server.replace('localhost', '127.0.0.1');
|
149 |
+
}
|
150 |
+
|
151 |
+
const args = {
|
152 |
+
headers: { 'Content-Type': 'application/json' },
|
153 |
+
};
|
154 |
+
|
155 |
+
setAdditionalHeaders(request, args, api_server);
|
156 |
+
|
157 |
+
const result = {};
|
158 |
+
|
159 |
+
const [koboldUnitedResponse, koboldExtraResponse, koboldModelResponse] = await Promise.all([
|
160 |
+
// We catch errors both from the response not having a successful HTTP status and from JSON parsing failing
|
161 |
+
|
162 |
+
// Kobold United API version
|
163 |
+
fetch(`${api_server}/v1/info/version`).then(response => {
|
164 |
+
if (!response.ok) throw new Error(`Kobold API error: ${response.status, response.statusText}`);
|
165 |
+
return response.json();
|
166 |
+
}).catch(() => ({ result: '0.0.0' })),
|
167 |
+
|
168 |
+
// KoboldCpp version
|
169 |
+
fetch(`${api_server}/extra/version`).then(response => {
|
170 |
+
if (!response.ok) throw new Error(`Kobold API error: ${response.status, response.statusText}`);
|
171 |
+
return response.json();
|
172 |
+
}).catch(() => ({ version: '0.0' })),
|
173 |
+
|
174 |
+
// Current model
|
175 |
+
fetch(`${api_server}/v1/model`).then(response => {
|
176 |
+
if (!response.ok) throw new Error(`Kobold API error: ${response.status, response.statusText}`);
|
177 |
+
return response.json();
|
178 |
+
}).catch(() => null),
|
179 |
+
]);
|
180 |
+
|
181 |
+
result.koboldUnitedVersion = koboldUnitedResponse.result;
|
182 |
+
result.koboldCppVersion = koboldExtraResponse.result;
|
183 |
+
result.model = !koboldModelResponse || koboldModelResponse.result === 'ReadOnly' ?
|
184 |
+
'no_connection' :
|
185 |
+
koboldModelResponse.result;
|
186 |
+
|
187 |
+
response.send(result);
|
188 |
+
});
|
189 |
+
|
190 |
+
router.post('/transcribe-audio', urlencodedParser, async function (request, response) {
|
191 |
+
try {
|
192 |
+
const server = request.body.server;
|
193 |
+
|
194 |
+
if (!server) {
|
195 |
+
console.log('Server is not set');
|
196 |
+
return response.sendStatus(400);
|
197 |
+
}
|
198 |
+
|
199 |
+
if (!request.file) {
|
200 |
+
console.log('No audio file found');
|
201 |
+
return response.sendStatus(400);
|
202 |
+
}
|
203 |
+
|
204 |
+
console.log('Transcribing audio with KoboldCpp', server);
|
205 |
+
|
206 |
+
const fileBase64 = fs.readFileSync(request.file.path).toString('base64');
|
207 |
+
fs.rmSync(request.file.path);
|
208 |
+
|
209 |
+
const headers = {};
|
210 |
+
setAdditionalHeadersByType(headers, TEXTGEN_TYPES.KOBOLDCPP, server, request.user.directories);
|
211 |
+
|
212 |
+
const url = new URL(server);
|
213 |
+
url.pathname = '/api/extra/transcribe';
|
214 |
+
|
215 |
+
const result = await fetch(url, {
|
216 |
+
method: 'POST',
|
217 |
+
headers: {
|
218 |
+
...headers,
|
219 |
+
},
|
220 |
+
body: JSON.stringify({
|
221 |
+
prompt: '',
|
222 |
+
audio_data: fileBase64,
|
223 |
+
}),
|
224 |
+
});
|
225 |
+
|
226 |
+
if (!result.ok) {
|
227 |
+
const text = await result.text();
|
228 |
+
console.log('KoboldCpp request failed', result.statusText, text);
|
229 |
+
return response.status(500).send(text);
|
230 |
+
}
|
231 |
+
|
232 |
+
const data = await result.json();
|
233 |
+
console.log('KoboldCpp transcription response', data);
|
234 |
+
return response.json(data);
|
235 |
+
} catch (error) {
|
236 |
+
console.error('KoboldCpp transcription failed', error);
|
237 |
+
response.status(500).send('Internal server error');
|
238 |
+
}
|
239 |
+
});
|
240 |
+
|
241 |
+
module.exports = { router };
|
src/endpoints/backends/scale-alt.js
ADDED
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const express = require('express');
|
2 |
+
const fetch = require('node-fetch').default;
|
3 |
+
|
4 |
+
const { jsonParser } = require('../../express-common');
|
5 |
+
|
6 |
+
const { readSecret, SECRET_KEYS } = require('../secrets');
|
7 |
+
|
8 |
+
const router = express.Router();
|
9 |
+
|
10 |
+
router.post('/generate', jsonParser, async function (request, response) {
|
11 |
+
if (!request.body) return response.sendStatus(400);
|
12 |
+
|
13 |
+
try {
|
14 |
+
const cookie = readSecret(request.user.directories, SECRET_KEYS.SCALE_COOKIE);
|
15 |
+
|
16 |
+
if (!cookie) {
|
17 |
+
console.log('No Scale cookie found');
|
18 |
+
return response.sendStatus(400);
|
19 |
+
}
|
20 |
+
|
21 |
+
const body = {
|
22 |
+
json: {
|
23 |
+
variant: {
|
24 |
+
name: 'New Variant',
|
25 |
+
appId: '',
|
26 |
+
taxonomy: null,
|
27 |
+
},
|
28 |
+
prompt: {
|
29 |
+
id: '',
|
30 |
+
template: '{{input}}\n',
|
31 |
+
exampleVariables: {},
|
32 |
+
variablesSourceDataId: null,
|
33 |
+
systemMessage: request.body.sysprompt,
|
34 |
+
},
|
35 |
+
modelParameters: {
|
36 |
+
id: '',
|
37 |
+
modelId: 'GPT4',
|
38 |
+
modelType: 'OpenAi',
|
39 |
+
maxTokens: request.body.max_tokens,
|
40 |
+
temperature: request.body.temp,
|
41 |
+
stop: 'user:',
|
42 |
+
suffix: null,
|
43 |
+
topP: request.body.top_p,
|
44 |
+
logprobs: null,
|
45 |
+
logitBias: request.body.logit_bias,
|
46 |
+
},
|
47 |
+
inputs: [
|
48 |
+
{
|
49 |
+
index: '-1',
|
50 |
+
valueByName: {
|
51 |
+
input: request.body.prompt,
|
52 |
+
},
|
53 |
+
},
|
54 |
+
],
|
55 |
+
},
|
56 |
+
meta: {
|
57 |
+
values: {
|
58 |
+
'variant.taxonomy': ['undefined'],
|
59 |
+
'prompt.variablesSourceDataId': ['undefined'],
|
60 |
+
'modelParameters.suffix': ['undefined'],
|
61 |
+
'modelParameters.logprobs': ['undefined'],
|
62 |
+
},
|
63 |
+
},
|
64 |
+
};
|
65 |
+
|
66 |
+
console.log('Scale request:', body);
|
67 |
+
|
68 |
+
const result = await fetch('https://dashboard.scale.com/spellbook/api/trpc/v2.variant.run', {
|
69 |
+
method: 'POST',
|
70 |
+
headers: {
|
71 |
+
'Content-Type': 'application/json',
|
72 |
+
'cookie': `_jwt=${cookie}`,
|
73 |
+
},
|
74 |
+
timeout: 0,
|
75 |
+
body: JSON.stringify(body),
|
76 |
+
});
|
77 |
+
|
78 |
+
if (!result.ok) {
|
79 |
+
const text = await result.text();
|
80 |
+
console.log('Scale request failed', result.statusText, text);
|
81 |
+
return response.status(500).send({ error: { message: result.statusText } });
|
82 |
+
}
|
83 |
+
|
84 |
+
const data = await result.json();
|
85 |
+
const output = data?.result?.data?.json?.outputs?.[0] || '';
|
86 |
+
|
87 |
+
console.log('Scale response:', data);
|
88 |
+
|
89 |
+
if (!output) {
|
90 |
+
console.warn('Scale response is empty');
|
91 |
+
return response.sendStatus(500).send({ error: { message: 'Empty response' } });
|
92 |
+
}
|
93 |
+
|
94 |
+
return response.json({ output });
|
95 |
+
} catch (error) {
|
96 |
+
console.log(error);
|
97 |
+
return response.sendStatus(500);
|
98 |
+
}
|
99 |
+
});
|
100 |
+
|
101 |
+
module.exports = { router };
|
src/endpoints/backends/text-completions.js
ADDED
@@ -0,0 +1,641 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const express = require('express');
|
2 |
+
const fetch = require('node-fetch').default;
|
3 |
+
const _ = require('lodash');
|
4 |
+
const Readable = require('stream').Readable;
|
5 |
+
|
6 |
+
const { jsonParser } = require('../../express-common');
|
7 |
+
const { TEXTGEN_TYPES, TOGETHERAI_KEYS, OLLAMA_KEYS, INFERMATICAI_KEYS, OPENROUTER_KEYS, VLLM_KEYS, DREAMGEN_KEYS, FEATHERLESS_KEYS } = require('../../constants');
|
8 |
+
const { forwardFetchResponse, trimV1, getConfigValue } = require('../../util');
|
9 |
+
const { setAdditionalHeaders } = require('../../additional-headers');
|
10 |
+
|
11 |
+
const router = express.Router();
|
12 |
+
|
13 |
+
/**
|
14 |
+
* Special boy's steaming routine. Wrap this abomination into proper SSE stream.
|
15 |
+
* @param {import('node-fetch').Response} jsonStream JSON stream
|
16 |
+
* @param {import('express').Request} request Express request
|
17 |
+
* @param {import('express').Response} response Express response
|
18 |
+
* @returns {Promise<any>} Nothing valuable
|
19 |
+
*/
|
20 |
+
async function parseOllamaStream(jsonStream, request, response) {
|
21 |
+
try {
|
22 |
+
let partialData = '';
|
23 |
+
jsonStream.body.on('data', (data) => {
|
24 |
+
const chunk = data.toString();
|
25 |
+
partialData += chunk;
|
26 |
+
while (true) {
|
27 |
+
let json;
|
28 |
+
try {
|
29 |
+
json = JSON.parse(partialData);
|
30 |
+
} catch (e) {
|
31 |
+
break;
|
32 |
+
}
|
33 |
+
const text = json.response || '';
|
34 |
+
const chunk = { choices: [{ text }] };
|
35 |
+
response.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
36 |
+
partialData = '';
|
37 |
+
}
|
38 |
+
});
|
39 |
+
|
40 |
+
request.socket.on('close', function () {
|
41 |
+
if (jsonStream.body instanceof Readable) jsonStream.body.destroy();
|
42 |
+
response.end();
|
43 |
+
});
|
44 |
+
|
45 |
+
jsonStream.body.on('end', () => {
|
46 |
+
console.log('Streaming request finished');
|
47 |
+
response.write('data: [DONE]\n\n');
|
48 |
+
response.end();
|
49 |
+
});
|
50 |
+
} catch (error) {
|
51 |
+
console.log('Error forwarding streaming response:', error);
|
52 |
+
if (!response.headersSent) {
|
53 |
+
return response.status(500).send({ error: true });
|
54 |
+
} else {
|
55 |
+
return response.end();
|
56 |
+
}
|
57 |
+
}
|
58 |
+
}
|
59 |
+
|
60 |
+
/**
|
61 |
+
* Abort KoboldCpp generation request.
|
62 |
+
* @param {string} url Server base URL
|
63 |
+
* @returns {Promise<void>} Promise resolving when we are done
|
64 |
+
*/
|
65 |
+
async function abortKoboldCppRequest(url) {
|
66 |
+
try {
|
67 |
+
console.log('Aborting Kobold generation...');
|
68 |
+
const abortResponse = await fetch(`${url}/api/extra/abort`, {
|
69 |
+
method: 'POST',
|
70 |
+
});
|
71 |
+
|
72 |
+
if (!abortResponse.ok) {
|
73 |
+
console.log('Error sending abort request to Kobold:', abortResponse.status, abortResponse.statusText);
|
74 |
+
}
|
75 |
+
} catch (error) {
|
76 |
+
console.log(error);
|
77 |
+
}
|
78 |
+
}
|
79 |
+
|
80 |
+
//************** Ooba/OpenAI text completions API
|
81 |
+
router.post('/status', jsonParser, async function (request, response) {
|
82 |
+
if (!request.body) return response.sendStatus(400);
|
83 |
+
|
84 |
+
try {
|
85 |
+
if (request.body.api_server.indexOf('localhost') !== -1) {
|
86 |
+
request.body.api_server = request.body.api_server.replace('localhost', '127.0.0.1');
|
87 |
+
}
|
88 |
+
|
89 |
+
console.log('Trying to connect to API:', request.body);
|
90 |
+
const baseUrl = trimV1(request.body.api_server);
|
91 |
+
|
92 |
+
const args = {
|
93 |
+
headers: { 'Content-Type': 'application/json' },
|
94 |
+
};
|
95 |
+
|
96 |
+
setAdditionalHeaders(request, args, baseUrl);
|
97 |
+
|
98 |
+
const apiType = request.body.api_type;
|
99 |
+
let url = baseUrl;
|
100 |
+
let result = '';
|
101 |
+
|
102 |
+
if (request.body.legacy_api) {
|
103 |
+
url += '/v1/model';
|
104 |
+
} else {
|
105 |
+
switch (apiType) {
|
106 |
+
case TEXTGEN_TYPES.OOBA:
|
107 |
+
case TEXTGEN_TYPES.VLLM:
|
108 |
+
case TEXTGEN_TYPES.APHRODITE:
|
109 |
+
case TEXTGEN_TYPES.KOBOLDCPP:
|
110 |
+
case TEXTGEN_TYPES.LLAMACPP:
|
111 |
+
case TEXTGEN_TYPES.INFERMATICAI:
|
112 |
+
case TEXTGEN_TYPES.OPENROUTER:
|
113 |
+
url += '/v1/models';
|
114 |
+
break;
|
115 |
+
case TEXTGEN_TYPES.DREAMGEN:
|
116 |
+
url += '/api/openai/v1/models';
|
117 |
+
break;
|
118 |
+
case TEXTGEN_TYPES.MANCER:
|
119 |
+
url += '/oai/v1/models';
|
120 |
+
break;
|
121 |
+
case TEXTGEN_TYPES.TABBY:
|
122 |
+
url += '/v1/model/list';
|
123 |
+
break;
|
124 |
+
case TEXTGEN_TYPES.TOGETHERAI:
|
125 |
+
url += '/api/models?&info';
|
126 |
+
break;
|
127 |
+
case TEXTGEN_TYPES.OLLAMA:
|
128 |
+
url += '/api/tags';
|
129 |
+
break;
|
130 |
+
case TEXTGEN_TYPES.FEATHERLESS:
|
131 |
+
url += '/v1/models';
|
132 |
+
break;
|
133 |
+
case TEXTGEN_TYPES.HUGGINGFACE:
|
134 |
+
url += '/info';
|
135 |
+
break;
|
136 |
+
}
|
137 |
+
}
|
138 |
+
|
139 |
+
const modelsReply = await fetch(url, args);
|
140 |
+
|
141 |
+
if (!modelsReply.ok) {
|
142 |
+
console.log('Models endpoint is offline.');
|
143 |
+
return response.status(400);
|
144 |
+
}
|
145 |
+
|
146 |
+
let data = await modelsReply.json();
|
147 |
+
|
148 |
+
if (request.body.legacy_api) {
|
149 |
+
console.log('Legacy API response:', data);
|
150 |
+
return response.send({ result: data?.result });
|
151 |
+
}
|
152 |
+
|
153 |
+
// Rewrap to OAI-like response
|
154 |
+
if (apiType === TEXTGEN_TYPES.TOGETHERAI && Array.isArray(data)) {
|
155 |
+
data = { data: data.map(x => ({ id: x.name, ...x })) };
|
156 |
+
}
|
157 |
+
|
158 |
+
if (apiType === TEXTGEN_TYPES.OLLAMA && Array.isArray(data.models)) {
|
159 |
+
data = { data: data.models.map(x => ({ id: x.name, ...x })) };
|
160 |
+
}
|
161 |
+
|
162 |
+
if (apiType === TEXTGEN_TYPES.HUGGINGFACE) {
|
163 |
+
data = { data: [] };
|
164 |
+
}
|
165 |
+
|
166 |
+
if (!Array.isArray(data.data)) {
|
167 |
+
console.log('Models response is not an array.');
|
168 |
+
return response.status(400);
|
169 |
+
}
|
170 |
+
|
171 |
+
const modelIds = data.data.map(x => x.id);
|
172 |
+
console.log('Models available:', modelIds);
|
173 |
+
|
174 |
+
// Set result to the first model ID
|
175 |
+
result = modelIds[0] || 'Valid';
|
176 |
+
|
177 |
+
if (apiType === TEXTGEN_TYPES.OOBA) {
|
178 |
+
try {
|
179 |
+
const modelInfoUrl = baseUrl + '/v1/internal/model/info';
|
180 |
+
const modelInfoReply = await fetch(modelInfoUrl, args);
|
181 |
+
|
182 |
+
if (modelInfoReply.ok) {
|
183 |
+
const modelInfo = await modelInfoReply.json();
|
184 |
+
console.log('Ooba model info:', modelInfo);
|
185 |
+
|
186 |
+
const modelName = modelInfo?.model_name;
|
187 |
+
result = modelName || result;
|
188 |
+
}
|
189 |
+
} catch (error) {
|
190 |
+
console.error(`Failed to get Ooba model info: ${error}`);
|
191 |
+
}
|
192 |
+
} else if (apiType === TEXTGEN_TYPES.TABBY) {
|
193 |
+
try {
|
194 |
+
const modelInfoUrl = baseUrl + '/v1/model';
|
195 |
+
const modelInfoReply = await fetch(modelInfoUrl, args);
|
196 |
+
|
197 |
+
if (modelInfoReply.ok) {
|
198 |
+
const modelInfo = await modelInfoReply.json();
|
199 |
+
console.log('Tabby model info:', modelInfo);
|
200 |
+
|
201 |
+
const modelName = modelInfo?.id;
|
202 |
+
result = modelName || result;
|
203 |
+
} else {
|
204 |
+
// TabbyAPI returns an error 400 if a model isn't loaded
|
205 |
+
|
206 |
+
result = 'None';
|
207 |
+
}
|
208 |
+
} catch (error) {
|
209 |
+
console.error(`Failed to get TabbyAPI model info: ${error}`);
|
210 |
+
}
|
211 |
+
}
|
212 |
+
|
213 |
+
return response.send({ result, data: data.data });
|
214 |
+
} catch (error) {
|
215 |
+
console.error(error);
|
216 |
+
return response.status(500);
|
217 |
+
}
|
218 |
+
});
|
219 |
+
|
220 |
+
router.post('/generate', jsonParser, async function (request, response) {
|
221 |
+
if (!request.body) return response.sendStatus(400);
|
222 |
+
|
223 |
+
try {
|
224 |
+
if (request.body.api_server.indexOf('localhost') !== -1) {
|
225 |
+
request.body.api_server = request.body.api_server.replace('localhost', '127.0.0.1');
|
226 |
+
}
|
227 |
+
|
228 |
+
const apiType = request.body.api_type;
|
229 |
+
const baseUrl = request.body.api_server;
|
230 |
+
console.log(request.body);
|
231 |
+
|
232 |
+
const controller = new AbortController();
|
233 |
+
request.socket.removeAllListeners('close');
|
234 |
+
request.socket.on('close', async function () {
|
235 |
+
if (request.body.api_type === TEXTGEN_TYPES.KOBOLDCPP && !response.writableEnded) {
|
236 |
+
await abortKoboldCppRequest(trimV1(baseUrl));
|
237 |
+
}
|
238 |
+
|
239 |
+
controller.abort();
|
240 |
+
});
|
241 |
+
|
242 |
+
let url = trimV1(baseUrl);
|
243 |
+
|
244 |
+
if (request.body.legacy_api) {
|
245 |
+
url += '/v1/generate';
|
246 |
+
} else {
|
247 |
+
switch (request.body.api_type) {
|
248 |
+
case TEXTGEN_TYPES.VLLM:
|
249 |
+
case TEXTGEN_TYPES.FEATHERLESS:
|
250 |
+
case TEXTGEN_TYPES.APHRODITE:
|
251 |
+
case TEXTGEN_TYPES.OOBA:
|
252 |
+
case TEXTGEN_TYPES.TABBY:
|
253 |
+
case TEXTGEN_TYPES.KOBOLDCPP:
|
254 |
+
case TEXTGEN_TYPES.TOGETHERAI:
|
255 |
+
case TEXTGEN_TYPES.INFERMATICAI:
|
256 |
+
case TEXTGEN_TYPES.HUGGINGFACE:
|
257 |
+
url += '/v1/completions';
|
258 |
+
break;
|
259 |
+
case TEXTGEN_TYPES.DREAMGEN:
|
260 |
+
url += '/api/openai/v1/completions';
|
261 |
+
break;
|
262 |
+
case TEXTGEN_TYPES.MANCER:
|
263 |
+
url += '/oai/v1/completions';
|
264 |
+
break;
|
265 |
+
case TEXTGEN_TYPES.LLAMACPP:
|
266 |
+
url += '/completion';
|
267 |
+
break;
|
268 |
+
case TEXTGEN_TYPES.OLLAMA:
|
269 |
+
url += '/api/generate';
|
270 |
+
break;
|
271 |
+
case TEXTGEN_TYPES.OPENROUTER:
|
272 |
+
url += '/v1/chat/completions';
|
273 |
+
break;
|
274 |
+
}
|
275 |
+
}
|
276 |
+
|
277 |
+
const args = {
|
278 |
+
method: 'POST',
|
279 |
+
body: JSON.stringify(request.body),
|
280 |
+
headers: { 'Content-Type': 'application/json' },
|
281 |
+
signal: controller.signal,
|
282 |
+
timeout: 0,
|
283 |
+
};
|
284 |
+
|
285 |
+
setAdditionalHeaders(request, args, baseUrl);
|
286 |
+
|
287 |
+
if (request.body.api_type === TEXTGEN_TYPES.TOGETHERAI) {
|
288 |
+
request.body = _.pickBy(request.body, (_, key) => TOGETHERAI_KEYS.includes(key));
|
289 |
+
args.body = JSON.stringify(request.body);
|
290 |
+
}
|
291 |
+
|
292 |
+
if (request.body.api_type === TEXTGEN_TYPES.INFERMATICAI) {
|
293 |
+
request.body = _.pickBy(request.body, (_, key) => INFERMATICAI_KEYS.includes(key));
|
294 |
+
args.body = JSON.stringify(request.body);
|
295 |
+
}
|
296 |
+
|
297 |
+
if (request.body.api_type === TEXTGEN_TYPES.FEATHERLESS) {
|
298 |
+
request.body = _.pickBy(request.body, (_, key) => FEATHERLESS_KEYS.includes(key));
|
299 |
+
args.body = JSON.stringify(request.body);
|
300 |
+
}
|
301 |
+
|
302 |
+
if (request.body.api_type === TEXTGEN_TYPES.DREAMGEN) {
|
303 |
+
request.body = _.pickBy(request.body, (_, key) => DREAMGEN_KEYS.includes(key));
|
304 |
+
// NOTE: DreamGen sometimes get confused by the unusual formatting in the character cards.
|
305 |
+
request.body.stop?.push('### User', '## User');
|
306 |
+
args.body = JSON.stringify(request.body);
|
307 |
+
}
|
308 |
+
|
309 |
+
if (request.body.api_type === TEXTGEN_TYPES.OPENROUTER) {
|
310 |
+
if (Array.isArray(request.body.provider) && request.body.provider.length > 0) {
|
311 |
+
request.body.provider = {
|
312 |
+
allow_fallbacks: request.body.allow_fallbacks ?? true,
|
313 |
+
order: request.body.provider,
|
314 |
+
};
|
315 |
+
} else {
|
316 |
+
delete request.body.provider;
|
317 |
+
}
|
318 |
+
request.body = _.pickBy(request.body, (_, key) => OPENROUTER_KEYS.includes(key));
|
319 |
+
args.body = JSON.stringify(request.body);
|
320 |
+
}
|
321 |
+
|
322 |
+
if (request.body.api_type === TEXTGEN_TYPES.VLLM) {
|
323 |
+
request.body = _.pickBy(request.body, (_, key) => VLLM_KEYS.includes(key));
|
324 |
+
args.body = JSON.stringify(request.body);
|
325 |
+
}
|
326 |
+
|
327 |
+
if (request.body.api_type === TEXTGEN_TYPES.OLLAMA) {
|
328 |
+
const keepAlive = getConfigValue('ollama.keepAlive', -1);
|
329 |
+
args.body = JSON.stringify({
|
330 |
+
model: request.body.model,
|
331 |
+
prompt: request.body.prompt,
|
332 |
+
stream: request.body.stream ?? false,
|
333 |
+
keep_alive: keepAlive,
|
334 |
+
raw: true,
|
335 |
+
options: _.pickBy(request.body, (_, key) => OLLAMA_KEYS.includes(key)),
|
336 |
+
});
|
337 |
+
}
|
338 |
+
|
339 |
+
if (request.body.api_type === TEXTGEN_TYPES.OLLAMA && request.body.stream) {
|
340 |
+
const stream = await fetch(url, args);
|
341 |
+
parseOllamaStream(stream, request, response);
|
342 |
+
} else if (request.body.stream) {
|
343 |
+
const completionsStream = await fetch(url, args);
|
344 |
+
// Pipe remote SSE stream to Express response
|
345 |
+
forwardFetchResponse(completionsStream, response);
|
346 |
+
}
|
347 |
+
else {
|
348 |
+
const completionsReply = await fetch(url, args);
|
349 |
+
|
350 |
+
if (completionsReply.ok) {
|
351 |
+
const data = await completionsReply.json();
|
352 |
+
console.log('Endpoint response:', data);
|
353 |
+
|
354 |
+
// Wrap legacy response to OAI completions format
|
355 |
+
if (request.body.legacy_api) {
|
356 |
+
const text = data?.results[0]?.text;
|
357 |
+
data['choices'] = [{ text }];
|
358 |
+
}
|
359 |
+
|
360 |
+
// Map InfermaticAI response to OAI completions format
|
361 |
+
if (apiType === TEXTGEN_TYPES.INFERMATICAI) {
|
362 |
+
data['choices'] = (data?.choices || []).map(choice => ({ text: choice?.message?.content || choice.text, logprobs: choice?.logprobs, index: choice?.index }));
|
363 |
+
}
|
364 |
+
|
365 |
+
return response.send(data);
|
366 |
+
} else {
|
367 |
+
const text = await completionsReply.text();
|
368 |
+
const errorBody = { error: true, status: completionsReply.status, response: text };
|
369 |
+
|
370 |
+
if (!response.headersSent) {
|
371 |
+
return response.send(errorBody);
|
372 |
+
}
|
373 |
+
|
374 |
+
return response.end();
|
375 |
+
}
|
376 |
+
}
|
377 |
+
} catch (error) {
|
378 |
+
let value = { error: true, status: error?.status, response: error?.statusText };
|
379 |
+
console.log('Endpoint error:', error);
|
380 |
+
|
381 |
+
if (!response.headersSent) {
|
382 |
+
return response.send(value);
|
383 |
+
}
|
384 |
+
|
385 |
+
return response.end();
|
386 |
+
}
|
387 |
+
});
|
388 |
+
|
389 |
+
const ollama = express.Router();
|
390 |
+
|
391 |
+
ollama.post('/download', jsonParser, async function (request, response) {
|
392 |
+
try {
|
393 |
+
if (!request.body.name || !request.body.api_server) return response.sendStatus(400);
|
394 |
+
|
395 |
+
const name = request.body.name;
|
396 |
+
const url = String(request.body.api_server).replace(/\/$/, '');
|
397 |
+
|
398 |
+
const fetchResponse = await fetch(`${url}/api/pull`, {
|
399 |
+
method: 'POST',
|
400 |
+
headers: { 'Content-Type': 'application/json' },
|
401 |
+
body: JSON.stringify({
|
402 |
+
name: name,
|
403 |
+
stream: false,
|
404 |
+
}),
|
405 |
+
timeout: 0,
|
406 |
+
});
|
407 |
+
|
408 |
+
if (!fetchResponse.ok) {
|
409 |
+
console.log('Download error:', fetchResponse.status, fetchResponse.statusText);
|
410 |
+
return response.status(fetchResponse.status).send({ error: true });
|
411 |
+
}
|
412 |
+
|
413 |
+
return response.send({ ok: true });
|
414 |
+
} catch (error) {
|
415 |
+
console.error(error);
|
416 |
+
return response.status(500);
|
417 |
+
}
|
418 |
+
});
|
419 |
+
|
420 |
+
ollama.post('/caption-image', jsonParser, async function (request, response) {
|
421 |
+
try {
|
422 |
+
if (!request.body.server_url || !request.body.model) {
|
423 |
+
return response.sendStatus(400);
|
424 |
+
}
|
425 |
+
|
426 |
+
console.log('Ollama caption request:', request.body);
|
427 |
+
const baseUrl = trimV1(request.body.server_url);
|
428 |
+
|
429 |
+
const fetchResponse = await fetch(`${baseUrl}/api/generate`, {
|
430 |
+
method: 'POST',
|
431 |
+
headers: { 'Content-Type': 'application/json' },
|
432 |
+
body: JSON.stringify({
|
433 |
+
model: request.body.model,
|
434 |
+
prompt: request.body.prompt,
|
435 |
+
images: [request.body.image],
|
436 |
+
stream: false,
|
437 |
+
}),
|
438 |
+
timeout: 0,
|
439 |
+
});
|
440 |
+
|
441 |
+
if (!fetchResponse.ok) {
|
442 |
+
console.log('Ollama caption error:', fetchResponse.status, fetchResponse.statusText);
|
443 |
+
return response.status(500).send({ error: true });
|
444 |
+
}
|
445 |
+
|
446 |
+
const data = await fetchResponse.json();
|
447 |
+
console.log('Ollama caption response:', data);
|
448 |
+
|
449 |
+
const caption = data?.response || '';
|
450 |
+
|
451 |
+
if (!caption) {
|
452 |
+
console.log('Ollama caption is empty.');
|
453 |
+
return response.status(500).send({ error: true });
|
454 |
+
}
|
455 |
+
|
456 |
+
return response.send({ caption });
|
457 |
+
} catch (error) {
|
458 |
+
console.error(error);
|
459 |
+
return response.status(500);
|
460 |
+
}
|
461 |
+
});
|
462 |
+
|
463 |
+
const llamacpp = express.Router();
|
464 |
+
|
465 |
+
llamacpp.post('/caption-image', jsonParser, async function (request, response) {
|
466 |
+
try {
|
467 |
+
if (!request.body.server_url) {
|
468 |
+
return response.sendStatus(400);
|
469 |
+
}
|
470 |
+
|
471 |
+
console.log('LlamaCpp caption request:', request.body);
|
472 |
+
const baseUrl = trimV1(request.body.server_url);
|
473 |
+
|
474 |
+
const fetchResponse = await fetch(`${baseUrl}/completion`, {
|
475 |
+
method: 'POST',
|
476 |
+
headers: { 'Content-Type': 'application/json' },
|
477 |
+
timeout: 0,
|
478 |
+
body: JSON.stringify({
|
479 |
+
prompt: `USER:[img-1]${String(request.body.prompt).trim()}\nASSISTANT:`,
|
480 |
+
image_data: [{ data: request.body.image, id: 1 }],
|
481 |
+
temperature: 0.1,
|
482 |
+
stream: false,
|
483 |
+
stop: ['USER:', '</s>'],
|
484 |
+
}),
|
485 |
+
});
|
486 |
+
|
487 |
+
if (!fetchResponse.ok) {
|
488 |
+
console.log('LlamaCpp caption error:', fetchResponse.status, fetchResponse.statusText);
|
489 |
+
return response.status(500).send({ error: true });
|
490 |
+
}
|
491 |
+
|
492 |
+
const data = await fetchResponse.json();
|
493 |
+
console.log('LlamaCpp caption response:', data);
|
494 |
+
|
495 |
+
const caption = data?.content || '';
|
496 |
+
|
497 |
+
if (!caption) {
|
498 |
+
console.log('LlamaCpp caption is empty.');
|
499 |
+
return response.status(500).send({ error: true });
|
500 |
+
}
|
501 |
+
|
502 |
+
return response.send({ caption });
|
503 |
+
|
504 |
+
} catch (error) {
|
505 |
+
console.error(error);
|
506 |
+
return response.status(500);
|
507 |
+
}
|
508 |
+
});
|
509 |
+
|
510 |
+
llamacpp.post('/props', jsonParser, async function (request, response) {
|
511 |
+
try {
|
512 |
+
if (!request.body.server_url) {
|
513 |
+
return response.sendStatus(400);
|
514 |
+
}
|
515 |
+
|
516 |
+
console.log('LlamaCpp props request:', request.body);
|
517 |
+
const baseUrl = trimV1(request.body.server_url);
|
518 |
+
|
519 |
+
const fetchResponse = await fetch(`${baseUrl}/props`, {
|
520 |
+
method: 'GET',
|
521 |
+
timeout: 0,
|
522 |
+
});
|
523 |
+
|
524 |
+
if (!fetchResponse.ok) {
|
525 |
+
console.log('LlamaCpp props error:', fetchResponse.status, fetchResponse.statusText);
|
526 |
+
return response.status(500).send({ error: true });
|
527 |
+
}
|
528 |
+
|
529 |
+
const data = await fetchResponse.json();
|
530 |
+
console.log('LlamaCpp props response:', data);
|
531 |
+
|
532 |
+
return response.send(data);
|
533 |
+
|
534 |
+
} catch (error) {
|
535 |
+
console.error(error);
|
536 |
+
return response.status(500);
|
537 |
+
}
|
538 |
+
});
|
539 |
+
|
540 |
+
llamacpp.post('/slots', jsonParser, async function (request, response) {
|
541 |
+
try {
|
542 |
+
if (!request.body.server_url) {
|
543 |
+
return response.sendStatus(400);
|
544 |
+
}
|
545 |
+
if (!/^(erase|info|restore|save)$/.test(request.body.action)) {
|
546 |
+
return response.sendStatus(400);
|
547 |
+
}
|
548 |
+
|
549 |
+
console.log('LlamaCpp slots request:', request.body);
|
550 |
+
const baseUrl = trimV1(request.body.server_url);
|
551 |
+
|
552 |
+
let fetchResponse;
|
553 |
+
if (request.body.action === 'info') {
|
554 |
+
fetchResponse = await fetch(`${baseUrl}/slots`, {
|
555 |
+
method: 'GET',
|
556 |
+
timeout: 0,
|
557 |
+
});
|
558 |
+
} else {
|
559 |
+
if (!/^\d+$/.test(request.body.id_slot)) {
|
560 |
+
return response.sendStatus(400);
|
561 |
+
}
|
562 |
+
if (request.body.action !== 'erase' && !request.body.filename) {
|
563 |
+
return response.sendStatus(400);
|
564 |
+
}
|
565 |
+
|
566 |
+
fetchResponse = await fetch(`${baseUrl}/slots/${request.body.id_slot}?action=${request.body.action}`, {
|
567 |
+
method: 'POST',
|
568 |
+
headers: { 'Content-Type': 'application/json' },
|
569 |
+
timeout: 0,
|
570 |
+
body: JSON.stringify({
|
571 |
+
filename: request.body.action !== 'erase' ? `${request.body.filename}` : undefined,
|
572 |
+
}),
|
573 |
+
});
|
574 |
+
}
|
575 |
+
|
576 |
+
if (!fetchResponse.ok) {
|
577 |
+
console.log('LlamaCpp slots error:', fetchResponse.status, fetchResponse.statusText);
|
578 |
+
return response.status(500).send({ error: true });
|
579 |
+
}
|
580 |
+
|
581 |
+
const data = await fetchResponse.json();
|
582 |
+
console.log('LlamaCpp slots response:', data);
|
583 |
+
|
584 |
+
return response.send(data);
|
585 |
+
|
586 |
+
} catch (error) {
|
587 |
+
console.error(error);
|
588 |
+
return response.status(500);
|
589 |
+
}
|
590 |
+
});
|
591 |
+
|
592 |
+
const tabby = express.Router();
|
593 |
+
|
594 |
+
tabby.post('/download', jsonParser, async function (request, response) {
|
595 |
+
try {
|
596 |
+
const baseUrl = String(request.body.api_server).replace(/\/$/, '');
|
597 |
+
|
598 |
+
const args = {
|
599 |
+
method: 'POST',
|
600 |
+
headers: { 'Content-Type': 'application/json' },
|
601 |
+
body: JSON.stringify(request.body),
|
602 |
+
timeout: 0,
|
603 |
+
};
|
604 |
+
|
605 |
+
setAdditionalHeaders(request, args, baseUrl);
|
606 |
+
|
607 |
+
// Check key permissions
|
608 |
+
const permissionResponse = await fetch(`${baseUrl}/v1/auth/permission`, {
|
609 |
+
headers: args.headers,
|
610 |
+
});
|
611 |
+
|
612 |
+
if (permissionResponse.ok) {
|
613 |
+
const permissionJson = await permissionResponse.json();
|
614 |
+
|
615 |
+
if (permissionJson['permission'] !== 'admin') {
|
616 |
+
return response.status(403).send({ error: true });
|
617 |
+
}
|
618 |
+
} else {
|
619 |
+
console.log('API Permission error:', permissionResponse.status, permissionResponse.statusText);
|
620 |
+
return response.status(permissionResponse.status).send({ error: true });
|
621 |
+
}
|
622 |
+
|
623 |
+
const fetchResponse = await fetch(`${baseUrl}/v1/download`, args);
|
624 |
+
|
625 |
+
if (!fetchResponse.ok) {
|
626 |
+
console.log('Download error:', fetchResponse.status, fetchResponse.statusText);
|
627 |
+
return response.status(fetchResponse.status).send({ error: true });
|
628 |
+
}
|
629 |
+
|
630 |
+
return response.send({ ok: true });
|
631 |
+
} catch (error) {
|
632 |
+
console.error(error);
|
633 |
+
return response.status(500);
|
634 |
+
}
|
635 |
+
});
|
636 |
+
|
637 |
+
router.use('/ollama', ollama);
|
638 |
+
router.use('/llamacpp', llamacpp);
|
639 |
+
router.use('/tabby', tabby);
|
640 |
+
|
641 |
+
module.exports = { router };
|
src/endpoints/backgrounds.js
ADDED
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const fs = require('fs');
|
2 |
+
const path = require('path');
|
3 |
+
const express = require('express');
|
4 |
+
const sanitize = require('sanitize-filename');
|
5 |
+
|
6 |
+
const { jsonParser, urlencodedParser } = require('../express-common');
|
7 |
+
const { invalidateThumbnail } = require('./thumbnails');
|
8 |
+
const { getImages } = require('../util');
|
9 |
+
|
10 |
+
const router = express.Router();
|
11 |
+
|
12 |
+
router.post('/all', jsonParser, function (request, response) {
|
13 |
+
var images = getImages(request.user.directories.backgrounds);
|
14 |
+
response.send(JSON.stringify(images));
|
15 |
+
});
|
16 |
+
|
17 |
+
router.post('/delete', jsonParser, function (request, response) {
|
18 |
+
if (!request.body) return response.sendStatus(400);
|
19 |
+
|
20 |
+
if (request.body.bg !== sanitize(request.body.bg)) {
|
21 |
+
console.error('Malicious bg name prevented');
|
22 |
+
return response.sendStatus(403);
|
23 |
+
}
|
24 |
+
|
25 |
+
const fileName = path.join(request.user.directories.backgrounds, sanitize(request.body.bg));
|
26 |
+
|
27 |
+
if (!fs.existsSync(fileName)) {
|
28 |
+
console.log('BG file not found');
|
29 |
+
return response.sendStatus(400);
|
30 |
+
}
|
31 |
+
|
32 |
+
fs.rmSync(fileName);
|
33 |
+
invalidateThumbnail(request.user.directories, 'bg', request.body.bg);
|
34 |
+
return response.send('ok');
|
35 |
+
});
|
36 |
+
|
37 |
+
router.post('/rename', jsonParser, function (request, response) {
|
38 |
+
if (!request.body) return response.sendStatus(400);
|
39 |
+
|
40 |
+
const oldFileName = path.join(request.user.directories.backgrounds, sanitize(request.body.old_bg));
|
41 |
+
const newFileName = path.join(request.user.directories.backgrounds, sanitize(request.body.new_bg));
|
42 |
+
|
43 |
+
if (!fs.existsSync(oldFileName)) {
|
44 |
+
console.log('BG file not found');
|
45 |
+
return response.sendStatus(400);
|
46 |
+
}
|
47 |
+
|
48 |
+
if (fs.existsSync(newFileName)) {
|
49 |
+
console.log('New BG file already exists');
|
50 |
+
return response.sendStatus(400);
|
51 |
+
}
|
52 |
+
|
53 |
+
fs.copyFileSync(oldFileName, newFileName);
|
54 |
+
fs.rmSync(oldFileName);
|
55 |
+
invalidateThumbnail(request.user.directories, 'bg', request.body.old_bg);
|
56 |
+
return response.send('ok');
|
57 |
+
});
|
58 |
+
|
59 |
+
router.post('/upload', urlencodedParser, function (request, response) {
|
60 |
+
if (!request.body || !request.file) return response.sendStatus(400);
|
61 |
+
|
62 |
+
const img_path = path.join(request.file.destination, request.file.filename);
|
63 |
+
const filename = request.file.originalname;
|
64 |
+
|
65 |
+
try {
|
66 |
+
fs.copyFileSync(img_path, path.join(request.user.directories.backgrounds, filename));
|
67 |
+
fs.rmSync(img_path);
|
68 |
+
invalidateThumbnail(request.user.directories, 'bg', filename);
|
69 |
+
response.send(filename);
|
70 |
+
} catch (err) {
|
71 |
+
console.error(err);
|
72 |
+
response.sendStatus(500);
|
73 |
+
}
|
74 |
+
});
|
75 |
+
|
76 |
+
module.exports = { router };
|
src/endpoints/caption.js
ADDED
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const express = require('express');
|
2 |
+
const { jsonParser } = require('../express-common');
|
3 |
+
|
4 |
+
const TASK = 'image-to-text';
|
5 |
+
|
6 |
+
const router = express.Router();
|
7 |
+
|
8 |
+
router.post('/', jsonParser, async (req, res) => {
|
9 |
+
try {
|
10 |
+
const { image } = req.body;
|
11 |
+
|
12 |
+
const module = await import('../transformers.mjs');
|
13 |
+
const rawImage = await module.default.getRawImage(image);
|
14 |
+
|
15 |
+
if (!rawImage) {
|
16 |
+
console.log('Failed to parse captioned image');
|
17 |
+
return res.sendStatus(400);
|
18 |
+
}
|
19 |
+
|
20 |
+
const pipe = await module.default.getPipeline(TASK);
|
21 |
+
const result = await pipe(rawImage);
|
22 |
+
const text = result[0].generated_text;
|
23 |
+
console.log('Image caption:', text);
|
24 |
+
|
25 |
+
return res.json({ caption: text });
|
26 |
+
} catch (error) {
|
27 |
+
console.error(error);
|
28 |
+
return res.sendStatus(500);
|
29 |
+
}
|
30 |
+
});
|
31 |
+
|
32 |
+
module.exports = { router };
|
src/endpoints/characters.js
ADDED
@@ -0,0 +1,1230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const path = require('path');
|
2 |
+
const fs = require('fs');
|
3 |
+
const fsPromises = require('fs').promises;
|
4 |
+
const readline = require('readline');
|
5 |
+
const express = require('express');
|
6 |
+
const sanitize = require('sanitize-filename');
|
7 |
+
const writeFileAtomicSync = require('write-file-atomic').sync;
|
8 |
+
const yaml = require('yaml');
|
9 |
+
const _ = require('lodash');
|
10 |
+
const mime = require('mime-types');
|
11 |
+
|
12 |
+
const jimp = require('jimp');
|
13 |
+
|
14 |
+
const { AVATAR_WIDTH, AVATAR_HEIGHT } = require('../constants');
|
15 |
+
const { jsonParser, urlencodedParser } = require('../express-common');
|
16 |
+
const { deepMerge, humanizedISO8601DateTime, tryParse, extractFileFromZipBuffer } = require('../util');
|
17 |
+
const { TavernCardValidator } = require('../validator/TavernCardValidator');
|
18 |
+
const characterCardParser = require('../character-card-parser.js');
|
19 |
+
const { readWorldInfoFile } = require('./worldinfo');
|
20 |
+
const { invalidateThumbnail } = require('./thumbnails');
|
21 |
+
const { importRisuSprites } = require('./sprites');
|
22 |
+
const defaultAvatarPath = './public/img/ai4.png';
|
23 |
+
|
24 |
+
// KV-store for parsed character data
|
25 |
+
const characterDataCache = new Map();
|
26 |
+
|
27 |
+
/**
|
28 |
+
* Reads the character card from the specified image file.
|
29 |
+
* @param {string} inputFile - Path to the image file
|
30 |
+
* @param {string} inputFormat - 'png'
|
31 |
+
* @returns {Promise<string | undefined>} - Character card data
|
32 |
+
*/
|
33 |
+
async function readCharacterData(inputFile, inputFormat = 'png') {
|
34 |
+
const stat = fs.statSync(inputFile);
|
35 |
+
const cacheKey = `${inputFile}-${stat.mtimeMs}`;
|
36 |
+
if (characterDataCache.has(cacheKey)) {
|
37 |
+
return characterDataCache.get(cacheKey);
|
38 |
+
}
|
39 |
+
|
40 |
+
const result = characterCardParser.parse(inputFile, inputFormat);
|
41 |
+
characterDataCache.set(cacheKey, result);
|
42 |
+
return result;
|
43 |
+
}
|
44 |
+
|
45 |
+
/**
|
46 |
+
* Writes the character card to the specified image file.
|
47 |
+
* @param {string|Buffer} inputFile - Path to the image file or image buffer
|
48 |
+
* @param {string} data - Character card data
|
49 |
+
* @param {string} outputFile - Target image file name
|
50 |
+
* @param {import('express').Request} request - Express request obejct
|
51 |
+
* @param {Crop|undefined} crop - Crop parameters
|
52 |
+
* @returns {Promise<boolean>} - True if the operation was successful
|
53 |
+
*/
|
54 |
+
async function writeCharacterData(inputFile, data, outputFile, request, crop = undefined) {
|
55 |
+
try {
|
56 |
+
// Reset the cache
|
57 |
+
for (const key of characterDataCache.keys()) {
|
58 |
+
if (key.startsWith(inputFile)) {
|
59 |
+
characterDataCache.delete(key);
|
60 |
+
break;
|
61 |
+
}
|
62 |
+
}
|
63 |
+
|
64 |
+
/**
|
65 |
+
* Read the image, resize, and save it as a PNG into the buffer.
|
66 |
+
* @returns {Promise<Buffer>} Image buffer
|
67 |
+
*/
|
68 |
+
function getInputImage() {
|
69 |
+
if (Buffer.isBuffer(inputFile)) {
|
70 |
+
return parseImageBuffer(inputFile, crop);
|
71 |
+
}
|
72 |
+
|
73 |
+
return tryReadImage(inputFile, crop);
|
74 |
+
}
|
75 |
+
|
76 |
+
const inputImage = await getInputImage();
|
77 |
+
|
78 |
+
// Get the chunks
|
79 |
+
const outputImage = characterCardParser.write(inputImage, data);
|
80 |
+
const outputImagePath = path.join(request.user.directories.characters, `${outputFile}.png`);
|
81 |
+
|
82 |
+
writeFileAtomicSync(outputImagePath, outputImage);
|
83 |
+
return true;
|
84 |
+
} catch (err) {
|
85 |
+
console.log(err);
|
86 |
+
return false;
|
87 |
+
}
|
88 |
+
}
|
89 |
+
|
90 |
+
/**
|
91 |
+
* @typedef {Object} Crop
|
92 |
+
* @property {number} x X-coordinate
|
93 |
+
* @property {number} y Y-coordinate
|
94 |
+
* @property {number} width Width
|
95 |
+
* @property {number} height Height
|
96 |
+
* @property {boolean} want_resize Resize the image to the standard avatar size
|
97 |
+
*/
|
98 |
+
|
99 |
+
/**
|
100 |
+
* Parses an image buffer and applies crop if defined.
|
101 |
+
* @param {Buffer} buffer Buffer of the image
|
102 |
+
* @param {Crop|undefined} [crop] Crop parameters
|
103 |
+
* @returns {Promise<Buffer>} Image buffer
|
104 |
+
*/
|
105 |
+
async function parseImageBuffer(buffer, crop) {
|
106 |
+
const image = await jimp.read(buffer);
|
107 |
+
let finalWidth = image.bitmap.width, finalHeight = image.bitmap.height;
|
108 |
+
|
109 |
+
// Apply crop if defined
|
110 |
+
if (typeof crop == 'object' && [crop.x, crop.y, crop.width, crop.height].every(x => typeof x === 'number')) {
|
111 |
+
image.crop(crop.x, crop.y, crop.width, crop.height);
|
112 |
+
// Apply standard resize if requested
|
113 |
+
if (crop.want_resize) {
|
114 |
+
finalWidth = AVATAR_WIDTH;
|
115 |
+
finalHeight = AVATAR_HEIGHT;
|
116 |
+
} else {
|
117 |
+
finalWidth = crop.width;
|
118 |
+
finalHeight = crop.height;
|
119 |
+
}
|
120 |
+
}
|
121 |
+
|
122 |
+
return image.cover(finalWidth, finalHeight).getBufferAsync(jimp.MIME_PNG);
|
123 |
+
}
|
124 |
+
|
125 |
+
/**
|
126 |
+
* Reads an image file and applies crop if defined.
|
127 |
+
* @param {string} imgPath Path to the image file
|
128 |
+
* @param {Crop|undefined} crop Crop parameters
|
129 |
+
* @returns {Promise<Buffer>} Image buffer
|
130 |
+
*/
|
131 |
+
async function tryReadImage(imgPath, crop) {
|
132 |
+
try {
|
133 |
+
let rawImg = await jimp.read(imgPath);
|
134 |
+
let finalWidth = rawImg.bitmap.width, finalHeight = rawImg.bitmap.height;
|
135 |
+
|
136 |
+
// Apply crop if defined
|
137 |
+
if (typeof crop == 'object' && [crop.x, crop.y, crop.width, crop.height].every(x => typeof x === 'number')) {
|
138 |
+
rawImg = rawImg.crop(crop.x, crop.y, crop.width, crop.height);
|
139 |
+
// Apply standard resize if requested
|
140 |
+
if (crop.want_resize) {
|
141 |
+
finalWidth = AVATAR_WIDTH;
|
142 |
+
finalHeight = AVATAR_HEIGHT;
|
143 |
+
} else {
|
144 |
+
finalWidth = crop.width;
|
145 |
+
finalHeight = crop.height;
|
146 |
+
}
|
147 |
+
}
|
148 |
+
|
149 |
+
const image = await rawImg.cover(finalWidth, finalHeight).getBufferAsync(jimp.MIME_PNG);
|
150 |
+
return image;
|
151 |
+
}
|
152 |
+
// If it's an unsupported type of image (APNG) - just read the file as buffer
|
153 |
+
catch {
|
154 |
+
return fs.readFileSync(imgPath);
|
155 |
+
}
|
156 |
+
}
|
157 |
+
|
158 |
+
/**
|
159 |
+
* calculateChatSize - Calculates the total chat size for a given character.
|
160 |
+
*
|
161 |
+
* @param {string} charDir The directory where the chats are stored.
|
162 |
+
* @return { {chatSize: number, dateLastChat: number} } The total chat size.
|
163 |
+
*/
|
164 |
+
const calculateChatSize = (charDir) => {
|
165 |
+
let chatSize = 0;
|
166 |
+
let dateLastChat = 0;
|
167 |
+
|
168 |
+
if (fs.existsSync(charDir)) {
|
169 |
+
const chats = fs.readdirSync(charDir);
|
170 |
+
if (Array.isArray(chats) && chats.length) {
|
171 |
+
for (const chat of chats) {
|
172 |
+
const chatStat = fs.statSync(path.join(charDir, chat));
|
173 |
+
chatSize += chatStat.size;
|
174 |
+
dateLastChat = Math.max(dateLastChat, chatStat.mtimeMs);
|
175 |
+
}
|
176 |
+
}
|
177 |
+
}
|
178 |
+
|
179 |
+
return { chatSize, dateLastChat };
|
180 |
+
};
|
181 |
+
|
182 |
+
// Calculate the total string length of the data object
|
183 |
+
const calculateDataSize = (data) => {
|
184 |
+
return typeof data === 'object' ? Object.values(data).reduce((acc, val) => acc + new String(val).length, 0) : 0;
|
185 |
+
};
|
186 |
+
|
187 |
+
/**
|
188 |
+
* processCharacter - Process a given character, read its data and calculate its statistics.
|
189 |
+
*
|
190 |
+
* @param {string} item The name of the character.
|
191 |
+
* @param {import('../users').UserDirectoryList} directories User directories
|
192 |
+
* @return {Promise<object>} A Promise that resolves when the character processing is done.
|
193 |
+
*/
|
194 |
+
const processCharacter = async (item, directories) => {
|
195 |
+
try {
|
196 |
+
const imgFile = path.join(directories.characters, item);
|
197 |
+
const imgData = await readCharacterData(imgFile);
|
198 |
+
if (imgData === undefined) throw new Error('Failed to read character file');
|
199 |
+
|
200 |
+
let jsonObject = getCharaCardV2(JSON.parse(imgData), directories, false);
|
201 |
+
jsonObject.avatar = item;
|
202 |
+
const character = jsonObject;
|
203 |
+
character['json_data'] = imgData;
|
204 |
+
const charStat = fs.statSync(path.join(directories.characters, item));
|
205 |
+
character['date_added'] = charStat.ctimeMs;
|
206 |
+
character['create_date'] = jsonObject['create_date'] || humanizedISO8601DateTime(charStat.ctimeMs);
|
207 |
+
const chatsDirectory = path.join(directories.chats, item.replace('.png', ''));
|
208 |
+
|
209 |
+
const { chatSize, dateLastChat } = calculateChatSize(chatsDirectory);
|
210 |
+
character['chat_size'] = chatSize;
|
211 |
+
character['date_last_chat'] = dateLastChat;
|
212 |
+
character['data_size'] = calculateDataSize(jsonObject?.data);
|
213 |
+
return character;
|
214 |
+
}
|
215 |
+
catch (err) {
|
216 |
+
console.log(`Could not process character: ${item}`);
|
217 |
+
|
218 |
+
if (err instanceof SyntaxError) {
|
219 |
+
console.log(`${item} does not contain a valid JSON object.`);
|
220 |
+
} else {
|
221 |
+
console.log('An unexpected error occurred: ', err);
|
222 |
+
}
|
223 |
+
|
224 |
+
return {
|
225 |
+
date_added: 0,
|
226 |
+
date_last_chat: 0,
|
227 |
+
chat_size: 0,
|
228 |
+
};
|
229 |
+
}
|
230 |
+
};
|
231 |
+
|
232 |
+
/**
|
233 |
+
* Convert a character object to Spec V2 format.
|
234 |
+
* @param {object} jsonObject Character object
|
235 |
+
* @param {import('../users').UserDirectoryList} directories User directories
|
236 |
+
* @param {boolean} hoistDate Will set the chat and create_date fields to the current date if they are missing
|
237 |
+
* @returns {object} Character object in Spec V2 format
|
238 |
+
*/
|
239 |
+
function getCharaCardV2(jsonObject, directories, hoistDate = true) {
|
240 |
+
if (jsonObject.spec === undefined) {
|
241 |
+
jsonObject = convertToV2(jsonObject, directories);
|
242 |
+
|
243 |
+
if (hoistDate && !jsonObject.create_date) {
|
244 |
+
jsonObject.create_date = humanizedISO8601DateTime();
|
245 |
+
}
|
246 |
+
} else {
|
247 |
+
jsonObject = readFromV2(jsonObject);
|
248 |
+
}
|
249 |
+
return jsonObject;
|
250 |
+
}
|
251 |
+
|
252 |
+
/**
|
253 |
+
* Convert a character object to Spec V2 format.
|
254 |
+
* @param {object} char Character object
|
255 |
+
* @param {import('../users').UserDirectoryList} directories User directories
|
256 |
+
* @returns {object} Character object in Spec V2 format
|
257 |
+
*/
|
258 |
+
function convertToV2(char, directories) {
|
259 |
+
// Simulate incoming data from frontend form
|
260 |
+
const result = charaFormatData({
|
261 |
+
json_data: JSON.stringify(char),
|
262 |
+
ch_name: char.name,
|
263 |
+
description: char.description,
|
264 |
+
personality: char.personality,
|
265 |
+
scenario: char.scenario,
|
266 |
+
first_mes: char.first_mes,
|
267 |
+
mes_example: char.mes_example,
|
268 |
+
creator_notes: char.creatorcomment,
|
269 |
+
talkativeness: char.talkativeness,
|
270 |
+
fav: char.fav,
|
271 |
+
creator: char.creator,
|
272 |
+
tags: char.tags,
|
273 |
+
depth_prompt_prompt: char.depth_prompt_prompt,
|
274 |
+
depth_prompt_depth: char.depth_prompt_depth,
|
275 |
+
depth_prompt_role: char.depth_prompt_role,
|
276 |
+
}, directories);
|
277 |
+
|
278 |
+
result.chat = char.chat ?? humanizedISO8601DateTime();
|
279 |
+
result.create_date = char.create_date;
|
280 |
+
|
281 |
+
return result;
|
282 |
+
}
|
283 |
+
|
284 |
+
|
285 |
+
function unsetFavFlag(char) {
|
286 |
+
_.set(char, 'fav', false);
|
287 |
+
_.set(char, 'data.extensions.fav', false);
|
288 |
+
}
|
289 |
+
|
290 |
+
function readFromV2(char) {
|
291 |
+
if (_.isUndefined(char.data)) {
|
292 |
+
console.warn(`Char ${char['name']} has Spec v2 data missing`);
|
293 |
+
return char;
|
294 |
+
}
|
295 |
+
|
296 |
+
const fieldMappings = {
|
297 |
+
name: 'name',
|
298 |
+
description: 'description',
|
299 |
+
personality: 'personality',
|
300 |
+
scenario: 'scenario',
|
301 |
+
first_mes: 'first_mes',
|
302 |
+
mes_example: 'mes_example',
|
303 |
+
talkativeness: 'extensions.talkativeness',
|
304 |
+
fav: 'extensions.fav',
|
305 |
+
tags: 'tags',
|
306 |
+
};
|
307 |
+
|
308 |
+
_.forEach(fieldMappings, (v2Path, charField) => {
|
309 |
+
//console.log(`Migrating field: ${charField} from ${v2Path}`);
|
310 |
+
const v2Value = _.get(char.data, v2Path);
|
311 |
+
if (_.isUndefined(v2Value)) {
|
312 |
+
let defaultValue = undefined;
|
313 |
+
|
314 |
+
// Backfill default values for missing ST extension fields
|
315 |
+
if (v2Path === 'extensions.talkativeness') {
|
316 |
+
defaultValue = 0.5;
|
317 |
+
}
|
318 |
+
|
319 |
+
if (v2Path === 'extensions.fav') {
|
320 |
+
defaultValue = false;
|
321 |
+
}
|
322 |
+
|
323 |
+
if (!_.isUndefined(defaultValue)) {
|
324 |
+
//console.debug(`Spec v2 extension data missing for field: ${charField}, using default value: ${defaultValue}`);
|
325 |
+
char[charField] = defaultValue;
|
326 |
+
} else {
|
327 |
+
console.debug(`Char ${char['name']} has Spec v2 data missing for unknown field: ${charField}`);
|
328 |
+
return;
|
329 |
+
}
|
330 |
+
}
|
331 |
+
if (!_.isUndefined(char[charField]) && !_.isUndefined(v2Value) && String(char[charField]) !== String(v2Value)) {
|
332 |
+
console.debug(`Char ${char['name']} has Spec v2 data mismatch with Spec v1 for field: ${charField}`, char[charField], v2Value);
|
333 |
+
}
|
334 |
+
char[charField] = v2Value;
|
335 |
+
});
|
336 |
+
|
337 |
+
char['chat'] = char['chat'] ?? humanizedISO8601DateTime();
|
338 |
+
|
339 |
+
return char;
|
340 |
+
}
|
341 |
+
|
342 |
+
/**
|
343 |
+
* Format character data to Spec V2 format.
|
344 |
+
* @param {object} data Character data
|
345 |
+
* @param {import('../users').UserDirectoryList} directories User directories
|
346 |
+
* @returns
|
347 |
+
*/
|
348 |
+
function charaFormatData(data, directories) {
|
349 |
+
// This is supposed to save all the foreign keys that ST doesn't care about
|
350 |
+
const char = tryParse(data.json_data) || {};
|
351 |
+
|
352 |
+
// Checks if data.alternate_greetings is an array, a string, or neither, and acts accordingly. (expected to be an array of strings)
|
353 |
+
const getAlternateGreetings = data => {
|
354 |
+
if (Array.isArray(data.alternate_greetings)) return data.alternate_greetings;
|
355 |
+
if (typeof data.alternate_greetings === 'string') return [data.alternate_greetings];
|
356 |
+
return [];
|
357 |
+
};
|
358 |
+
|
359 |
+
// Spec V1 fields
|
360 |
+
_.set(char, 'name', data.ch_name);
|
361 |
+
_.set(char, 'description', data.description || '');
|
362 |
+
_.set(char, 'personality', data.personality || '');
|
363 |
+
_.set(char, 'scenario', data.scenario || '');
|
364 |
+
_.set(char, 'first_mes', data.first_mes || '');
|
365 |
+
_.set(char, 'mes_example', data.mes_example || '');
|
366 |
+
|
367 |
+
// Old ST extension fields (for backward compatibility, will be deprecated)
|
368 |
+
_.set(char, 'creatorcomment', data.creator_notes);
|
369 |
+
_.set(char, 'avatar', 'none');
|
370 |
+
_.set(char, 'chat', data.ch_name + ' - ' + humanizedISO8601DateTime());
|
371 |
+
_.set(char, 'talkativeness', data.talkativeness);
|
372 |
+
_.set(char, 'fav', data.fav == 'true');
|
373 |
+
_.set(char, 'tags', typeof data.tags == 'string' ? (data.tags.split(',').map(x => x.trim()).filter(x => x)) : data.tags || []);
|
374 |
+
|
375 |
+
// Spec V2 fields
|
376 |
+
_.set(char, 'spec', 'chara_card_v2');
|
377 |
+
_.set(char, 'spec_version', '2.0');
|
378 |
+
_.set(char, 'data.name', data.ch_name);
|
379 |
+
_.set(char, 'data.description', data.description || '');
|
380 |
+
_.set(char, 'data.personality', data.personality || '');
|
381 |
+
_.set(char, 'data.scenario', data.scenario || '');
|
382 |
+
_.set(char, 'data.first_mes', data.first_mes || '');
|
383 |
+
_.set(char, 'data.mes_example', data.mes_example || '');
|
384 |
+
|
385 |
+
// New V2 fields
|
386 |
+
_.set(char, 'data.creator_notes', data.creator_notes || '');
|
387 |
+
_.set(char, 'data.system_prompt', data.system_prompt || '');
|
388 |
+
_.set(char, 'data.post_history_instructions', data.post_history_instructions || '');
|
389 |
+
_.set(char, 'data.tags', typeof data.tags == 'string' ? (data.tags.split(',').map(x => x.trim()).filter(x => x)) : data.tags || []);
|
390 |
+
_.set(char, 'data.creator', data.creator || '');
|
391 |
+
_.set(char, 'data.character_version', data.character_version || '');
|
392 |
+
_.set(char, 'data.alternate_greetings', getAlternateGreetings(data));
|
393 |
+
|
394 |
+
// ST extension fields to V2 object
|
395 |
+
_.set(char, 'data.extensions.talkativeness', data.talkativeness);
|
396 |
+
_.set(char, 'data.extensions.fav', data.fav == 'true');
|
397 |
+
_.set(char, 'data.extensions.world', data.world || '');
|
398 |
+
|
399 |
+
// Spec extension: depth prompt
|
400 |
+
const depth_default = 4;
|
401 |
+
const role_default = 'system';
|
402 |
+
const depth_value = !isNaN(Number(data.depth_prompt_depth)) ? Number(data.depth_prompt_depth) : depth_default;
|
403 |
+
const role_value = data.depth_prompt_role ?? role_default;
|
404 |
+
_.set(char, 'data.extensions.depth_prompt.prompt', data.depth_prompt_prompt ?? '');
|
405 |
+
_.set(char, 'data.extensions.depth_prompt.depth', depth_value);
|
406 |
+
_.set(char, 'data.extensions.depth_prompt.role', role_value);
|
407 |
+
//_.set(char, 'data.extensions.create_date', humanizedISO8601DateTime());
|
408 |
+
//_.set(char, 'data.extensions.avatar', 'none');
|
409 |
+
//_.set(char, 'data.extensions.chat', data.ch_name + ' - ' + humanizedISO8601DateTime());
|
410 |
+
|
411 |
+
// V3 fields
|
412 |
+
_.set(char, 'data.group_only_greetings', data.group_only_greetings ?? []);
|
413 |
+
|
414 |
+
if (data.world) {
|
415 |
+
try {
|
416 |
+
const file = readWorldInfoFile(directories, data.world, false);
|
417 |
+
|
418 |
+
// File was imported - save it to the character book
|
419 |
+
if (file && file.originalData) {
|
420 |
+
_.set(char, 'data.character_book', file.originalData);
|
421 |
+
}
|
422 |
+
|
423 |
+
// File was not imported - convert the world info to the character book
|
424 |
+
if (file && file.entries) {
|
425 |
+
_.set(char, 'data.character_book', convertWorldInfoToCharacterBook(data.world, file.entries));
|
426 |
+
}
|
427 |
+
|
428 |
+
} catch {
|
429 |
+
console.debug(`Failed to read world info file: ${data.world}. Character book will not be available.`);
|
430 |
+
}
|
431 |
+
}
|
432 |
+
|
433 |
+
if (data.extensions) {
|
434 |
+
try {
|
435 |
+
const extensions = JSON.parse(data.extensions);
|
436 |
+
// Deep merge the extensions object
|
437 |
+
_.set(char, 'data.extensions', deepMerge(char.data.extensions, extensions));
|
438 |
+
} catch {
|
439 |
+
console.debug(`Failed to parse extensions JSON: ${data.extensions}`);
|
440 |
+
}
|
441 |
+
}
|
442 |
+
|
443 |
+
return char;
|
444 |
+
}
|
445 |
+
|
446 |
+
/**
|
447 |
+
* @param {string} name Name of World Info file
|
448 |
+
* @param {object} entries Entries object
|
449 |
+
*/
|
450 |
+
function convertWorldInfoToCharacterBook(name, entries) {
|
451 |
+
/** @type {{ entries: object[]; name: string }} */
|
452 |
+
const result = { entries: [], name };
|
453 |
+
|
454 |
+
for (const index in entries) {
|
455 |
+
const entry = entries[index];
|
456 |
+
|
457 |
+
const originalEntry = {
|
458 |
+
id: entry.uid,
|
459 |
+
keys: entry.key,
|
460 |
+
secondary_keys: entry.keysecondary,
|
461 |
+
comment: entry.comment,
|
462 |
+
content: entry.content,
|
463 |
+
constant: entry.constant,
|
464 |
+
selective: entry.selective,
|
465 |
+
insertion_order: entry.order,
|
466 |
+
enabled: !entry.disable,
|
467 |
+
position: entry.position == 0 ? 'before_char' : 'after_char',
|
468 |
+
use_regex: true, // ST keys are always regex
|
469 |
+
extensions: {
|
470 |
+
position: entry.position,
|
471 |
+
exclude_recursion: entry.excludeRecursion,
|
472 |
+
display_index: entry.displayIndex,
|
473 |
+
probability: entry.probability ?? null,
|
474 |
+
useProbability: entry.useProbability ?? false,
|
475 |
+
depth: entry.depth ?? 4,
|
476 |
+
selectiveLogic: entry.selectiveLogic ?? 0,
|
477 |
+
group: entry.group ?? '',
|
478 |
+
group_override: entry.groupOverride ?? false,
|
479 |
+
group_weight: entry.groupWeight ?? null,
|
480 |
+
prevent_recursion: entry.preventRecursion ?? false,
|
481 |
+
delay_until_recursion: entry.delayUntilRecursion ?? false,
|
482 |
+
scan_depth: entry.scanDepth ?? null,
|
483 |
+
match_whole_words: entry.matchWholeWords ?? null,
|
484 |
+
use_group_scoring: entry.useGroupScoring ?? false,
|
485 |
+
case_sensitive: entry.caseSensitive ?? null,
|
486 |
+
automation_id: entry.automationId ?? '',
|
487 |
+
role: entry.role ?? 0,
|
488 |
+
vectorized: entry.vectorized ?? false,
|
489 |
+
sticky: entry.sticky ?? null,
|
490 |
+
cooldown: entry.cooldown ?? null,
|
491 |
+
delay: entry.delay ?? null,
|
492 |
+
},
|
493 |
+
};
|
494 |
+
|
495 |
+
result.entries.push(originalEntry);
|
496 |
+
}
|
497 |
+
|
498 |
+
return result;
|
499 |
+
}
|
500 |
+
|
501 |
+
/**
|
502 |
+
* Import a character from a YAML file.
|
503 |
+
* @param {string} uploadPath Path to the uploaded file
|
504 |
+
* @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects
|
505 |
+
* @param {string|undefined} preservedFileName Preserved file name
|
506 |
+
* @returns {Promise<string>} Internal name of the character
|
507 |
+
*/
|
508 |
+
async function importFromYaml(uploadPath, context, preservedFileName) {
|
509 |
+
const fileText = fs.readFileSync(uploadPath, 'utf8');
|
510 |
+
fs.rmSync(uploadPath);
|
511 |
+
const yamlData = yaml.parse(fileText);
|
512 |
+
console.log('Importing from YAML');
|
513 |
+
yamlData.name = sanitize(yamlData.name);
|
514 |
+
const fileName = preservedFileName || getPngName(yamlData.name, context.request.user.directories);
|
515 |
+
let char = convertToV2({
|
516 |
+
'name': yamlData.name,
|
517 |
+
'description': yamlData.context ?? '',
|
518 |
+
'first_mes': yamlData.greeting ?? '',
|
519 |
+
'create_date': humanizedISO8601DateTime(),
|
520 |
+
'chat': `${yamlData.name} - ${humanizedISO8601DateTime()}`,
|
521 |
+
'personality': '',
|
522 |
+
'creatorcomment': '',
|
523 |
+
'avatar': 'none',
|
524 |
+
'mes_example': '',
|
525 |
+
'scenario': '',
|
526 |
+
'talkativeness': 0.5,
|
527 |
+
'creator': '',
|
528 |
+
'tags': '',
|
529 |
+
}, context.request.user.directories);
|
530 |
+
const result = await writeCharacterData(defaultAvatarPath, JSON.stringify(char), fileName, context.request);
|
531 |
+
return result ? fileName : '';
|
532 |
+
}
|
533 |
+
|
534 |
+
/**
|
535 |
+
* Imports a character card from CharX (ZIP) file.
|
536 |
+
* @param {string} uploadPath
|
537 |
+
* @param {object} params
|
538 |
+
* @param {import('express').Request} params.request
|
539 |
+
* @param {string|undefined} preservedFileName Preserved file name
|
540 |
+
* @returns {Promise<string>} Internal name of the character
|
541 |
+
*/
|
542 |
+
async function importFromCharX(uploadPath, { request }, preservedFileName) {
|
543 |
+
const data = fs.readFileSync(uploadPath);
|
544 |
+
fs.rmSync(uploadPath);
|
545 |
+
console.log('Importing from CharX');
|
546 |
+
const cardBuffer = await extractFileFromZipBuffer(data, 'card.json');
|
547 |
+
|
548 |
+
if (!cardBuffer) {
|
549 |
+
throw new Error('Failed to extract card.json from CharX file');
|
550 |
+
}
|
551 |
+
|
552 |
+
const card = readFromV2(JSON.parse(cardBuffer.toString()));
|
553 |
+
|
554 |
+
if (card.spec === undefined) {
|
555 |
+
throw new Error('Invalid CharX card file: missing spec field');
|
556 |
+
}
|
557 |
+
|
558 |
+
/** @type {string|Buffer} */
|
559 |
+
let avatar = defaultAvatarPath;
|
560 |
+
const assets = _.get(card, 'data.assets');
|
561 |
+
if (Array.isArray(assets) && assets.length) {
|
562 |
+
for (const asset of assets.filter(x => x.type === 'icon' && typeof x.uri === 'string')) {
|
563 |
+
const pathNoProtocol = String(asset.uri.replace(/^(?:\/\/|[^/]+)*\//, ''));
|
564 |
+
const buffer = await extractFileFromZipBuffer(data, pathNoProtocol);
|
565 |
+
if (buffer) {
|
566 |
+
avatar = buffer;
|
567 |
+
break;
|
568 |
+
}
|
569 |
+
}
|
570 |
+
}
|
571 |
+
|
572 |
+
unsetFavFlag(card);
|
573 |
+
card['create_date'] = humanizedISO8601DateTime();
|
574 |
+
card.name = sanitize(card.name);
|
575 |
+
const fileName = preservedFileName || getPngName(card.name, request.user.directories);
|
576 |
+
const result = await writeCharacterData(avatar, JSON.stringify(card), fileName, request);
|
577 |
+
return result ? fileName : '';
|
578 |
+
}
|
579 |
+
|
580 |
+
/**
|
581 |
+
* Import a character from a JSON file.
|
582 |
+
* @param {string} uploadPath Path to the uploaded file
|
583 |
+
* @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects
|
584 |
+
* @param {string|undefined} preservedFileName Preserved file name
|
585 |
+
* @returns {Promise<string>} Internal name of the character
|
586 |
+
*/
|
587 |
+
async function importFromJson(uploadPath, { request }, preservedFileName) {
|
588 |
+
const data = fs.readFileSync(uploadPath, 'utf8');
|
589 |
+
fs.unlinkSync(uploadPath);
|
590 |
+
|
591 |
+
let jsonData = JSON.parse(data);
|
592 |
+
|
593 |
+
if (jsonData.spec !== undefined) {
|
594 |
+
console.log(`Importing from ${jsonData.spec} json`);
|
595 |
+
importRisuSprites(request.user.directories, jsonData);
|
596 |
+
unsetFavFlag(jsonData);
|
597 |
+
jsonData = readFromV2(jsonData);
|
598 |
+
jsonData['create_date'] = humanizedISO8601DateTime();
|
599 |
+
const pngName = preservedFileName || getPngName(jsonData.data?.name || jsonData.name, request.user.directories);
|
600 |
+
const char = JSON.stringify(jsonData);
|
601 |
+
const result = await writeCharacterData(defaultAvatarPath, char, pngName, request);
|
602 |
+
return result ? pngName : '';
|
603 |
+
} else if (jsonData.name !== undefined) {
|
604 |
+
console.log('Importing from v1 json');
|
605 |
+
jsonData.name = sanitize(jsonData.name);
|
606 |
+
if (jsonData.creator_notes) {
|
607 |
+
jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', '');
|
608 |
+
}
|
609 |
+
const pngName = preservedFileName || getPngName(jsonData.name, request.user.directories);
|
610 |
+
let char = {
|
611 |
+
'name': jsonData.name,
|
612 |
+
'description': jsonData.description ?? '',
|
613 |
+
'creatorcomment': jsonData.creatorcomment ?? jsonData.creator_notes ?? '',
|
614 |
+
'personality': jsonData.personality ?? '',
|
615 |
+
'first_mes': jsonData.first_mes ?? '',
|
616 |
+
'avatar': 'none',
|
617 |
+
'chat': jsonData.name + ' - ' + humanizedISO8601DateTime(),
|
618 |
+
'mes_example': jsonData.mes_example ?? '',
|
619 |
+
'scenario': jsonData.scenario ?? '',
|
620 |
+
'create_date': humanizedISO8601DateTime(),
|
621 |
+
'talkativeness': jsonData.talkativeness ?? 0.5,
|
622 |
+
'creator': jsonData.creator ?? '',
|
623 |
+
'tags': jsonData.tags ?? '',
|
624 |
+
};
|
625 |
+
char = convertToV2(char, request.user.directories);
|
626 |
+
let charJSON = JSON.stringify(char);
|
627 |
+
const result = await writeCharacterData(defaultAvatarPath, charJSON, pngName, request);
|
628 |
+
return result ? pngName : '';
|
629 |
+
} else if (jsonData.char_name !== undefined) {//json Pygmalion notepad
|
630 |
+
console.log('Importing from gradio json');
|
631 |
+
jsonData.char_name = sanitize(jsonData.char_name);
|
632 |
+
if (jsonData.creator_notes) {
|
633 |
+
jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', '');
|
634 |
+
}
|
635 |
+
const pngName = preservedFileName || getPngName(jsonData.char_name, request.user.directories);
|
636 |
+
let char = {
|
637 |
+
'name': jsonData.char_name,
|
638 |
+
'description': jsonData.char_persona ?? '',
|
639 |
+
'creatorcomment': jsonData.creatorcomment ?? jsonData.creator_notes ?? '',
|
640 |
+
'personality': '',
|
641 |
+
'first_mes': jsonData.char_greeting ?? '',
|
642 |
+
'avatar': 'none',
|
643 |
+
'chat': jsonData.name + ' - ' + humanizedISO8601DateTime(),
|
644 |
+
'mes_example': jsonData.example_dialogue ?? '',
|
645 |
+
'scenario': jsonData.world_scenario ?? '',
|
646 |
+
'create_date': humanizedISO8601DateTime(),
|
647 |
+
'talkativeness': jsonData.talkativeness ?? 0.5,
|
648 |
+
'creator': jsonData.creator ?? '',
|
649 |
+
'tags': jsonData.tags ?? '',
|
650 |
+
};
|
651 |
+
char = convertToV2(char, request.user.directories);
|
652 |
+
const charJSON = JSON.stringify(char);
|
653 |
+
const result = await writeCharacterData(defaultAvatarPath, charJSON, pngName, request);
|
654 |
+
return result ? pngName : '';
|
655 |
+
}
|
656 |
+
|
657 |
+
return '';
|
658 |
+
}
|
659 |
+
|
660 |
+
/**
|
661 |
+
* Import a character from a PNG file.
|
662 |
+
* @param {string} uploadPath Path to the uploaded file
|
663 |
+
* @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects
|
664 |
+
* @param {string|undefined} preservedFileName Preserved file name
|
665 |
+
* @returns {Promise<string>} Internal name of the character
|
666 |
+
*/
|
667 |
+
async function importFromPng(uploadPath, { request }, preservedFileName) {
|
668 |
+
const imgData = await readCharacterData(uploadPath);
|
669 |
+
if (imgData === undefined) throw new Error('Failed to read character data');
|
670 |
+
|
671 |
+
let jsonData = JSON.parse(imgData);
|
672 |
+
|
673 |
+
jsonData.name = sanitize(jsonData.data?.name || jsonData.name);
|
674 |
+
const pngName = preservedFileName || getPngName(jsonData.name, request.user.directories);
|
675 |
+
|
676 |
+
if (jsonData.spec !== undefined) {
|
677 |
+
console.log(`Found a ${jsonData.spec} character file.`);
|
678 |
+
importRisuSprites(request.user.directories, jsonData);
|
679 |
+
unsetFavFlag(jsonData);
|
680 |
+
jsonData = readFromV2(jsonData);
|
681 |
+
jsonData['create_date'] = humanizedISO8601DateTime();
|
682 |
+
const char = JSON.stringify(jsonData);
|
683 |
+
const result = await writeCharacterData(uploadPath, char, pngName, request);
|
684 |
+
fs.unlinkSync(uploadPath);
|
685 |
+
return result ? pngName : '';
|
686 |
+
} else if (jsonData.name !== undefined) {
|
687 |
+
console.log('Found a v1 character file.');
|
688 |
+
|
689 |
+
if (jsonData.creator_notes) {
|
690 |
+
jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', '');
|
691 |
+
}
|
692 |
+
|
693 |
+
let char = {
|
694 |
+
'name': jsonData.name,
|
695 |
+
'description': jsonData.description ?? '',
|
696 |
+
'creatorcomment': jsonData.creatorcomment ?? jsonData.creator_notes ?? '',
|
697 |
+
'personality': jsonData.personality ?? '',
|
698 |
+
'first_mes': jsonData.first_mes ?? '',
|
699 |
+
'avatar': 'none',
|
700 |
+
'chat': jsonData.name + ' - ' + humanizedISO8601DateTime(),
|
701 |
+
'mes_example': jsonData.mes_example ?? '',
|
702 |
+
'scenario': jsonData.scenario ?? '',
|
703 |
+
'create_date': humanizedISO8601DateTime(),
|
704 |
+
'talkativeness': jsonData.talkativeness ?? 0.5,
|
705 |
+
'creator': jsonData.creator ?? '',
|
706 |
+
'tags': jsonData.tags ?? '',
|
707 |
+
};
|
708 |
+
char = convertToV2(char, request.user.directories);
|
709 |
+
const charJSON = JSON.stringify(char);
|
710 |
+
const result = await writeCharacterData(uploadPath, charJSON, pngName, request);
|
711 |
+
fs.unlinkSync(uploadPath);
|
712 |
+
return result ? pngName : '';
|
713 |
+
}
|
714 |
+
|
715 |
+
return '';
|
716 |
+
}
|
717 |
+
|
718 |
+
const router = express.Router();
|
719 |
+
|
720 |
+
router.post('/create', urlencodedParser, async function (request, response) {
|
721 |
+
try {
|
722 |
+
if (!request.body) return response.sendStatus(400);
|
723 |
+
|
724 |
+
request.body.ch_name = sanitize(request.body.ch_name);
|
725 |
+
|
726 |
+
const char = JSON.stringify(charaFormatData(request.body, request.user.directories));
|
727 |
+
const internalName = getPngName(request.body.ch_name, request.user.directories);
|
728 |
+
const avatarName = `${internalName}.png`;
|
729 |
+
const defaultAvatar = './public/img/ai4.png';
|
730 |
+
const chatsPath = path.join(request.user.directories.chats, internalName);
|
731 |
+
|
732 |
+
if (!fs.existsSync(chatsPath)) fs.mkdirSync(chatsPath);
|
733 |
+
|
734 |
+
if (!request.file) {
|
735 |
+
await writeCharacterData(defaultAvatar, char, internalName, request);
|
736 |
+
return response.send(avatarName);
|
737 |
+
} else {
|
738 |
+
const crop = tryParse(request.query.crop);
|
739 |
+
const uploadPath = path.join(request.file.destination, request.file.filename);
|
740 |
+
await writeCharacterData(uploadPath, char, internalName, request, crop);
|
741 |
+
fs.unlinkSync(uploadPath);
|
742 |
+
return response.send(avatarName);
|
743 |
+
}
|
744 |
+
} catch (err) {
|
745 |
+
console.error(err);
|
746 |
+
response.sendStatus(500);
|
747 |
+
}
|
748 |
+
});
|
749 |
+
|
750 |
+
router.post('/rename', jsonParser, async function (request, response) {
|
751 |
+
if (!request.body.avatar_url || !request.body.new_name) {
|
752 |
+
return response.sendStatus(400);
|
753 |
+
}
|
754 |
+
|
755 |
+
const oldAvatarName = request.body.avatar_url;
|
756 |
+
const newName = sanitize(request.body.new_name);
|
757 |
+
const oldInternalName = path.parse(request.body.avatar_url).name;
|
758 |
+
const newInternalName = getPngName(newName, request.user.directories);
|
759 |
+
const newAvatarName = `${newInternalName}.png`;
|
760 |
+
|
761 |
+
const oldAvatarPath = path.join(request.user.directories.characters, oldAvatarName);
|
762 |
+
|
763 |
+
const oldChatsPath = path.join(request.user.directories.chats, oldInternalName);
|
764 |
+
const newChatsPath = path.join(request.user.directories.chats, newInternalName);
|
765 |
+
|
766 |
+
try {
|
767 |
+
// Read old file, replace name int it
|
768 |
+
const rawOldData = await readCharacterData(oldAvatarPath);
|
769 |
+
if (rawOldData === undefined) throw new Error('Failed to read character file');
|
770 |
+
|
771 |
+
const oldData = getCharaCardV2(JSON.parse(rawOldData), request.user.directories);
|
772 |
+
_.set(oldData, 'data.name', newName);
|
773 |
+
_.set(oldData, 'name', newName);
|
774 |
+
const newData = JSON.stringify(oldData);
|
775 |
+
|
776 |
+
// Write data to new location
|
777 |
+
await writeCharacterData(oldAvatarPath, newData, newInternalName, request);
|
778 |
+
|
779 |
+
// Rename chats folder
|
780 |
+
if (fs.existsSync(oldChatsPath) && !fs.existsSync(newChatsPath)) {
|
781 |
+
fs.cpSync(oldChatsPath, newChatsPath, { recursive: true });
|
782 |
+
fs.rmSync(oldChatsPath, { recursive: true, force: true });
|
783 |
+
}
|
784 |
+
|
785 |
+
// Remove the old character file
|
786 |
+
fs.rmSync(oldAvatarPath);
|
787 |
+
|
788 |
+
// Return new avatar name to ST
|
789 |
+
return response.send({ avatar: newAvatarName });
|
790 |
+
}
|
791 |
+
catch (err) {
|
792 |
+
console.error(err);
|
793 |
+
return response.sendStatus(500);
|
794 |
+
}
|
795 |
+
});
|
796 |
+
|
797 |
+
router.post('/edit', urlencodedParser, async function (request, response) {
|
798 |
+
if (!request.body) {
|
799 |
+
console.error('Error: no response body detected');
|
800 |
+
response.status(400).send('Error: no response body detected');
|
801 |
+
return;
|
802 |
+
}
|
803 |
+
|
804 |
+
if (request.body.ch_name === '' || request.body.ch_name === undefined || request.body.ch_name === '.') {
|
805 |
+
console.error('Error: invalid name.');
|
806 |
+
response.status(400).send('Error: invalid name.');
|
807 |
+
return;
|
808 |
+
}
|
809 |
+
|
810 |
+
let char = charaFormatData(request.body, request.user.directories);
|
811 |
+
char.chat = request.body.chat;
|
812 |
+
char.create_date = request.body.create_date;
|
813 |
+
char = JSON.stringify(char);
|
814 |
+
let targetFile = (request.body.avatar_url).replace('.png', '');
|
815 |
+
|
816 |
+
try {
|
817 |
+
if (!request.file) {
|
818 |
+
const avatarPath = path.join(request.user.directories.characters, request.body.avatar_url);
|
819 |
+
await writeCharacterData(avatarPath, char, targetFile, request);
|
820 |
+
} else {
|
821 |
+
const crop = tryParse(request.query.crop);
|
822 |
+
const newAvatarPath = path.join(request.file.destination, request.file.filename);
|
823 |
+
invalidateThumbnail(request.user.directories, 'avatar', request.body.avatar_url);
|
824 |
+
await writeCharacterData(newAvatarPath, char, targetFile, request, crop);
|
825 |
+
fs.unlinkSync(newAvatarPath);
|
826 |
+
}
|
827 |
+
|
828 |
+
return response.sendStatus(200);
|
829 |
+
}
|
830 |
+
catch {
|
831 |
+
console.error('An error occured, character edit invalidated.');
|
832 |
+
}
|
833 |
+
});
|
834 |
+
|
835 |
+
|
836 |
+
/**
|
837 |
+
* Handle a POST request to edit a character attribute.
|
838 |
+
*
|
839 |
+
* This function reads the character data from a file, updates the specified attribute,
|
840 |
+
* and writes the updated data back to the file.
|
841 |
+
*
|
842 |
+
* @param {Object} request - The HTTP request object.
|
843 |
+
* @param {Object} response - The HTTP response object.
|
844 |
+
* @returns {void}
|
845 |
+
*/
|
846 |
+
router.post('/edit-attribute', jsonParser, async function (request, response) {
|
847 |
+
console.log(request.body);
|
848 |
+
if (!request.body) {
|
849 |
+
console.error('Error: no response body detected');
|
850 |
+
return response.status(400).send('Error: no response body detected');
|
851 |
+
}
|
852 |
+
|
853 |
+
if (request.body.ch_name === '' || request.body.ch_name === undefined || request.body.ch_name === '.') {
|
854 |
+
console.error('Error: invalid name.');
|
855 |
+
return response.status(400).send('Error: invalid name.');
|
856 |
+
}
|
857 |
+
|
858 |
+
try {
|
859 |
+
const avatarPath = path.join(request.user.directories.characters, request.body.avatar_url);
|
860 |
+
const charJSON = await readCharacterData(avatarPath);
|
861 |
+
if (typeof charJSON !== 'string') throw new Error('Failed to read character file');
|
862 |
+
|
863 |
+
const char = JSON.parse(charJSON);
|
864 |
+
//check if the field exists
|
865 |
+
if (char[request.body.field] === undefined && char.data[request.body.field] === undefined) {
|
866 |
+
console.error('Error: invalid field.');
|
867 |
+
response.status(400).send('Error: invalid field.');
|
868 |
+
return;
|
869 |
+
}
|
870 |
+
char[request.body.field] = request.body.value;
|
871 |
+
char.data[request.body.field] = request.body.value;
|
872 |
+
let newCharJSON = JSON.stringify(char);
|
873 |
+
const targetFile = (request.body.avatar_url).replace('.png', '');
|
874 |
+
await writeCharacterData(avatarPath, newCharJSON, targetFile, request);
|
875 |
+
return response.sendStatus(200);
|
876 |
+
} catch (err) {
|
877 |
+
console.error('An error occured, character edit invalidated.', err);
|
878 |
+
}
|
879 |
+
});
|
880 |
+
|
881 |
+
/**
|
882 |
+
* Handle a POST request to edit character properties.
|
883 |
+
*
|
884 |
+
* Merges the request body with the selected character and
|
885 |
+
* validates the result against TavernCard V2 specification.
|
886 |
+
*
|
887 |
+
* @param {Object} request - The HTTP request object.
|
888 |
+
* @param {Object} response - The HTTP response object.
|
889 |
+
*
|
890 |
+
* @returns {void}
|
891 |
+
* */
|
892 |
+
router.post('/merge-attributes', jsonParser, async function (request, response) {
|
893 |
+
try {
|
894 |
+
const update = request.body;
|
895 |
+
const avatarPath = path.join(request.user.directories.characters, update.avatar);
|
896 |
+
|
897 |
+
const pngStringData = await readCharacterData(avatarPath);
|
898 |
+
|
899 |
+
if (!pngStringData) {
|
900 |
+
console.error('Error: invalid character file.');
|
901 |
+
return response.status(400).send('Error: invalid character file.');
|
902 |
+
}
|
903 |
+
|
904 |
+
let character = JSON.parse(pngStringData);
|
905 |
+
character = deepMerge(character, update);
|
906 |
+
|
907 |
+
const validator = new TavernCardValidator(character);
|
908 |
+
const targetImg = (update.avatar).replace('.png', '');
|
909 |
+
|
910 |
+
//Accept either V1 or V2.
|
911 |
+
if (validator.validate()) {
|
912 |
+
await writeCharacterData(avatarPath, JSON.stringify(character), targetImg, request);
|
913 |
+
response.sendStatus(200);
|
914 |
+
} else {
|
915 |
+
console.log(validator.lastValidationError);
|
916 |
+
response.status(400).send({ message: `Validation failed for ${character.name}`, error: validator.lastValidationError });
|
917 |
+
}
|
918 |
+
} catch (exception) {
|
919 |
+
response.status(500).send({ message: 'Unexpected error while saving character.', error: exception.toString() });
|
920 |
+
}
|
921 |
+
});
|
922 |
+
|
923 |
+
router.post('/delete', jsonParser, async function (request, response) {
|
924 |
+
if (!request.body || !request.body.avatar_url) {
|
925 |
+
return response.sendStatus(400);
|
926 |
+
}
|
927 |
+
|
928 |
+
if (request.body.avatar_url !== sanitize(request.body.avatar_url)) {
|
929 |
+
console.error('Malicious filename prevented');
|
930 |
+
return response.sendStatus(403);
|
931 |
+
}
|
932 |
+
|
933 |
+
const avatarPath = path.join(request.user.directories.characters, request.body.avatar_url);
|
934 |
+
if (!fs.existsSync(avatarPath)) {
|
935 |
+
return response.sendStatus(400);
|
936 |
+
}
|
937 |
+
|
938 |
+
fs.rmSync(avatarPath);
|
939 |
+
invalidateThumbnail(request.user.directories, 'avatar', request.body.avatar_url);
|
940 |
+
let dir_name = (request.body.avatar_url.replace('.png', ''));
|
941 |
+
|
942 |
+
if (!dir_name.length) {
|
943 |
+
console.error('Malicious dirname prevented');
|
944 |
+
return response.sendStatus(403);
|
945 |
+
}
|
946 |
+
|
947 |
+
if (request.body.delete_chats == true) {
|
948 |
+
try {
|
949 |
+
await fs.promises.rm(path.join(request.user.directories.chats, sanitize(dir_name)), { recursive: true, force: true });
|
950 |
+
} catch (err) {
|
951 |
+
console.error(err);
|
952 |
+
return response.sendStatus(500);
|
953 |
+
}
|
954 |
+
}
|
955 |
+
|
956 |
+
return response.sendStatus(200);
|
957 |
+
});
|
958 |
+
|
959 |
+
/**
|
960 |
+
* HTTP POST endpoint for the "/api/characters/all" route.
|
961 |
+
*
|
962 |
+
* This endpoint is responsible for reading character files from the `charactersPath` directory,
|
963 |
+
* parsing character data, calculating stats for each character and responding with the data.
|
964 |
+
* Stats are calculated only on the first run, on subsequent runs the stats are fetched from
|
965 |
+
* the `charStats` variable.
|
966 |
+
* The stats are calculated by the `calculateStats` function.
|
967 |
+
* The characters are processed by the `processCharacter` function.
|
968 |
+
*
|
969 |
+
* @param {import("express").Request} request The HTTP request object.
|
970 |
+
* @param {import("express").Response} response The HTTP response object.
|
971 |
+
* @return {void}
|
972 |
+
*/
|
973 |
+
router.post('/all', jsonParser, async function (request, response) {
|
974 |
+
try {
|
975 |
+
const files = fs.readdirSync(request.user.directories.characters);
|
976 |
+
const pngFiles = files.filter(file => file.endsWith('.png'));
|
977 |
+
const processingPromises = pngFiles.map(file => processCharacter(file, request.user.directories));
|
978 |
+
const data = (await Promise.all(processingPromises)).filter(c => c.name);
|
979 |
+
return response.send(data);
|
980 |
+
} catch (err) {
|
981 |
+
console.error(err);
|
982 |
+
response.sendStatus(500);
|
983 |
+
}
|
984 |
+
});
|
985 |
+
|
986 |
+
router.post('/get', jsonParser, async function (request, response) {
|
987 |
+
try {
|
988 |
+
if (!request.body) return response.sendStatus(400);
|
989 |
+
const item = request.body.avatar_url;
|
990 |
+
const filePath = path.join(request.user.directories.characters, item);
|
991 |
+
|
992 |
+
if (!fs.existsSync(filePath)) {
|
993 |
+
return response.sendStatus(404);
|
994 |
+
}
|
995 |
+
|
996 |
+
const data = await processCharacter(item, request.user.directories);
|
997 |
+
|
998 |
+
return response.send(data);
|
999 |
+
} catch (err) {
|
1000 |
+
console.error(err);
|
1001 |
+
response.sendStatus(500);
|
1002 |
+
}
|
1003 |
+
});
|
1004 |
+
|
1005 |
+
router.post('/chats', jsonParser, async function (request, response) {
|
1006 |
+
if (!request.body) return response.sendStatus(400);
|
1007 |
+
|
1008 |
+
const characterDirectory = (request.body.avatar_url).replace('.png', '');
|
1009 |
+
|
1010 |
+
try {
|
1011 |
+
const chatsDirectory = path.join(request.user.directories.chats, characterDirectory);
|
1012 |
+
const files = fs.readdirSync(chatsDirectory);
|
1013 |
+
const jsonFiles = files.filter(file => path.extname(file) === '.jsonl');
|
1014 |
+
|
1015 |
+
if (jsonFiles.length === 0) {
|
1016 |
+
response.send({ error: true });
|
1017 |
+
return;
|
1018 |
+
}
|
1019 |
+
|
1020 |
+
if (request.body.simple) {
|
1021 |
+
return response.send(jsonFiles.map(file => ({ file_name: file })));
|
1022 |
+
}
|
1023 |
+
|
1024 |
+
const jsonFilesPromise = jsonFiles.map((file) => {
|
1025 |
+
return new Promise(async (res) => {
|
1026 |
+
const pathToFile = path.join(request.user.directories.chats, characterDirectory, file);
|
1027 |
+
const fileStream = fs.createReadStream(pathToFile);
|
1028 |
+
const stats = fs.statSync(pathToFile);
|
1029 |
+
const fileSizeInKB = `${(stats.size / 1024).toFixed(2)}kb`;
|
1030 |
+
|
1031 |
+
const rl = readline.createInterface({
|
1032 |
+
input: fileStream,
|
1033 |
+
crlfDelay: Infinity,
|
1034 |
+
});
|
1035 |
+
|
1036 |
+
let lastLine;
|
1037 |
+
let itemCounter = 0;
|
1038 |
+
rl.on('line', (line) => {
|
1039 |
+
itemCounter++;
|
1040 |
+
lastLine = line;
|
1041 |
+
});
|
1042 |
+
rl.on('close', () => {
|
1043 |
+
rl.close();
|
1044 |
+
|
1045 |
+
if (lastLine) {
|
1046 |
+
const jsonData = tryParse(lastLine);
|
1047 |
+
if (jsonData && (jsonData.name || jsonData.character_name)) {
|
1048 |
+
const chatData = {};
|
1049 |
+
|
1050 |
+
chatData['file_name'] = file;
|
1051 |
+
chatData['file_size'] = fileSizeInKB;
|
1052 |
+
chatData['chat_items'] = itemCounter - 1;
|
1053 |
+
chatData['mes'] = jsonData['mes'] || '[The chat is empty]';
|
1054 |
+
chatData['last_mes'] = jsonData['send_date'] || Date.now();
|
1055 |
+
|
1056 |
+
res(chatData);
|
1057 |
+
} else {
|
1058 |
+
console.log('Found an invalid or corrupted chat file:', pathToFile);
|
1059 |
+
res({});
|
1060 |
+
}
|
1061 |
+
}
|
1062 |
+
});
|
1063 |
+
});
|
1064 |
+
});
|
1065 |
+
|
1066 |
+
const chatData = await Promise.all(jsonFilesPromise);
|
1067 |
+
const validFiles = chatData.filter(i => i.file_name);
|
1068 |
+
|
1069 |
+
return response.send(validFiles);
|
1070 |
+
} catch (error) {
|
1071 |
+
console.log(error);
|
1072 |
+
return response.send({ error: true });
|
1073 |
+
}
|
1074 |
+
});
|
1075 |
+
|
1076 |
+
/**
|
1077 |
+
* Gets the name for the uploaded PNG file.
|
1078 |
+
* @param {string} file File name
|
1079 |
+
* @param {import('../users').UserDirectoryList} directories User directories
|
1080 |
+
* @returns {string} - The name for the uploaded PNG file
|
1081 |
+
*/
|
1082 |
+
function getPngName(file, directories) {
|
1083 |
+
let i = 1;
|
1084 |
+
const baseName = file;
|
1085 |
+
while (fs.existsSync(path.join(directories.characters, `${file}.png`))) {
|
1086 |
+
file = baseName + i;
|
1087 |
+
i++;
|
1088 |
+
}
|
1089 |
+
return file;
|
1090 |
+
}
|
1091 |
+
|
1092 |
+
/**
|
1093 |
+
* Gets the preserved name for the uploaded file if the request is valid.
|
1094 |
+
* @param {import("express").Request} request - Express request object
|
1095 |
+
* @returns {string | undefined} - The preserved name if the request is valid, otherwise undefined
|
1096 |
+
*/
|
1097 |
+
function getPreservedName(request) {
|
1098 |
+
return typeof request.body.preserved_name === 'string' && request.body.preserved_name.length > 0
|
1099 |
+
? path.parse(request.body.preserved_name).name
|
1100 |
+
: undefined;
|
1101 |
+
}
|
1102 |
+
|
1103 |
+
router.post('/import', urlencodedParser, async function (request, response) {
|
1104 |
+
if (!request.body || !request.file) return response.sendStatus(400);
|
1105 |
+
|
1106 |
+
const uploadPath = path.join(request.file.destination, request.file.filename);
|
1107 |
+
const format = request.body.file_type;
|
1108 |
+
const preservedFileName = getPreservedName(request);
|
1109 |
+
|
1110 |
+
const formatImportFunctions = {
|
1111 |
+
'yaml': importFromYaml,
|
1112 |
+
'yml': importFromYaml,
|
1113 |
+
'json': importFromJson,
|
1114 |
+
'png': importFromPng,
|
1115 |
+
'charx': importFromCharX,
|
1116 |
+
};
|
1117 |
+
|
1118 |
+
try {
|
1119 |
+
const importFunction = formatImportFunctions[format];
|
1120 |
+
|
1121 |
+
if (!importFunction) {
|
1122 |
+
throw new Error(`Unsupported format: ${format}`);
|
1123 |
+
}
|
1124 |
+
|
1125 |
+
const fileName = await importFunction(uploadPath, { request, response }, preservedFileName);
|
1126 |
+
|
1127 |
+
if (!fileName) {
|
1128 |
+
console.error('Failed to import character');
|
1129 |
+
return response.sendStatus(400);
|
1130 |
+
}
|
1131 |
+
|
1132 |
+
if (preservedFileName) {
|
1133 |
+
invalidateThumbnail(request.user.directories, 'avatar', `${preservedFileName}.png`);
|
1134 |
+
}
|
1135 |
+
|
1136 |
+
response.send({ file_name: fileName });
|
1137 |
+
} catch (err) {
|
1138 |
+
console.log(err);
|
1139 |
+
response.send({ error: true });
|
1140 |
+
}
|
1141 |
+
});
|
1142 |
+
|
1143 |
+
router.post('/duplicate', jsonParser, async function (request, response) {
|
1144 |
+
try {
|
1145 |
+
if (!request.body.avatar_url) {
|
1146 |
+
console.log('avatar URL not found in request body');
|
1147 |
+
console.log(request.body);
|
1148 |
+
return response.sendStatus(400);
|
1149 |
+
}
|
1150 |
+
let filename = path.join(request.user.directories.characters, sanitize(request.body.avatar_url));
|
1151 |
+
if (!fs.existsSync(filename)) {
|
1152 |
+
console.log('file for dupe not found');
|
1153 |
+
console.log(filename);
|
1154 |
+
return response.sendStatus(404);
|
1155 |
+
}
|
1156 |
+
let suffix = 1;
|
1157 |
+
let newFilename = filename;
|
1158 |
+
|
1159 |
+
// If filename ends with a _number, increment the number
|
1160 |
+
const nameParts = path.basename(filename, path.extname(filename)).split('_');
|
1161 |
+
const lastPart = nameParts[nameParts.length - 1];
|
1162 |
+
|
1163 |
+
let baseName;
|
1164 |
+
|
1165 |
+
if (!isNaN(Number(lastPart)) && nameParts.length > 1) {
|
1166 |
+
suffix = parseInt(lastPart) + 1;
|
1167 |
+
baseName = nameParts.slice(0, -1).join('_'); // construct baseName without suffix
|
1168 |
+
} else {
|
1169 |
+
baseName = nameParts.join('_'); // original filename is completely the baseName
|
1170 |
+
}
|
1171 |
+
|
1172 |
+
newFilename = path.join(request.user.directories.characters, `${baseName}_${suffix}${path.extname(filename)}`);
|
1173 |
+
|
1174 |
+
while (fs.existsSync(newFilename)) {
|
1175 |
+
let suffixStr = '_' + suffix;
|
1176 |
+
newFilename = path.join(request.user.directories.characters, `${baseName}${suffixStr}${path.extname(filename)}`);
|
1177 |
+
suffix++;
|
1178 |
+
}
|
1179 |
+
|
1180 |
+
fs.copyFileSync(filename, newFilename);
|
1181 |
+
console.log(`${filename} was copied to ${newFilename}`);
|
1182 |
+
response.send({ path: path.parse(newFilename).base });
|
1183 |
+
}
|
1184 |
+
catch (error) {
|
1185 |
+
console.error(error);
|
1186 |
+
return response.send({ error: true });
|
1187 |
+
}
|
1188 |
+
});
|
1189 |
+
|
1190 |
+
router.post('/export', jsonParser, async function (request, response) {
|
1191 |
+
try {
|
1192 |
+
if (!request.body.format || !request.body.avatar_url) {
|
1193 |
+
return response.sendStatus(400);
|
1194 |
+
}
|
1195 |
+
|
1196 |
+
let filename = path.join(request.user.directories.characters, sanitize(request.body.avatar_url));
|
1197 |
+
|
1198 |
+
if (!fs.existsSync(filename)) {
|
1199 |
+
return response.sendStatus(404);
|
1200 |
+
}
|
1201 |
+
|
1202 |
+
switch (request.body.format) {
|
1203 |
+
case 'png': {
|
1204 |
+
const fileContent = await fsPromises.readFile(filename);
|
1205 |
+
const contentType = mime.lookup(filename) || 'image/png';
|
1206 |
+
response.setHeader('Content-Type', contentType);
|
1207 |
+
response.setHeader('Content-Disposition', `attachment; filename="${encodeURI(path.basename(filename))}"`);
|
1208 |
+
return response.send(fileContent);
|
1209 |
+
}
|
1210 |
+
case 'json': {
|
1211 |
+
try {
|
1212 |
+
let json = await readCharacterData(filename);
|
1213 |
+
if (json === undefined) return response.sendStatus(400);
|
1214 |
+
let jsonObject = getCharaCardV2(JSON.parse(json), request.user.directories);
|
1215 |
+
return response.type('json').send(JSON.stringify(jsonObject, null, 4));
|
1216 |
+
}
|
1217 |
+
catch {
|
1218 |
+
return response.sendStatus(400);
|
1219 |
+
}
|
1220 |
+
}
|
1221 |
+
}
|
1222 |
+
|
1223 |
+
return response.sendStatus(400);
|
1224 |
+
} catch (err) {
|
1225 |
+
console.error('Character export failed', err);
|
1226 |
+
response.sendStatus(500);
|
1227 |
+
}
|
1228 |
+
});
|
1229 |
+
|
1230 |
+
module.exports = { router };
|
src/endpoints/chats.js
ADDED
@@ -0,0 +1,461 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const fs = require('fs');
|
2 |
+
const path = require('path');
|
3 |
+
const readline = require('readline');
|
4 |
+
const express = require('express');
|
5 |
+
const sanitize = require('sanitize-filename');
|
6 |
+
const writeFileAtomicSync = require('write-file-atomic').sync;
|
7 |
+
|
8 |
+
const { jsonParser, urlencodedParser } = require('../express-common');
|
9 |
+
const { getConfigValue, humanizedISO8601DateTime, tryParse, generateTimestamp, removeOldBackups } = require('../util');
|
10 |
+
|
11 |
+
/**
|
12 |
+
* Saves a chat to the backups directory.
|
13 |
+
* @param {string} directory The user's backups directory.
|
14 |
+
* @param {string} name The name of the chat.
|
15 |
+
* @param {string} chat The serialized chat to save.
|
16 |
+
*/
|
17 |
+
function backupChat(directory, name, chat) {
|
18 |
+
try {
|
19 |
+
const isBackupDisabled = getConfigValue('disableChatBackup', false);
|
20 |
+
|
21 |
+
if (isBackupDisabled) {
|
22 |
+
return;
|
23 |
+
}
|
24 |
+
|
25 |
+
// replace non-alphanumeric characters with underscores
|
26 |
+
name = sanitize(name).replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
27 |
+
|
28 |
+
const backupFile = path.join(directory, `chat_${name}_${generateTimestamp()}.jsonl`);
|
29 |
+
writeFileAtomicSync(backupFile, chat, 'utf-8');
|
30 |
+
|
31 |
+
removeOldBackups(directory, `chat_${name}_`);
|
32 |
+
} catch (err) {
|
33 |
+
console.log(`Could not backup chat for ${name}`, err);
|
34 |
+
}
|
35 |
+
}
|
36 |
+
|
37 |
+
/**
|
38 |
+
* Imports a chat from Ooba's format.
|
39 |
+
* @param {string} userName User name
|
40 |
+
* @param {string} characterName Character name
|
41 |
+
* @param {object} jsonData JSON data
|
42 |
+
* @returns {string} Chat data
|
43 |
+
*/
|
44 |
+
function importOobaChat(userName, characterName, jsonData) {
|
45 |
+
/** @type {object[]} */
|
46 |
+
const chat = [{
|
47 |
+
user_name: userName,
|
48 |
+
character_name: characterName,
|
49 |
+
create_date: humanizedISO8601DateTime(),
|
50 |
+
}];
|
51 |
+
|
52 |
+
for (const arr of jsonData.data_visible) {
|
53 |
+
if (arr[0]) {
|
54 |
+
const userMessage = {
|
55 |
+
name: userName,
|
56 |
+
is_user: true,
|
57 |
+
send_date: humanizedISO8601DateTime(),
|
58 |
+
mes: arr[0],
|
59 |
+
};
|
60 |
+
chat.push(userMessage);
|
61 |
+
}
|
62 |
+
if (arr[1]) {
|
63 |
+
const charMessage = {
|
64 |
+
name: characterName,
|
65 |
+
is_user: false,
|
66 |
+
send_date: humanizedISO8601DateTime(),
|
67 |
+
mes: arr[1],
|
68 |
+
};
|
69 |
+
chat.push(charMessage);
|
70 |
+
}
|
71 |
+
}
|
72 |
+
|
73 |
+
const chatContent = chat.map(obj => JSON.stringify(obj)).join('\n');
|
74 |
+
return chatContent;
|
75 |
+
}
|
76 |
+
|
77 |
+
/**
|
78 |
+
* Imports a chat from Agnai's format.
|
79 |
+
* @param {string} userName User name
|
80 |
+
* @param {string} characterName Character name
|
81 |
+
* @param {object} jsonData Chat data
|
82 |
+
* @returns {string} Chat data
|
83 |
+
*/
|
84 |
+
function importAgnaiChat(userName, characterName, jsonData) {
|
85 |
+
/** @type {object[]} */
|
86 |
+
const chat = [{
|
87 |
+
user_name: userName,
|
88 |
+
character_name: characterName,
|
89 |
+
create_date: humanizedISO8601DateTime(),
|
90 |
+
}];
|
91 |
+
|
92 |
+
for (const message of jsonData.messages) {
|
93 |
+
const isUser = !!message.userId;
|
94 |
+
chat.push({
|
95 |
+
name: isUser ? userName : characterName,
|
96 |
+
is_user: isUser,
|
97 |
+
send_date: humanizedISO8601DateTime(),
|
98 |
+
mes: message.msg,
|
99 |
+
});
|
100 |
+
}
|
101 |
+
|
102 |
+
const chatContent = chat.map(obj => JSON.stringify(obj)).join('\n');
|
103 |
+
return chatContent;
|
104 |
+
}
|
105 |
+
|
106 |
+
/**
|
107 |
+
* Imports a chat from CAI Tools format.
|
108 |
+
* @param {string} userName User name
|
109 |
+
* @param {string} characterName Character name
|
110 |
+
* @param {object} jsonData JSON data
|
111 |
+
* @returns {string[]} Converted data
|
112 |
+
*/
|
113 |
+
function importCAIChat(userName, characterName, jsonData) {
|
114 |
+
/**
|
115 |
+
* Converts the chat data to suitable format.
|
116 |
+
* @param {object} history Imported chat data
|
117 |
+
* @returns {object[]} Converted chat data
|
118 |
+
*/
|
119 |
+
function convert(history) {
|
120 |
+
const starter = {
|
121 |
+
user_name: userName,
|
122 |
+
character_name: characterName,
|
123 |
+
create_date: humanizedISO8601DateTime(),
|
124 |
+
};
|
125 |
+
|
126 |
+
const historyData = history.msgs.map((msg) => ({
|
127 |
+
name: msg.src.is_human ? userName : characterName,
|
128 |
+
is_user: msg.src.is_human,
|
129 |
+
send_date: humanizedISO8601DateTime(),
|
130 |
+
mes: msg.text,
|
131 |
+
}));
|
132 |
+
|
133 |
+
return [starter, ...historyData];
|
134 |
+
}
|
135 |
+
|
136 |
+
const newChats = (jsonData.histories.histories ?? []).map(history => newChats.push(convert(history).map(obj => JSON.stringify(obj)).join('\n')));
|
137 |
+
return newChats;
|
138 |
+
}
|
139 |
+
|
140 |
+
const router = express.Router();
|
141 |
+
|
142 |
+
router.post('/save', jsonParser, function (request, response) {
|
143 |
+
try {
|
144 |
+
const directoryName = String(request.body.avatar_url).replace('.png', '');
|
145 |
+
const chatData = request.body.chat;
|
146 |
+
const jsonlData = chatData.map(JSON.stringify).join('\n');
|
147 |
+
const fileName = `${sanitize(String(request.body.file_name))}.jsonl`;
|
148 |
+
const filePath = path.join(request.user.directories.chats, directoryName, fileName);
|
149 |
+
writeFileAtomicSync(filePath, jsonlData, 'utf8');
|
150 |
+
backupChat(request.user.directories.backups, directoryName, jsonlData);
|
151 |
+
return response.send({ result: 'ok' });
|
152 |
+
} catch (error) {
|
153 |
+
response.send(error);
|
154 |
+
return console.log(error);
|
155 |
+
}
|
156 |
+
});
|
157 |
+
|
158 |
+
router.post('/get', jsonParser, function (request, response) {
|
159 |
+
try {
|
160 |
+
const dirName = String(request.body.avatar_url).replace('.png', '');
|
161 |
+
const directoryPath = path.join(request.user.directories.chats, dirName);
|
162 |
+
const chatDirExists = fs.existsSync(directoryPath);
|
163 |
+
|
164 |
+
//if no chat dir for the character is found, make one with the character name
|
165 |
+
if (!chatDirExists) {
|
166 |
+
fs.mkdirSync(directoryPath);
|
167 |
+
return response.send({});
|
168 |
+
}
|
169 |
+
|
170 |
+
if (!request.body.file_name) {
|
171 |
+
return response.send({});
|
172 |
+
}
|
173 |
+
|
174 |
+
const fileName = path.join(directoryPath, `${sanitize(String(request.body.file_name))}.jsonl`);
|
175 |
+
const chatFileExists = fs.existsSync(fileName);
|
176 |
+
|
177 |
+
if (!chatFileExists) {
|
178 |
+
return response.send({});
|
179 |
+
}
|
180 |
+
|
181 |
+
const data = fs.readFileSync(fileName, 'utf8');
|
182 |
+
const lines = data.split('\n');
|
183 |
+
|
184 |
+
// Iterate through the array of strings and parse each line as JSON
|
185 |
+
const jsonData = lines.map((l) => { try { return JSON.parse(l); } catch (_) { return; } }).filter(x => x);
|
186 |
+
return response.send(jsonData);
|
187 |
+
} catch (error) {
|
188 |
+
console.error(error);
|
189 |
+
return response.send({});
|
190 |
+
}
|
191 |
+
});
|
192 |
+
|
193 |
+
|
194 |
+
router.post('/rename', jsonParser, async function (request, response) {
|
195 |
+
if (!request.body || !request.body.original_file || !request.body.renamed_file) {
|
196 |
+
return response.sendStatus(400);
|
197 |
+
}
|
198 |
+
|
199 |
+
const pathToFolder = request.body.is_group
|
200 |
+
? request.user.directories.groupChats
|
201 |
+
: path.join(request.user.directories.chats, String(request.body.avatar_url).replace('.png', ''));
|
202 |
+
const pathToOriginalFile = path.join(pathToFolder, request.body.original_file);
|
203 |
+
const pathToRenamedFile = path.join(pathToFolder, request.body.renamed_file);
|
204 |
+
console.log('Old chat name', pathToOriginalFile);
|
205 |
+
console.log('New chat name', pathToRenamedFile);
|
206 |
+
|
207 |
+
if (!fs.existsSync(pathToOriginalFile) || fs.existsSync(pathToRenamedFile)) {
|
208 |
+
console.log('Either Source or Destination files are not available');
|
209 |
+
return response.status(400).send({ error: true });
|
210 |
+
}
|
211 |
+
|
212 |
+
fs.copyFileSync(pathToOriginalFile, pathToRenamedFile);
|
213 |
+
fs.rmSync(pathToOriginalFile);
|
214 |
+
console.log('Successfully renamed.');
|
215 |
+
return response.send({ ok: true });
|
216 |
+
});
|
217 |
+
|
218 |
+
router.post('/delete', jsonParser, function (request, response) {
|
219 |
+
if (!request.body) {
|
220 |
+
console.log('no request body seen');
|
221 |
+
return response.sendStatus(400);
|
222 |
+
}
|
223 |
+
|
224 |
+
if (request.body.chatfile !== sanitize(request.body.chatfile)) {
|
225 |
+
console.error('Malicious chat name prevented');
|
226 |
+
return response.sendStatus(403);
|
227 |
+
}
|
228 |
+
|
229 |
+
const dirName = String(request.body.avatar_url).replace('.png', '');
|
230 |
+
const fileName = path.join(request.user.directories.chats, dirName, sanitize(String(request.body.chatfile)));
|
231 |
+
const chatFileExists = fs.existsSync(fileName);
|
232 |
+
|
233 |
+
if (!chatFileExists) {
|
234 |
+
console.log(`Chat file not found '${fileName}'`);
|
235 |
+
return response.sendStatus(400);
|
236 |
+
} else {
|
237 |
+
fs.rmSync(fileName);
|
238 |
+
console.log('Deleted chat file: ' + fileName);
|
239 |
+
}
|
240 |
+
|
241 |
+
return response.send('ok');
|
242 |
+
});
|
243 |
+
|
244 |
+
router.post('/export', jsonParser, async function (request, response) {
|
245 |
+
if (!request.body.file || (!request.body.avatar_url && request.body.is_group === false)) {
|
246 |
+
return response.sendStatus(400);
|
247 |
+
}
|
248 |
+
const pathToFolder = request.body.is_group
|
249 |
+
? request.user.directories.groupChats
|
250 |
+
: path.join(request.user.directories.chats, String(request.body.avatar_url).replace('.png', ''));
|
251 |
+
let filename = path.join(pathToFolder, request.body.file);
|
252 |
+
let exportfilename = request.body.exportfilename;
|
253 |
+
if (!fs.existsSync(filename)) {
|
254 |
+
const errorMessage = {
|
255 |
+
message: `Could not find JSONL file to export. Source chat file: ${filename}.`,
|
256 |
+
};
|
257 |
+
console.log(errorMessage.message);
|
258 |
+
return response.status(404).json(errorMessage);
|
259 |
+
}
|
260 |
+
try {
|
261 |
+
// Short path for JSONL files
|
262 |
+
if (request.body.format == 'jsonl') {
|
263 |
+
try {
|
264 |
+
const rawFile = fs.readFileSync(filename, 'utf8');
|
265 |
+
const successMessage = {
|
266 |
+
message: `Chat saved to ${exportfilename}`,
|
267 |
+
result: rawFile,
|
268 |
+
};
|
269 |
+
|
270 |
+
console.log(`Chat exported as ${exportfilename}`);
|
271 |
+
return response.status(200).json(successMessage);
|
272 |
+
}
|
273 |
+
catch (err) {
|
274 |
+
console.error(err);
|
275 |
+
const errorMessage = {
|
276 |
+
message: `Could not read JSONL file to export. Source chat file: ${filename}.`,
|
277 |
+
};
|
278 |
+
console.log(errorMessage.message);
|
279 |
+
return response.status(500).json(errorMessage);
|
280 |
+
}
|
281 |
+
}
|
282 |
+
|
283 |
+
const readStream = fs.createReadStream(filename);
|
284 |
+
const rl = readline.createInterface({
|
285 |
+
input: readStream,
|
286 |
+
});
|
287 |
+
let buffer = '';
|
288 |
+
rl.on('line', (line) => {
|
289 |
+
const data = JSON.parse(line);
|
290 |
+
// Skip non-printable/prompt-hidden messages
|
291 |
+
if (data.is_system) {
|
292 |
+
return;
|
293 |
+
}
|
294 |
+
if (data.mes) {
|
295 |
+
const name = data.name;
|
296 |
+
const message = (data?.extra?.display_text || data?.mes || '').replace(/\r?\n/g, '\n');
|
297 |
+
buffer += (`${name}: ${message}\n\n`);
|
298 |
+
}
|
299 |
+
});
|
300 |
+
rl.on('close', () => {
|
301 |
+
const successMessage = {
|
302 |
+
message: `Chat saved to ${exportfilename}`,
|
303 |
+
result: buffer,
|
304 |
+
};
|
305 |
+
console.log(`Chat exported as ${exportfilename}`);
|
306 |
+
return response.status(200).json(successMessage);
|
307 |
+
});
|
308 |
+
}
|
309 |
+
catch (err) {
|
310 |
+
console.log('chat export failed.');
|
311 |
+
console.log(err);
|
312 |
+
return response.sendStatus(400);
|
313 |
+
}
|
314 |
+
});
|
315 |
+
|
316 |
+
router.post('/group/import', urlencodedParser, function (request, response) {
|
317 |
+
try {
|
318 |
+
const filedata = request.file;
|
319 |
+
|
320 |
+
if (!filedata) {
|
321 |
+
return response.sendStatus(400);
|
322 |
+
}
|
323 |
+
|
324 |
+
const chatname = humanizedISO8601DateTime();
|
325 |
+
const pathToUpload = path.join(filedata.destination, filedata.filename);
|
326 |
+
const pathToNewFile = path.join(request.user.directories.groupChats, `${chatname}.jsonl`);
|
327 |
+
fs.copyFileSync(pathToUpload, pathToNewFile);
|
328 |
+
fs.unlinkSync(pathToUpload);
|
329 |
+
return response.send({ res: chatname });
|
330 |
+
} catch (error) {
|
331 |
+
console.error(error);
|
332 |
+
return response.send({ error: true });
|
333 |
+
}
|
334 |
+
});
|
335 |
+
|
336 |
+
router.post('/import', urlencodedParser, function (request, response) {
|
337 |
+
if (!request.body) return response.sendStatus(400);
|
338 |
+
|
339 |
+
const format = request.body.file_type;
|
340 |
+
const avatarUrl = (request.body.avatar_url).replace('.png', '');
|
341 |
+
const characterName = request.body.character_name;
|
342 |
+
const userName = request.body.user_name || 'You';
|
343 |
+
|
344 |
+
if (!request.file) {
|
345 |
+
return response.sendStatus(400);
|
346 |
+
}
|
347 |
+
|
348 |
+
try {
|
349 |
+
const pathToUpload = path.join(request.file.destination, request.file.filename);
|
350 |
+
const data = fs.readFileSync(pathToUpload, 'utf8');
|
351 |
+
|
352 |
+
if (format === 'json') {
|
353 |
+
fs.unlinkSync(pathToUpload);
|
354 |
+
const jsonData = JSON.parse(data);
|
355 |
+
if (jsonData.histories !== undefined) {
|
356 |
+
// CAI Tools format
|
357 |
+
const chats = importCAIChat(userName, characterName, jsonData);
|
358 |
+
for (const chat of chats) {
|
359 |
+
const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`;
|
360 |
+
const filePath = path.join(request.user.directories.chats, avatarUrl, fileName);
|
361 |
+
writeFileAtomicSync(filePath, chat, 'utf8');
|
362 |
+
}
|
363 |
+
return response.send({ res: true });
|
364 |
+
} else if (Array.isArray(jsonData.data_visible)) {
|
365 |
+
// oobabooga's format
|
366 |
+
const chat = importOobaChat(userName, characterName, jsonData);
|
367 |
+
const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`;
|
368 |
+
const filePath = path.join(request.user.directories.chats, avatarUrl, fileName);
|
369 |
+
writeFileAtomicSync(filePath, chat, 'utf8');
|
370 |
+
return response.send({ res: true });
|
371 |
+
} else if (Array.isArray(jsonData.messages)) {
|
372 |
+
// Agnai format
|
373 |
+
const chat = importAgnaiChat(userName, characterName, jsonData);
|
374 |
+
const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`;
|
375 |
+
const filePath = path.join(request.user.directories.chats, avatarUrl, fileName);
|
376 |
+
writeFileAtomicSync(filePath, chat, 'utf8');
|
377 |
+
return response.send({ res: true });
|
378 |
+
} else {
|
379 |
+
console.log('Incorrect chat format .json');
|
380 |
+
return response.send({ error: true });
|
381 |
+
}
|
382 |
+
}
|
383 |
+
|
384 |
+
if (format === 'jsonl') {
|
385 |
+
const line = data.split('\n')[0];
|
386 |
+
|
387 |
+
const jsonData = JSON.parse(line);
|
388 |
+
|
389 |
+
if (jsonData.user_name !== undefined || jsonData.name !== undefined) {
|
390 |
+
const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`;
|
391 |
+
const filePath = path.join(request.user.directories.chats, avatarUrl, fileName);
|
392 |
+
fs.copyFileSync(pathToUpload, filePath);
|
393 |
+
fs.unlinkSync(pathToUpload);
|
394 |
+
response.send({ res: true });
|
395 |
+
} else {
|
396 |
+
console.log('Incorrect chat format .jsonl');
|
397 |
+
return response.send({ error: true });
|
398 |
+
}
|
399 |
+
}
|
400 |
+
} catch (error) {
|
401 |
+
console.error(error);
|
402 |
+
return response.send({ error: true });
|
403 |
+
}
|
404 |
+
});
|
405 |
+
|
406 |
+
router.post('/group/get', jsonParser, (request, response) => {
|
407 |
+
if (!request.body || !request.body.id) {
|
408 |
+
return response.sendStatus(400);
|
409 |
+
}
|
410 |
+
|
411 |
+
const id = request.body.id;
|
412 |
+
const pathToFile = path.join(request.user.directories.groupChats, `${id}.jsonl`);
|
413 |
+
|
414 |
+
if (fs.existsSync(pathToFile)) {
|
415 |
+
const data = fs.readFileSync(pathToFile, 'utf8');
|
416 |
+
const lines = data.split('\n');
|
417 |
+
|
418 |
+
// Iterate through the array of strings and parse each line as JSON
|
419 |
+
const jsonData = lines.map(line => tryParse(line)).filter(x => x);
|
420 |
+
return response.send(jsonData);
|
421 |
+
} else {
|
422 |
+
return response.send([]);
|
423 |
+
}
|
424 |
+
});
|
425 |
+
|
426 |
+
router.post('/group/delete', jsonParser, (request, response) => {
|
427 |
+
if (!request.body || !request.body.id) {
|
428 |
+
return response.sendStatus(400);
|
429 |
+
}
|
430 |
+
|
431 |
+
const id = request.body.id;
|
432 |
+
const pathToFile = path.join(request.user.directories.groupChats, `${id}.jsonl`);
|
433 |
+
|
434 |
+
if (fs.existsSync(pathToFile)) {
|
435 |
+
fs.rmSync(pathToFile);
|
436 |
+
return response.send({ ok: true });
|
437 |
+
}
|
438 |
+
|
439 |
+
return response.send({ error: true });
|
440 |
+
});
|
441 |
+
|
442 |
+
router.post('/group/save', jsonParser, (request, response) => {
|
443 |
+
if (!request.body || !request.body.id) {
|
444 |
+
return response.sendStatus(400);
|
445 |
+
}
|
446 |
+
|
447 |
+
const id = request.body.id;
|
448 |
+
const pathToFile = path.join(request.user.directories.groupChats, `${id}.jsonl`);
|
449 |
+
|
450 |
+
if (!fs.existsSync(request.user.directories.groupChats)) {
|
451 |
+
fs.mkdirSync(request.user.directories.groupChats);
|
452 |
+
}
|
453 |
+
|
454 |
+
let chat_data = request.body.chat;
|
455 |
+
let jsonlData = chat_data.map(JSON.stringify).join('\n');
|
456 |
+
writeFileAtomicSync(pathToFile, jsonlData, 'utf8');
|
457 |
+
backupChat(request.user.directories.backups, String(id), jsonlData);
|
458 |
+
return response.send({ ok: true });
|
459 |
+
});
|
460 |
+
|
461 |
+
module.exports = { router };
|
src/endpoints/classify.js
ADDED
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const express = require('express');
|
2 |
+
const { jsonParser } = require('../express-common');
|
3 |
+
|
4 |
+
const TASK = 'text-classification';
|
5 |
+
|
6 |
+
const router = express.Router();
|
7 |
+
|
8 |
+
/**
|
9 |
+
* @type {Map<string, object>} Cache for classification results
|
10 |
+
*/
|
11 |
+
const cacheObject = new Map();
|
12 |
+
|
13 |
+
router.post('/labels', jsonParser, async (req, res) => {
|
14 |
+
try {
|
15 |
+
const module = await import('../transformers.mjs');
|
16 |
+
const pipe = await module.default.getPipeline(TASK);
|
17 |
+
const result = Object.keys(pipe.model.config.label2id);
|
18 |
+
return res.json({ labels: result });
|
19 |
+
} catch (error) {
|
20 |
+
console.error(error);
|
21 |
+
return res.sendStatus(500);
|
22 |
+
}
|
23 |
+
});
|
24 |
+
|
25 |
+
router.post('/', jsonParser, async (req, res) => {
|
26 |
+
try {
|
27 |
+
const { text } = req.body;
|
28 |
+
|
29 |
+
/**
|
30 |
+
* Get classification result for a given text
|
31 |
+
* @param {string} text Text to classify
|
32 |
+
* @returns {Promise<object>} Classification result
|
33 |
+
*/
|
34 |
+
async function getResult(text) {
|
35 |
+
if (cacheObject.has(text)) {
|
36 |
+
return cacheObject.get(text);
|
37 |
+
} else {
|
38 |
+
const module = await import('../transformers.mjs');
|
39 |
+
const pipe = await module.default.getPipeline(TASK);
|
40 |
+
const result = await pipe(text, { topk: 5 });
|
41 |
+
result.sort((a, b) => b.score - a.score);
|
42 |
+
cacheObject.set(text, result);
|
43 |
+
return result;
|
44 |
+
}
|
45 |
+
}
|
46 |
+
|
47 |
+
console.log('Classify input:', text);
|
48 |
+
const result = await getResult(text);
|
49 |
+
console.log('Classify output:', result);
|
50 |
+
|
51 |
+
return res.json({ classification: result });
|
52 |
+
} catch (error) {
|
53 |
+
console.error(error);
|
54 |
+
return res.sendStatus(500);
|
55 |
+
}
|
56 |
+
});
|
57 |
+
|
58 |
+
module.exports = { router };
|
src/endpoints/content-manager.js
ADDED
@@ -0,0 +1,725 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const fs = require('fs');
|
2 |
+
const path = require('path');
|
3 |
+
const express = require('express');
|
4 |
+
const fetch = require('node-fetch').default;
|
5 |
+
const sanitize = require('sanitize-filename');
|
6 |
+
const { getConfigValue, color } = require('../util');
|
7 |
+
const { jsonParser } = require('../express-common');
|
8 |
+
const writeFileAtomicSync = require('write-file-atomic').sync;
|
9 |
+
const contentDirectory = path.join(process.cwd(), 'default/content');
|
10 |
+
const scaffoldDirectory = path.join(process.cwd(), 'default/scaffold');
|
11 |
+
const contentIndexPath = path.join(contentDirectory, 'index.json');
|
12 |
+
const scaffoldIndexPath = path.join(scaffoldDirectory, 'index.json');
|
13 |
+
const characterCardParser = require('../character-card-parser.js');
|
14 |
+
|
15 |
+
const WHITELIST_GENERIC_URL_DOWNLOAD_SOURCES = getConfigValue('whitelistImportDomains', []);
|
16 |
+
|
17 |
+
/**
|
18 |
+
* @typedef {Object} ContentItem
|
19 |
+
* @property {string} filename
|
20 |
+
* @property {string} type
|
21 |
+
* @property {string} [name]
|
22 |
+
* @property {string|null} [folder]
|
23 |
+
*/
|
24 |
+
|
25 |
+
/**
|
26 |
+
* @typedef {string} ContentType
|
27 |
+
* @enum {string}
|
28 |
+
*/
|
29 |
+
const CONTENT_TYPES = {
|
30 |
+
SETTINGS: 'settings',
|
31 |
+
CHARACTER: 'character',
|
32 |
+
SPRITES: 'sprites',
|
33 |
+
BACKGROUND: 'background',
|
34 |
+
WORLD: 'world',
|
35 |
+
AVATAR: 'avatar',
|
36 |
+
THEME: 'theme',
|
37 |
+
WORKFLOW: 'workflow',
|
38 |
+
KOBOLD_PRESET: 'kobold_preset',
|
39 |
+
OPENAI_PRESET: 'openai_preset',
|
40 |
+
NOVEL_PRESET: 'novel_preset',
|
41 |
+
TEXTGEN_PRESET: 'textgen_preset',
|
42 |
+
INSTRUCT: 'instruct',
|
43 |
+
CONTEXT: 'context',
|
44 |
+
MOVING_UI: 'moving_ui',
|
45 |
+
QUICK_REPLIES: 'quick_replies',
|
46 |
+
};
|
47 |
+
|
48 |
+
/**
|
49 |
+
* Gets the default presets from the content directory.
|
50 |
+
* @param {import('../users').UserDirectoryList} directories User directories
|
51 |
+
* @returns {object[]} Array of default presets
|
52 |
+
*/
|
53 |
+
function getDefaultPresets(directories) {
|
54 |
+
try {
|
55 |
+
const contentIndex = getContentIndex();
|
56 |
+
const presets = [];
|
57 |
+
|
58 |
+
for (const contentItem of contentIndex) {
|
59 |
+
if (contentItem.type.endsWith('_preset') || contentItem.type === 'instruct' || contentItem.type === 'context') {
|
60 |
+
contentItem.name = path.parse(contentItem.filename).name;
|
61 |
+
contentItem.folder = getTargetByType(contentItem.type, directories);
|
62 |
+
presets.push(contentItem);
|
63 |
+
}
|
64 |
+
}
|
65 |
+
|
66 |
+
return presets;
|
67 |
+
} catch (err) {
|
68 |
+
console.log('Failed to get default presets', err);
|
69 |
+
return [];
|
70 |
+
}
|
71 |
+
}
|
72 |
+
|
73 |
+
/**
|
74 |
+
* Gets a default JSON file from the content directory.
|
75 |
+
* @param {string} filename Name of the file to get
|
76 |
+
* @returns {object | null} JSON object or null if the file doesn't exist
|
77 |
+
*/
|
78 |
+
function getDefaultPresetFile(filename) {
|
79 |
+
try {
|
80 |
+
const contentPath = path.join(contentDirectory, filename);
|
81 |
+
|
82 |
+
if (!fs.existsSync(contentPath)) {
|
83 |
+
return null;
|
84 |
+
}
|
85 |
+
|
86 |
+
const fileContent = fs.readFileSync(contentPath, 'utf8');
|
87 |
+
return JSON.parse(fileContent);
|
88 |
+
} catch (err) {
|
89 |
+
console.log(`Failed to get default file ${filename}`, err);
|
90 |
+
return null;
|
91 |
+
}
|
92 |
+
}
|
93 |
+
|
94 |
+
/**
|
95 |
+
* Seeds content for a user.
|
96 |
+
* @param {ContentItem[]} contentIndex Content index
|
97 |
+
* @param {import('../users').UserDirectoryList} directories User directories
|
98 |
+
* @param {string[]} forceCategories List of categories to force check (even if content check is skipped)
|
99 |
+
* @returns {Promise<boolean>} Whether any content was added
|
100 |
+
*/
|
101 |
+
async function seedContentForUser(contentIndex, directories, forceCategories) {
|
102 |
+
let anyContentAdded = false;
|
103 |
+
|
104 |
+
if (!fs.existsSync(directories.root)) {
|
105 |
+
fs.mkdirSync(directories.root, { recursive: true });
|
106 |
+
}
|
107 |
+
|
108 |
+
const contentLogPath = path.join(directories.root, 'content.log');
|
109 |
+
const contentLog = getContentLog(contentLogPath);
|
110 |
+
|
111 |
+
for (const contentItem of contentIndex) {
|
112 |
+
// If the content item is already in the log, skip it
|
113 |
+
if (contentLog.includes(contentItem.filename) && !forceCategories?.includes(contentItem.type)) {
|
114 |
+
continue;
|
115 |
+
}
|
116 |
+
|
117 |
+
if (!contentItem.folder) {
|
118 |
+
console.log(`Content file ${contentItem.filename} has no parent folder`);
|
119 |
+
continue;
|
120 |
+
}
|
121 |
+
|
122 |
+
const contentPath = path.join(contentItem.folder, contentItem.filename);
|
123 |
+
|
124 |
+
if (!fs.existsSync(contentPath)) {
|
125 |
+
console.log(`Content file ${contentItem.filename} is missing`);
|
126 |
+
continue;
|
127 |
+
}
|
128 |
+
|
129 |
+
const contentTarget = getTargetByType(contentItem.type, directories);
|
130 |
+
|
131 |
+
if (!contentTarget) {
|
132 |
+
console.log(`Content file ${contentItem.filename} has unknown type ${contentItem.type}`);
|
133 |
+
continue;
|
134 |
+
}
|
135 |
+
|
136 |
+
const basePath = path.parse(contentItem.filename).base;
|
137 |
+
const targetPath = path.join(contentTarget, basePath);
|
138 |
+
contentLog.push(contentItem.filename);
|
139 |
+
|
140 |
+
if (fs.existsSync(targetPath)) {
|
141 |
+
console.log(`Content file ${contentItem.filename} already exists in ${contentTarget}`);
|
142 |
+
continue;
|
143 |
+
}
|
144 |
+
|
145 |
+
fs.cpSync(contentPath, targetPath, { recursive: true, force: false });
|
146 |
+
console.log(`Content file ${contentItem.filename} copied to ${contentTarget}`);
|
147 |
+
anyContentAdded = true;
|
148 |
+
}
|
149 |
+
|
150 |
+
writeFileAtomicSync(contentLogPath, contentLog.join('\n'));
|
151 |
+
return anyContentAdded;
|
152 |
+
}
|
153 |
+
|
154 |
+
/**
|
155 |
+
* Checks for new content and seeds it for all users.
|
156 |
+
* @param {import('../users').UserDirectoryList[]} directoriesList List of user directories
|
157 |
+
* @param {string[]} forceCategories List of categories to force check (even if content check is skipped)
|
158 |
+
* @returns {Promise<void>}
|
159 |
+
*/
|
160 |
+
async function checkForNewContent(directoriesList, forceCategories = []) {
|
161 |
+
try {
|
162 |
+
const contentCheckSkip = getConfigValue('skipContentCheck', false);
|
163 |
+
if (contentCheckSkip && forceCategories?.length === 0) {
|
164 |
+
return;
|
165 |
+
}
|
166 |
+
|
167 |
+
const contentIndex = getContentIndex();
|
168 |
+
let anyContentAdded = false;
|
169 |
+
|
170 |
+
for (const directories of directoriesList) {
|
171 |
+
const seedResult = await seedContentForUser(contentIndex, directories, forceCategories);
|
172 |
+
|
173 |
+
if (seedResult) {
|
174 |
+
anyContentAdded = true;
|
175 |
+
}
|
176 |
+
}
|
177 |
+
|
178 |
+
if (anyContentAdded && !contentCheckSkip && forceCategories?.length === 0) {
|
179 |
+
console.log();
|
180 |
+
console.log(`${color.blue('If you don\'t want to receive content updates in the future, set')} ${color.yellow('skipContentCheck')} ${color.blue('to true in the config.yaml file.')}`);
|
181 |
+
console.log();
|
182 |
+
}
|
183 |
+
} catch (err) {
|
184 |
+
console.log('Content check failed', err);
|
185 |
+
}
|
186 |
+
}
|
187 |
+
|
188 |
+
/**
|
189 |
+
* Gets combined content index from the content and scaffold directories.
|
190 |
+
* @returns {ContentItem[]} Array of content index
|
191 |
+
*/
|
192 |
+
function getContentIndex() {
|
193 |
+
const result = [];
|
194 |
+
|
195 |
+
if (fs.existsSync(scaffoldIndexPath)) {
|
196 |
+
const scaffoldIndexText = fs.readFileSync(scaffoldIndexPath, 'utf8');
|
197 |
+
const scaffoldIndex = JSON.parse(scaffoldIndexText);
|
198 |
+
if (Array.isArray(scaffoldIndex)) {
|
199 |
+
scaffoldIndex.forEach((item) => {
|
200 |
+
item.folder = scaffoldDirectory;
|
201 |
+
});
|
202 |
+
result.push(...scaffoldIndex);
|
203 |
+
}
|
204 |
+
}
|
205 |
+
|
206 |
+
if (fs.existsSync(contentIndexPath)) {
|
207 |
+
const contentIndexText = fs.readFileSync(contentIndexPath, 'utf8');
|
208 |
+
const contentIndex = JSON.parse(contentIndexText);
|
209 |
+
if (Array.isArray(contentIndex)) {
|
210 |
+
contentIndex.forEach((item) => {
|
211 |
+
item.folder = contentDirectory;
|
212 |
+
});
|
213 |
+
result.push(...contentIndex);
|
214 |
+
}
|
215 |
+
}
|
216 |
+
|
217 |
+
return result;
|
218 |
+
}
|
219 |
+
|
220 |
+
/**
|
221 |
+
* Gets the target directory for the specified asset type.
|
222 |
+
* @param {ContentType} type Asset type
|
223 |
+
* @param {import('../users').UserDirectoryList} directories User directories
|
224 |
+
* @returns {string | null} Target directory
|
225 |
+
*/
|
226 |
+
function getTargetByType(type, directories) {
|
227 |
+
switch (type) {
|
228 |
+
case CONTENT_TYPES.SETTINGS:
|
229 |
+
return directories.root;
|
230 |
+
case CONTENT_TYPES.CHARACTER:
|
231 |
+
return directories.characters;
|
232 |
+
case CONTENT_TYPES.SPRITES:
|
233 |
+
return directories.characters;
|
234 |
+
case CONTENT_TYPES.BACKGROUND:
|
235 |
+
return directories.backgrounds;
|
236 |
+
case CONTENT_TYPES.WORLD:
|
237 |
+
return directories.worlds;
|
238 |
+
case CONTENT_TYPES.AVATAR:
|
239 |
+
return directories.avatars;
|
240 |
+
case CONTENT_TYPES.THEME:
|
241 |
+
return directories.themes;
|
242 |
+
case CONTENT_TYPES.WORKFLOW:
|
243 |
+
return directories.comfyWorkflows;
|
244 |
+
case CONTENT_TYPES.KOBOLD_PRESET:
|
245 |
+
return directories.koboldAI_Settings;
|
246 |
+
case CONTENT_TYPES.OPENAI_PRESET:
|
247 |
+
return directories.openAI_Settings;
|
248 |
+
case CONTENT_TYPES.NOVEL_PRESET:
|
249 |
+
return directories.novelAI_Settings;
|
250 |
+
case CONTENT_TYPES.TEXTGEN_PRESET:
|
251 |
+
return directories.textGen_Settings;
|
252 |
+
case CONTENT_TYPES.INSTRUCT:
|
253 |
+
return directories.instruct;
|
254 |
+
case CONTENT_TYPES.CONTEXT:
|
255 |
+
return directories.context;
|
256 |
+
case CONTENT_TYPES.MOVING_UI:
|
257 |
+
return directories.movingUI;
|
258 |
+
case CONTENT_TYPES.QUICK_REPLIES:
|
259 |
+
return directories.quickreplies;
|
260 |
+
default:
|
261 |
+
return null;
|
262 |
+
}
|
263 |
+
}
|
264 |
+
|
265 |
+
/**
|
266 |
+
* Gets the content log from the content log file.
|
267 |
+
* @param {string} contentLogPath Path to the content log file
|
268 |
+
* @returns {string[]} Array of content log lines
|
269 |
+
*/
|
270 |
+
function getContentLog(contentLogPath) {
|
271 |
+
if (!fs.existsSync(contentLogPath)) {
|
272 |
+
return [];
|
273 |
+
}
|
274 |
+
|
275 |
+
const contentLogText = fs.readFileSync(contentLogPath, 'utf8');
|
276 |
+
return contentLogText.split('\n');
|
277 |
+
}
|
278 |
+
|
279 |
+
async function downloadChubLorebook(id) {
|
280 |
+
const result = await fetch('https://api.chub.ai/api/lorebooks/download', {
|
281 |
+
method: 'POST',
|
282 |
+
headers: { 'Content-Type': 'application/json' },
|
283 |
+
body: JSON.stringify({
|
284 |
+
'fullPath': id,
|
285 |
+
'format': 'SILLYTAVERN',
|
286 |
+
}),
|
287 |
+
});
|
288 |
+
|
289 |
+
if (!result.ok) {
|
290 |
+
const text = await result.text();
|
291 |
+
console.log('Chub returned error', result.statusText, text);
|
292 |
+
throw new Error('Failed to download lorebook');
|
293 |
+
}
|
294 |
+
|
295 |
+
const name = id.split('/').pop();
|
296 |
+
const buffer = await result.buffer();
|
297 |
+
const fileName = `${sanitize(name)}.json`;
|
298 |
+
const fileType = result.headers.get('content-type');
|
299 |
+
|
300 |
+
return { buffer, fileName, fileType };
|
301 |
+
}
|
302 |
+
|
303 |
+
async function downloadChubCharacter(id) {
|
304 |
+
const result = await fetch('https://api.chub.ai/api/characters/download', {
|
305 |
+
method: 'POST',
|
306 |
+
headers: { 'Content-Type': 'application/json' },
|
307 |
+
body: JSON.stringify({
|
308 |
+
'format': 'tavern',
|
309 |
+
'fullPath': id,
|
310 |
+
}),
|
311 |
+
});
|
312 |
+
|
313 |
+
if (!result.ok) {
|
314 |
+
const text = await result.text();
|
315 |
+
console.log('Chub returned error', result.statusText, text);
|
316 |
+
throw new Error('Failed to download character');
|
317 |
+
}
|
318 |
+
|
319 |
+
const buffer = await result.buffer();
|
320 |
+
const fileName = result.headers.get('content-disposition')?.split('filename=')[1] || `${sanitize(id)}.png`;
|
321 |
+
const fileType = result.headers.get('content-type');
|
322 |
+
|
323 |
+
return { buffer, fileName, fileType };
|
324 |
+
}
|
325 |
+
|
326 |
+
/**
|
327 |
+
* Downloads a character card from the Pygsite.
|
328 |
+
* @param {string} id UUID of the character
|
329 |
+
* @returns {Promise<{buffer: Buffer, fileName: string, fileType: string}>}
|
330 |
+
*/
|
331 |
+
async function downloadPygmalionCharacter(id) {
|
332 |
+
const result = await fetch(`https://server.pygmalion.chat/api/export/character/${id}/v2`);
|
333 |
+
|
334 |
+
if (!result.ok) {
|
335 |
+
const text = await result.text();
|
336 |
+
console.log('Pygsite returned error', result.status, text);
|
337 |
+
throw new Error('Failed to download character');
|
338 |
+
}
|
339 |
+
|
340 |
+
const jsonData = await result.json();
|
341 |
+
const characterData = jsonData?.character;
|
342 |
+
|
343 |
+
if (!characterData || typeof characterData !== 'object') {
|
344 |
+
console.error('Pygsite returned invalid character data', jsonData);
|
345 |
+
throw new Error('Failed to download character');
|
346 |
+
}
|
347 |
+
|
348 |
+
try {
|
349 |
+
const avatarUrl = characterData?.data?.avatar;
|
350 |
+
|
351 |
+
if (!avatarUrl) {
|
352 |
+
console.error('Pygsite character does not have an avatar', characterData);
|
353 |
+
throw new Error('Failed to download avatar');
|
354 |
+
}
|
355 |
+
|
356 |
+
const avatarResult = await fetch(avatarUrl);
|
357 |
+
const avatarBuffer = await avatarResult.buffer();
|
358 |
+
|
359 |
+
const cardBuffer = characterCardParser.write(avatarBuffer, JSON.stringify(characterData));
|
360 |
+
|
361 |
+
return {
|
362 |
+
buffer: cardBuffer,
|
363 |
+
fileName: `${sanitize(id)}.png`,
|
364 |
+
fileType: 'image/png',
|
365 |
+
};
|
366 |
+
} catch (e) {
|
367 |
+
console.error('Failed to download avatar, using JSON instead', e);
|
368 |
+
return {
|
369 |
+
buffer: Buffer.from(JSON.stringify(jsonData)),
|
370 |
+
fileName: `${sanitize(id)}.json`,
|
371 |
+
fileType: 'application/json',
|
372 |
+
};
|
373 |
+
}
|
374 |
+
}
|
375 |
+
|
376 |
+
/**
|
377 |
+
*
|
378 |
+
* @param {String} str
|
379 |
+
* @returns { { id: string, type: "character" | "lorebook" } | null }
|
380 |
+
*/
|
381 |
+
function parseChubUrl(str) {
|
382 |
+
const splitStr = str.split('/');
|
383 |
+
const length = splitStr.length;
|
384 |
+
|
385 |
+
if (length < 2) {
|
386 |
+
return null;
|
387 |
+
}
|
388 |
+
|
389 |
+
let domainIndex = -1;
|
390 |
+
|
391 |
+
splitStr.forEach((part, index) => {
|
392 |
+
if (part === 'www.chub.ai' || part === 'chub.ai' || part === 'www.characterhub.org' || part === 'characterhub.org') {
|
393 |
+
domainIndex = index;
|
394 |
+
}
|
395 |
+
});
|
396 |
+
|
397 |
+
const lastTwo = domainIndex !== -1 ? splitStr.slice(domainIndex + 1) : splitStr;
|
398 |
+
|
399 |
+
const firstPart = lastTwo[0].toLowerCase();
|
400 |
+
|
401 |
+
if (firstPart === 'characters' || firstPart === 'lorebooks') {
|
402 |
+
const type = firstPart === 'characters' ? 'character' : 'lorebook';
|
403 |
+
const id = type === 'character' ? lastTwo.slice(1).join('/') : lastTwo.join('/');
|
404 |
+
return {
|
405 |
+
id: id,
|
406 |
+
type: type,
|
407 |
+
};
|
408 |
+
} else if (length === 2) {
|
409 |
+
return {
|
410 |
+
id: lastTwo.join('/'),
|
411 |
+
type: 'character',
|
412 |
+
};
|
413 |
+
}
|
414 |
+
|
415 |
+
return null;
|
416 |
+
}
|
417 |
+
|
418 |
+
// Warning: Some characters might not exist in JannyAI.me
|
419 |
+
async function downloadJannyCharacter(uuid) {
|
420 |
+
// This endpoint is being guarded behind Bot Fight Mode of Cloudflare
|
421 |
+
// So hosted ST on Azure/AWS/GCP/Collab might get blocked by IP
|
422 |
+
// Should work normally on self-host PC/Android
|
423 |
+
const result = await fetch('https://api.jannyai.com/api/v1/download', {
|
424 |
+
method: 'POST',
|
425 |
+
headers: { 'Content-Type': 'application/json' },
|
426 |
+
body: JSON.stringify({
|
427 |
+
'characterId': uuid,
|
428 |
+
}),
|
429 |
+
});
|
430 |
+
|
431 |
+
if (result.ok) {
|
432 |
+
const downloadResult = await result.json();
|
433 |
+
if (downloadResult.status === 'ok') {
|
434 |
+
const imageResult = await fetch(downloadResult.downloadUrl);
|
435 |
+
const buffer = await imageResult.buffer();
|
436 |
+
const fileName = `${sanitize(uuid)}.png`;
|
437 |
+
const fileType = imageResult.headers.get('content-type');
|
438 |
+
|
439 |
+
return { buffer, fileName, fileType };
|
440 |
+
}
|
441 |
+
}
|
442 |
+
|
443 |
+
console.log('Janny returned error', result.statusText, await result.text());
|
444 |
+
throw new Error('Failed to download character');
|
445 |
+
}
|
446 |
+
|
447 |
+
//Download Character Cards from AICharactersCards.com (AICC) API.
|
448 |
+
async function downloadAICCCharacter(id) {
|
449 |
+
const apiURL = `https://aicharactercards.com/wp-json/pngapi/v1/image/${id}`;
|
450 |
+
try {
|
451 |
+
const response = await fetch(apiURL);
|
452 |
+
if (!response.ok) {
|
453 |
+
throw new Error(`Failed to download character: ${response.statusText}`);
|
454 |
+
}
|
455 |
+
|
456 |
+
const contentType = response.headers.get('content-type') || 'image/png'; // Default to 'image/png' if header is missing
|
457 |
+
const buffer = await response.buffer();
|
458 |
+
const fileName = `${sanitize(id)}.png`; // Assuming PNG, but adjust based on actual content or headers
|
459 |
+
|
460 |
+
return {
|
461 |
+
buffer: buffer,
|
462 |
+
fileName: fileName,
|
463 |
+
fileType: contentType,
|
464 |
+
};
|
465 |
+
} catch (error) {
|
466 |
+
console.error('Error downloading character:', error);
|
467 |
+
throw error;
|
468 |
+
}
|
469 |
+
}
|
470 |
+
|
471 |
+
/**
|
472 |
+
* Parses an aicharactercards URL to extract the path.
|
473 |
+
* @param {string} url URL to parse
|
474 |
+
* @returns {string | null} AICC path
|
475 |
+
*/
|
476 |
+
function parseAICC(url) {
|
477 |
+
const pattern = /^https?:\/\/aicharactercards\.com\/character-cards\/([^/]+)\/([^/]+)\/?$|([^/]+)\/([^/]+)$/;
|
478 |
+
const match = url.match(pattern);
|
479 |
+
if (match) {
|
480 |
+
// Match group 1 & 2 for full URL, 3 & 4 for relative path
|
481 |
+
return match[1] && match[2] ? `${match[1]}/${match[2]}` : `${match[3]}/${match[4]}`;
|
482 |
+
}
|
483 |
+
return null;
|
484 |
+
}
|
485 |
+
|
486 |
+
/**
|
487 |
+
* Download character card from generic url.
|
488 |
+
* @param {String} url
|
489 |
+
*/
|
490 |
+
async function downloadGenericPng(url) {
|
491 |
+
try {
|
492 |
+
const result = await fetch(url);
|
493 |
+
|
494 |
+
if (result.ok) {
|
495 |
+
const buffer = await result.buffer();
|
496 |
+
const fileName = sanitize(result.url.split('?')[0].split('/').reverse()[0]);
|
497 |
+
const contentType = result.headers.get('content-type') || 'image/png'; //yoink it from AICC function lol
|
498 |
+
|
499 |
+
return {
|
500 |
+
buffer: buffer,
|
501 |
+
fileName: fileName,
|
502 |
+
fileType: contentType,
|
503 |
+
};
|
504 |
+
}
|
505 |
+
} catch (error) {
|
506 |
+
console.error('Error downloading file: ', error);
|
507 |
+
throw error;
|
508 |
+
}
|
509 |
+
return null;
|
510 |
+
}
|
511 |
+
|
512 |
+
/**
|
513 |
+
* Parse Risu Realm URL to extract the UUID.
|
514 |
+
* @param {string} url Risu Realm URL
|
515 |
+
* @returns {string | null} UUID of the character
|
516 |
+
*/
|
517 |
+
function parseRisuUrl(url) {
|
518 |
+
// Example: https://realm.risuai.net/character/7adb0ed8d81855c820b3506980fb40f054ceef010ff0c4bab73730c0ebe92279
|
519 |
+
// or https://realm.risuai.net/character/7adb0ed8-d818-55c8-20b3-506980fb40f0
|
520 |
+
const pattern = /^https?:\/\/realm\.risuai\.net\/character\/([a-f0-9-]+)\/?$/i;
|
521 |
+
const match = url.match(pattern);
|
522 |
+
return match ? match[1] : null;
|
523 |
+
}
|
524 |
+
|
525 |
+
/**
|
526 |
+
* Download RisuAI character card
|
527 |
+
* @param {string} uuid UUID of the character
|
528 |
+
* @returns {Promise<{buffer: Buffer, fileName: string, fileType: string}>}
|
529 |
+
*/
|
530 |
+
async function downloadRisuCharacter(uuid) {
|
531 |
+
const result = await fetch(`https://realm.risuai.net/api/v1/download/png-v3/${uuid}?non_commercial=true`);
|
532 |
+
|
533 |
+
if (!result.ok) {
|
534 |
+
const text = await result.text();
|
535 |
+
console.log('RisuAI returned error', result.statusText, text);
|
536 |
+
throw new Error('Failed to download character');
|
537 |
+
}
|
538 |
+
|
539 |
+
const buffer = await result.buffer();
|
540 |
+
const fileName = `${sanitize(uuid)}.png`;
|
541 |
+
const fileType = 'image/png';
|
542 |
+
|
543 |
+
return { buffer, fileName, fileType };
|
544 |
+
}
|
545 |
+
|
546 |
+
/**
|
547 |
+
* @param {String} url
|
548 |
+
* @returns {String | null } UUID of the character
|
549 |
+
*/
|
550 |
+
function getUuidFromUrl(url) {
|
551 |
+
// Extract UUID from URL
|
552 |
+
const uuidRegex = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/;
|
553 |
+
const matches = url.match(uuidRegex);
|
554 |
+
|
555 |
+
// Check if UUID is found
|
556 |
+
const uuid = matches ? matches[0] : null;
|
557 |
+
return uuid;
|
558 |
+
}
|
559 |
+
|
560 |
+
/**
|
561 |
+
* Filter to get the domain host of a url instead of a blanket string search.
|
562 |
+
* @param {String} url URL to strip
|
563 |
+
* @returns {String} Domain name
|
564 |
+
*/
|
565 |
+
function getHostFromUrl(url) {
|
566 |
+
try {
|
567 |
+
const urlObj = new URL(url);
|
568 |
+
return urlObj.hostname;
|
569 |
+
} catch {
|
570 |
+
return '';
|
571 |
+
}
|
572 |
+
}
|
573 |
+
|
574 |
+
/**
|
575 |
+
* Checks if host is part of generic download source whitelist.
|
576 |
+
* @param {String} host Host to check
|
577 |
+
* @returns {boolean} If the host is on the whitelist.
|
578 |
+
*/
|
579 |
+
function isHostWhitelisted(host) {
|
580 |
+
return WHITELIST_GENERIC_URL_DOWNLOAD_SOURCES.includes(host);
|
581 |
+
}
|
582 |
+
|
583 |
+
const router = express.Router();
|
584 |
+
|
585 |
+
router.post('/importURL', jsonParser, async (request, response) => {
|
586 |
+
if (!request.body.url) {
|
587 |
+
return response.sendStatus(400);
|
588 |
+
}
|
589 |
+
|
590 |
+
try {
|
591 |
+
const url = request.body.url;
|
592 |
+
const host = getHostFromUrl(url);
|
593 |
+
let result;
|
594 |
+
let type;
|
595 |
+
|
596 |
+
const isChub = host.includes('chub.ai') || host.includes('characterhub.org');
|
597 |
+
const isJannnyContent = host.includes('janitorai');
|
598 |
+
const isPygmalionContent = host.includes('pygmalion.chat');
|
599 |
+
const isAICharacterCardsContent = host.includes('aicharactercards.com');
|
600 |
+
const isRisu = host.includes('realm.risuai.net');
|
601 |
+
const isGeneric = isHostWhitelisted(host);
|
602 |
+
|
603 |
+
if (isPygmalionContent) {
|
604 |
+
const uuid = getUuidFromUrl(url);
|
605 |
+
if (!uuid) {
|
606 |
+
return response.sendStatus(404);
|
607 |
+
}
|
608 |
+
|
609 |
+
type = 'character';
|
610 |
+
result = await downloadPygmalionCharacter(uuid);
|
611 |
+
} else if (isJannnyContent) {
|
612 |
+
const uuid = getUuidFromUrl(url);
|
613 |
+
if (!uuid) {
|
614 |
+
return response.sendStatus(404);
|
615 |
+
}
|
616 |
+
|
617 |
+
type = 'character';
|
618 |
+
result = await downloadJannyCharacter(uuid);
|
619 |
+
} else if (isAICharacterCardsContent) {
|
620 |
+
const AICCParsed = parseAICC(url);
|
621 |
+
if (!AICCParsed) {
|
622 |
+
return response.sendStatus(404);
|
623 |
+
}
|
624 |
+
type = 'character';
|
625 |
+
result = await downloadAICCCharacter(AICCParsed);
|
626 |
+
} else if (isChub) {
|
627 |
+
const chubParsed = parseChubUrl(url);
|
628 |
+
type = chubParsed?.type;
|
629 |
+
|
630 |
+
if (chubParsed?.type === 'character') {
|
631 |
+
console.log('Downloading chub character:', chubParsed.id);
|
632 |
+
result = await downloadChubCharacter(chubParsed.id);
|
633 |
+
}
|
634 |
+
else if (chubParsed?.type === 'lorebook') {
|
635 |
+
console.log('Downloading chub lorebook:', chubParsed.id);
|
636 |
+
result = await downloadChubLorebook(chubParsed.id);
|
637 |
+
}
|
638 |
+
else {
|
639 |
+
return response.sendStatus(404);
|
640 |
+
}
|
641 |
+
} else if (isRisu) {
|
642 |
+
const uuid = parseRisuUrl(url);
|
643 |
+
if (!uuid) {
|
644 |
+
return response.sendStatus(404);
|
645 |
+
}
|
646 |
+
|
647 |
+
type = 'character';
|
648 |
+
result = await downloadRisuCharacter(uuid);
|
649 |
+
} else if (isGeneric) {
|
650 |
+
console.log('Downloading from generic url.');
|
651 |
+
type = 'character';
|
652 |
+
result = await downloadGenericPng(url);
|
653 |
+
} else {
|
654 |
+
return response.sendStatus(404);
|
655 |
+
}
|
656 |
+
|
657 |
+
if (!result) {
|
658 |
+
return response.sendStatus(404);
|
659 |
+
}
|
660 |
+
|
661 |
+
if (result.fileType) response.set('Content-Type', result.fileType);
|
662 |
+
response.set('Content-Disposition', `attachment; filename="${encodeURI(result.fileName)}"`);
|
663 |
+
response.set('X-Custom-Content-Type', type);
|
664 |
+
return response.send(result.buffer);
|
665 |
+
} catch (error) {
|
666 |
+
console.log('Importing custom content failed', error);
|
667 |
+
return response.sendStatus(500);
|
668 |
+
}
|
669 |
+
});
|
670 |
+
|
671 |
+
router.post('/importUUID', jsonParser, async (request, response) => {
|
672 |
+
if (!request.body.url) {
|
673 |
+
return response.sendStatus(400);
|
674 |
+
}
|
675 |
+
|
676 |
+
try {
|
677 |
+
const uuid = request.body.url;
|
678 |
+
let result;
|
679 |
+
|
680 |
+
const isJannny = uuid.includes('_character');
|
681 |
+
const isPygmalion = (!isJannny && uuid.length == 36);
|
682 |
+
const isAICC = uuid.startsWith('AICC/');
|
683 |
+
const uuidType = uuid.includes('lorebook') ? 'lorebook' : 'character';
|
684 |
+
|
685 |
+
if (isPygmalion) {
|
686 |
+
console.log('Downloading Pygmalion character:', uuid);
|
687 |
+
result = await downloadPygmalionCharacter(uuid);
|
688 |
+
} else if (isJannny) {
|
689 |
+
console.log('Downloading Janitor character:', uuid.split('_')[0]);
|
690 |
+
result = await downloadJannyCharacter(uuid.split('_')[0]);
|
691 |
+
} else if (isAICC) {
|
692 |
+
const [, author, card] = uuid.split('/');
|
693 |
+
console.log('Downloading AICC character:', `${author}/${card}`);
|
694 |
+
result = await downloadAICCCharacter(`${author}/${card}`);
|
695 |
+
} else {
|
696 |
+
if (uuidType === 'character') {
|
697 |
+
console.log('Downloading chub character:', uuid);
|
698 |
+
result = await downloadChubCharacter(uuid);
|
699 |
+
}
|
700 |
+
else if (uuidType === 'lorebook') {
|
701 |
+
console.log('Downloading chub lorebook:', uuid);
|
702 |
+
result = await downloadChubLorebook(uuid);
|
703 |
+
}
|
704 |
+
else {
|
705 |
+
return response.sendStatus(404);
|
706 |
+
}
|
707 |
+
}
|
708 |
+
|
709 |
+
if (result.fileType) response.set('Content-Type', result.fileType);
|
710 |
+
response.set('Content-Disposition', `attachment; filename="${result.fileName}"`);
|
711 |
+
response.set('X-Custom-Content-Type', uuidType);
|
712 |
+
return response.send(result.buffer);
|
713 |
+
} catch (error) {
|
714 |
+
console.log('Importing custom content failed', error);
|
715 |
+
return response.sendStatus(500);
|
716 |
+
}
|
717 |
+
});
|
718 |
+
|
719 |
+
module.exports = {
|
720 |
+
CONTENT_TYPES,
|
721 |
+
checkForNewContent,
|
722 |
+
getDefaultPresets,
|
723 |
+
getDefaultPresetFile,
|
724 |
+
router,
|
725 |
+
};
|
src/endpoints/extensions.js
ADDED
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const path = require('path');
|
2 |
+
const fs = require('fs');
|
3 |
+
const express = require('express');
|
4 |
+
const { default: simpleGit } = require('simple-git');
|
5 |
+
const sanitize = require('sanitize-filename');
|
6 |
+
const { PUBLIC_DIRECTORIES } = require('../constants');
|
7 |
+
const { jsonParser } = require('../express-common');
|
8 |
+
|
9 |
+
/**
|
10 |
+
* This function extracts the extension information from the manifest file.
|
11 |
+
* @param {string} extensionPath - The path of the extension folder
|
12 |
+
* @returns {Promise<Object>} - Returns the manifest data as an object
|
13 |
+
*/
|
14 |
+
async function getManifest(extensionPath) {
|
15 |
+
const manifestPath = path.join(extensionPath, 'manifest.json');
|
16 |
+
|
17 |
+
// Check if manifest.json exists
|
18 |
+
if (!fs.existsSync(manifestPath)) {
|
19 |
+
throw new Error(`Manifest file not found at ${manifestPath}`);
|
20 |
+
}
|
21 |
+
|
22 |
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
23 |
+
return manifest;
|
24 |
+
}
|
25 |
+
|
26 |
+
/**
|
27 |
+
* This function checks if the local repository is up-to-date with the remote repository.
|
28 |
+
* @param {string} extensionPath - The path of the extension folder
|
29 |
+
* @returns {Promise<Object>} - Returns the extension information as an object
|
30 |
+
*/
|
31 |
+
async function checkIfRepoIsUpToDate(extensionPath) {
|
32 |
+
const git = simpleGit();
|
33 |
+
await git.cwd(extensionPath).fetch('origin');
|
34 |
+
const currentBranch = await git.cwd(extensionPath).branch();
|
35 |
+
const currentCommitHash = await git.cwd(extensionPath).revparse(['HEAD']);
|
36 |
+
const log = await git.cwd(extensionPath).log({
|
37 |
+
from: currentCommitHash,
|
38 |
+
to: `origin/${currentBranch.current}`,
|
39 |
+
});
|
40 |
+
|
41 |
+
// Fetch remote repository information
|
42 |
+
const remotes = await git.cwd(extensionPath).getRemotes(true);
|
43 |
+
|
44 |
+
return {
|
45 |
+
isUpToDate: log.total === 0,
|
46 |
+
remoteUrl: remotes[0].refs.fetch, // URL of the remote repository
|
47 |
+
};
|
48 |
+
}
|
49 |
+
|
50 |
+
const router = express.Router();
|
51 |
+
|
52 |
+
/**
|
53 |
+
* HTTP POST handler function to clone a git repository from a provided URL, read the extension manifest,
|
54 |
+
* and return extension information and path.
|
55 |
+
*
|
56 |
+
* @param {Object} request - HTTP Request object, expects a JSON body with a 'url' property.
|
57 |
+
* @param {Object} response - HTTP Response object used to respond to the HTTP request.
|
58 |
+
*
|
59 |
+
* @returns {void}
|
60 |
+
*/
|
61 |
+
router.post('/install', jsonParser, async (request, response) => {
|
62 |
+
if (!request.body.url) {
|
63 |
+
return response.status(400).send('Bad Request: URL is required in the request body.');
|
64 |
+
}
|
65 |
+
|
66 |
+
try {
|
67 |
+
const git = simpleGit();
|
68 |
+
|
69 |
+
// make sure the third-party directory exists
|
70 |
+
if (!fs.existsSync(path.join(request.user.directories.extensions))) {
|
71 |
+
fs.mkdirSync(path.join(request.user.directories.extensions));
|
72 |
+
}
|
73 |
+
|
74 |
+
const url = request.body.url;
|
75 |
+
const extensionPath = path.join(request.user.directories.extensions, path.basename(url, '.git'));
|
76 |
+
|
77 |
+
if (fs.existsSync(extensionPath)) {
|
78 |
+
return response.status(409).send(`Directory already exists at ${extensionPath}`);
|
79 |
+
}
|
80 |
+
|
81 |
+
await git.clone(url, extensionPath, { '--depth': 1 });
|
82 |
+
console.log(`Extension has been cloned at ${extensionPath}`);
|
83 |
+
|
84 |
+
|
85 |
+
const { version, author, display_name } = await getManifest(extensionPath);
|
86 |
+
|
87 |
+
|
88 |
+
return response.send({ version, author, display_name, extensionPath });
|
89 |
+
} catch (error) {
|
90 |
+
console.log('Importing custom content failed', error);
|
91 |
+
return response.status(500).send(`Server Error: ${error.message}`);
|
92 |
+
}
|
93 |
+
});
|
94 |
+
|
95 |
+
/**
|
96 |
+
* HTTP POST handler function to pull the latest updates from a git repository
|
97 |
+
* based on the extension name provided in the request body. It returns the latest commit hash,
|
98 |
+
* the path of the extension, the status of the repository (whether it's up-to-date or not),
|
99 |
+
* and the remote URL of the repository.
|
100 |
+
*
|
101 |
+
* @param {Object} request - HTTP Request object, expects a JSON body with an 'extensionName' property.
|
102 |
+
* @param {Object} response - HTTP Response object used to respond to the HTTP request.
|
103 |
+
*
|
104 |
+
* @returns {void}
|
105 |
+
*/
|
106 |
+
router.post('/update', jsonParser, async (request, response) => {
|
107 |
+
const git = simpleGit();
|
108 |
+
if (!request.body.extensionName) {
|
109 |
+
return response.status(400).send('Bad Request: extensionName is required in the request body.');
|
110 |
+
}
|
111 |
+
|
112 |
+
try {
|
113 |
+
const extensionName = request.body.extensionName;
|
114 |
+
const extensionPath = path.join(request.user.directories.extensions, extensionName);
|
115 |
+
|
116 |
+
if (!fs.existsSync(extensionPath)) {
|
117 |
+
return response.status(404).send(`Directory does not exist at ${extensionPath}`);
|
118 |
+
}
|
119 |
+
|
120 |
+
const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
|
121 |
+
const currentBranch = await git.cwd(extensionPath).branch();
|
122 |
+
if (!isUpToDate) {
|
123 |
+
|
124 |
+
await git.cwd(extensionPath).pull('origin', currentBranch.current);
|
125 |
+
console.log(`Extension has been updated at ${extensionPath}`);
|
126 |
+
} else {
|
127 |
+
console.log(`Extension is up to date at ${extensionPath}`);
|
128 |
+
}
|
129 |
+
await git.cwd(extensionPath).fetch('origin');
|
130 |
+
const fullCommitHash = await git.cwd(extensionPath).revparse(['HEAD']);
|
131 |
+
const shortCommitHash = fullCommitHash.slice(0, 7);
|
132 |
+
|
133 |
+
return response.send({ shortCommitHash, extensionPath, isUpToDate, remoteUrl });
|
134 |
+
|
135 |
+
} catch (error) {
|
136 |
+
console.log('Updating custom content failed', error);
|
137 |
+
return response.status(500).send(`Server Error: ${error.message}`);
|
138 |
+
}
|
139 |
+
});
|
140 |
+
|
141 |
+
/**
|
142 |
+
* HTTP POST handler function to get the current git commit hash and branch name for a given extension.
|
143 |
+
* It checks whether the repository is up-to-date with the remote, and returns the status along with
|
144 |
+
* the remote URL of the repository.
|
145 |
+
*
|
146 |
+
* @param {Object} request - HTTP Request object, expects a JSON body with an 'extensionName' property.
|
147 |
+
* @param {Object} response - HTTP Response object used to respond to the HTTP request.
|
148 |
+
*
|
149 |
+
* @returns {void}
|
150 |
+
*/
|
151 |
+
router.post('/version', jsonParser, async (request, response) => {
|
152 |
+
const git = simpleGit();
|
153 |
+
if (!request.body.extensionName) {
|
154 |
+
return response.status(400).send('Bad Request: extensionName is required in the request body.');
|
155 |
+
}
|
156 |
+
|
157 |
+
try {
|
158 |
+
const extensionName = request.body.extensionName;
|
159 |
+
const extensionPath = path.join(request.user.directories.extensions, extensionName);
|
160 |
+
|
161 |
+
if (!fs.existsSync(extensionPath)) {
|
162 |
+
return response.status(404).send(`Directory does not exist at ${extensionPath}`);
|
163 |
+
}
|
164 |
+
|
165 |
+
const currentBranch = await git.cwd(extensionPath).branch();
|
166 |
+
// get only the working branch
|
167 |
+
const currentBranchName = currentBranch.current;
|
168 |
+
await git.cwd(extensionPath).fetch('origin');
|
169 |
+
const currentCommitHash = await git.cwd(extensionPath).revparse(['HEAD']);
|
170 |
+
console.log(currentBranch, currentCommitHash);
|
171 |
+
const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
|
172 |
+
|
173 |
+
return response.send({ currentBranchName, currentCommitHash, isUpToDate, remoteUrl });
|
174 |
+
|
175 |
+
} catch (error) {
|
176 |
+
console.log('Getting extension version failed', error);
|
177 |
+
return response.status(500).send(`Server Error: ${error.message}`);
|
178 |
+
}
|
179 |
+
});
|
180 |
+
|
181 |
+
/**
|
182 |
+
* HTTP POST handler function to delete a git repository based on the extension name provided in the request body.
|
183 |
+
*
|
184 |
+
* @param {Object} request - HTTP Request object, expects a JSON body with a 'url' property.
|
185 |
+
* @param {Object} response - HTTP Response object used to respond to the HTTP request.
|
186 |
+
*
|
187 |
+
* @returns {void}
|
188 |
+
*/
|
189 |
+
router.post('/delete', jsonParser, async (request, response) => {
|
190 |
+
if (!request.body.extensionName) {
|
191 |
+
return response.status(400).send('Bad Request: extensionName is required in the request body.');
|
192 |
+
}
|
193 |
+
|
194 |
+
// Sanitize the extension name to prevent directory traversal
|
195 |
+
const extensionName = sanitize(request.body.extensionName);
|
196 |
+
|
197 |
+
try {
|
198 |
+
const extensionPath = path.join(request.user.directories.extensions, extensionName);
|
199 |
+
|
200 |
+
if (!fs.existsSync(extensionPath)) {
|
201 |
+
return response.status(404).send(`Directory does not exist at ${extensionPath}`);
|
202 |
+
}
|
203 |
+
|
204 |
+
await fs.promises.rm(extensionPath, { recursive: true });
|
205 |
+
console.log(`Extension has been deleted at ${extensionPath}`);
|
206 |
+
|
207 |
+
return response.send(`Extension has been deleted at ${extensionPath}`);
|
208 |
+
|
209 |
+
} catch (error) {
|
210 |
+
console.log('Deleting custom content failed', error);
|
211 |
+
return response.status(500).send(`Server Error: ${error.message}`);
|
212 |
+
}
|
213 |
+
});
|
214 |
+
|
215 |
+
/**
|
216 |
+
* Discover the extension folders
|
217 |
+
* If the folder is called third-party, search for subfolders instead
|
218 |
+
*/
|
219 |
+
router.get('/discover', jsonParser, function (request, response) {
|
220 |
+
// get all folders in the extensions folder, except third-party
|
221 |
+
const extensions = fs
|
222 |
+
.readdirSync(PUBLIC_DIRECTORIES.extensions)
|
223 |
+
.filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.extensions, f)).isDirectory())
|
224 |
+
.filter(f => f !== 'third-party');
|
225 |
+
|
226 |
+
// get all folders in the third-party folder, if it exists
|
227 |
+
|
228 |
+
if (!fs.existsSync(path.join(request.user.directories.extensions))) {
|
229 |
+
return response.send(extensions);
|
230 |
+
}
|
231 |
+
|
232 |
+
const thirdPartyExtensions = fs
|
233 |
+
.readdirSync(path.join(request.user.directories.extensions))
|
234 |
+
.filter(f => fs.statSync(path.join(request.user.directories.extensions, f)).isDirectory());
|
235 |
+
|
236 |
+
// add the third-party extensions to the extensions array
|
237 |
+
extensions.push(...thirdPartyExtensions.map(f => `third-party/${f}`));
|
238 |
+
console.log(extensions);
|
239 |
+
|
240 |
+
|
241 |
+
return response.send(extensions);
|
242 |
+
});
|
243 |
+
|
244 |
+
module.exports = { router };
|
src/endpoints/files.js
ADDED
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const path = require('path');
|
2 |
+
const fs = require('fs');
|
3 |
+
const writeFileSyncAtomic = require('write-file-atomic').sync;
|
4 |
+
const express = require('express');
|
5 |
+
const sanitize = require('sanitize-filename');
|
6 |
+
const router = express.Router();
|
7 |
+
const { validateAssetFileName } = require('./assets');
|
8 |
+
const { jsonParser } = require('../express-common');
|
9 |
+
const { clientRelativePath } = require('../util');
|
10 |
+
|
11 |
+
router.post('/sanitize-filename', jsonParser, async (request, response) => {
|
12 |
+
try {
|
13 |
+
const fileName = String(request.body.fileName);
|
14 |
+
if (!fileName) {
|
15 |
+
return response.status(400).send('No fileName specified');
|
16 |
+
}
|
17 |
+
|
18 |
+
const sanitizedFilename = sanitize(fileName);
|
19 |
+
return response.send({ fileName: sanitizedFilename });
|
20 |
+
} catch (error) {
|
21 |
+
console.log(error);
|
22 |
+
return response.sendStatus(500);
|
23 |
+
}
|
24 |
+
});
|
25 |
+
|
26 |
+
router.post('/upload', jsonParser, async (request, response) => {
|
27 |
+
try {
|
28 |
+
if (!request.body.name) {
|
29 |
+
return response.status(400).send('No upload name specified');
|
30 |
+
}
|
31 |
+
|
32 |
+
if (!request.body.data) {
|
33 |
+
return response.status(400).send('No upload data specified');
|
34 |
+
}
|
35 |
+
|
36 |
+
// Validate filename
|
37 |
+
const validation = validateAssetFileName(request.body.name);
|
38 |
+
if (validation.error)
|
39 |
+
return response.status(400).send(validation.message);
|
40 |
+
|
41 |
+
const pathToUpload = path.join(request.user.directories.files, request.body.name);
|
42 |
+
writeFileSyncAtomic(pathToUpload, request.body.data, 'base64');
|
43 |
+
const url = clientRelativePath(request.user.directories.root, pathToUpload);
|
44 |
+
console.log(`Uploaded file: ${url} from ${request.user.profile.handle}`);
|
45 |
+
return response.send({ path: url });
|
46 |
+
} catch (error) {
|
47 |
+
console.log(error);
|
48 |
+
return response.sendStatus(500);
|
49 |
+
}
|
50 |
+
});
|
51 |
+
|
52 |
+
router.post('/delete', jsonParser, async (request, response) => {
|
53 |
+
try {
|
54 |
+
if (!request.body.path) {
|
55 |
+
return response.status(400).send('No path specified');
|
56 |
+
}
|
57 |
+
|
58 |
+
const pathToDelete = path.join(request.user.directories.root, request.body.path);
|
59 |
+
if (!pathToDelete.startsWith(request.user.directories.files)) {
|
60 |
+
return response.status(400).send('Invalid path');
|
61 |
+
}
|
62 |
+
|
63 |
+
if (!fs.existsSync(pathToDelete)) {
|
64 |
+
return response.status(404).send('File not found');
|
65 |
+
}
|
66 |
+
|
67 |
+
fs.rmSync(pathToDelete);
|
68 |
+
console.log(`Deleted file: ${request.body.path} from ${request.user.profile.handle}`);
|
69 |
+
return response.sendStatus(200);
|
70 |
+
} catch (error) {
|
71 |
+
console.log(error);
|
72 |
+
return response.sendStatus(500);
|
73 |
+
}
|
74 |
+
});
|
75 |
+
|
76 |
+
router.post('/verify', jsonParser, async (request, response) => {
|
77 |
+
try {
|
78 |
+
if (!Array.isArray(request.body.urls)) {
|
79 |
+
return response.status(400).send('No URLs specified');
|
80 |
+
}
|
81 |
+
|
82 |
+
const verified = {};
|
83 |
+
|
84 |
+
for (const url of request.body.urls) {
|
85 |
+
const pathToVerify = path.join(request.user.directories.root, url);
|
86 |
+
if (!pathToVerify.startsWith(request.user.directories.files)) {
|
87 |
+
console.debug(`File verification: Invalid path: ${pathToVerify}`);
|
88 |
+
continue;
|
89 |
+
}
|
90 |
+
const fileExists = fs.existsSync(pathToVerify);
|
91 |
+
verified[url] = fileExists;
|
92 |
+
}
|
93 |
+
|
94 |
+
return response.send(verified);
|
95 |
+
} catch (error) {
|
96 |
+
console.log(error);
|
97 |
+
return response.sendStatus(500);
|
98 |
+
}
|
99 |
+
});
|
100 |
+
|
101 |
+
module.exports = { router };
|
src/endpoints/google.js
ADDED
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const { readSecret, SECRET_KEYS } = require('./secrets');
|
2 |
+
const fetch = require('node-fetch').default;
|
3 |
+
const express = require('express');
|
4 |
+
const { jsonParser } = require('../express-common');
|
5 |
+
const { GEMINI_SAFETY } = require('../constants');
|
6 |
+
|
7 |
+
const API_MAKERSUITE = 'https://generativelanguage.googleapis.com';
|
8 |
+
|
9 |
+
const router = express.Router();
|
10 |
+
|
11 |
+
router.post('/caption-image', jsonParser, async (request, response) => {
|
12 |
+
try {
|
13 |
+
const mimeType = request.body.image.split(';')[0].split(':')[1];
|
14 |
+
const base64Data = request.body.image.split(',')[1];
|
15 |
+
const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE);
|
16 |
+
const apiUrl = new URL(request.body.reverse_proxy || API_MAKERSUITE);
|
17 |
+
const model = request.body.model || 'gemini-pro-vision';
|
18 |
+
const url = `${apiUrl.origin}/v1beta/models/${model}:generateContent?key=${apiKey}`;
|
19 |
+
const body = {
|
20 |
+
contents: [{
|
21 |
+
parts: [
|
22 |
+
{ text: request.body.prompt },
|
23 |
+
{
|
24 |
+
inlineData: {
|
25 |
+
mimeType: 'image/png', // It needs to specify a MIME type in data if it's not a PNG
|
26 |
+
data: mimeType === 'image/png' ? base64Data : request.body.image,
|
27 |
+
},
|
28 |
+
}],
|
29 |
+
}],
|
30 |
+
safetySettings: GEMINI_SAFETY,
|
31 |
+
generationConfig: { maxOutputTokens: 1000 },
|
32 |
+
};
|
33 |
+
|
34 |
+
console.log('Multimodal captioning request', model, body);
|
35 |
+
|
36 |
+
const result = await fetch(url, {
|
37 |
+
body: JSON.stringify(body),
|
38 |
+
method: 'POST',
|
39 |
+
headers: {
|
40 |
+
'Content-Type': 'application/json',
|
41 |
+
},
|
42 |
+
timeout: 0,
|
43 |
+
});
|
44 |
+
|
45 |
+
if (!result.ok) {
|
46 |
+
const error = await result.json();
|
47 |
+
console.log(`Google AI Studio API returned error: ${result.status} ${result.statusText}`, error);
|
48 |
+
return response.status(result.status).send({ error: true });
|
49 |
+
}
|
50 |
+
|
51 |
+
const data = await result.json();
|
52 |
+
console.log('Multimodal captioning response', data);
|
53 |
+
|
54 |
+
const candidates = data?.candidates;
|
55 |
+
if (!candidates) {
|
56 |
+
return response.status(500).send('No candidates found, image was most likely filtered.');
|
57 |
+
}
|
58 |
+
|
59 |
+
const caption = candidates[0].content.parts[0].text;
|
60 |
+
if (!caption) {
|
61 |
+
return response.status(500).send('No caption found');
|
62 |
+
}
|
63 |
+
|
64 |
+
return response.json({ caption });
|
65 |
+
} catch (error) {
|
66 |
+
console.error(error);
|
67 |
+
response.status(500).send('Internal server error');
|
68 |
+
}
|
69 |
+
});
|
70 |
+
|
71 |
+
module.exports = { router };
|
src/endpoints/groups.js
ADDED
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const fs = require('fs');
|
2 |
+
const path = require('path');
|
3 |
+
const express = require('express');
|
4 |
+
const sanitize = require('sanitize-filename');
|
5 |
+
const writeFileAtomicSync = require('write-file-atomic').sync;
|
6 |
+
|
7 |
+
const { jsonParser } = require('../express-common');
|
8 |
+
const { humanizedISO8601DateTime } = require('../util');
|
9 |
+
|
10 |
+
const router = express.Router();
|
11 |
+
|
12 |
+
router.post('/all', jsonParser, (request, response) => {
|
13 |
+
const groups = [];
|
14 |
+
|
15 |
+
if (!fs.existsSync(request.user.directories.groups)) {
|
16 |
+
fs.mkdirSync(request.user.directories.groups);
|
17 |
+
}
|
18 |
+
|
19 |
+
const files = fs.readdirSync(request.user.directories.groups).filter(x => path.extname(x) === '.json');
|
20 |
+
const chats = fs.readdirSync(request.user.directories.groupChats).filter(x => path.extname(x) === '.jsonl');
|
21 |
+
|
22 |
+
files.forEach(function (file) {
|
23 |
+
try {
|
24 |
+
const filePath = path.join(request.user.directories.groups, file);
|
25 |
+
const fileContents = fs.readFileSync(filePath, 'utf8');
|
26 |
+
const group = JSON.parse(fileContents);
|
27 |
+
const groupStat = fs.statSync(filePath);
|
28 |
+
group['date_added'] = groupStat.birthtimeMs;
|
29 |
+
group['create_date'] = humanizedISO8601DateTime(groupStat.birthtimeMs);
|
30 |
+
|
31 |
+
let chat_size = 0;
|
32 |
+
let date_last_chat = 0;
|
33 |
+
|
34 |
+
if (Array.isArray(group.chats) && Array.isArray(chats)) {
|
35 |
+
for (const chat of chats) {
|
36 |
+
if (group.chats.includes(path.parse(chat).name)) {
|
37 |
+
const chatStat = fs.statSync(path.join(request.user.directories.groupChats, chat));
|
38 |
+
chat_size += chatStat.size;
|
39 |
+
date_last_chat = Math.max(date_last_chat, chatStat.mtimeMs);
|
40 |
+
}
|
41 |
+
}
|
42 |
+
}
|
43 |
+
|
44 |
+
group['date_last_chat'] = date_last_chat;
|
45 |
+
group['chat_size'] = chat_size;
|
46 |
+
groups.push(group);
|
47 |
+
}
|
48 |
+
catch (error) {
|
49 |
+
console.error(error);
|
50 |
+
}
|
51 |
+
});
|
52 |
+
|
53 |
+
return response.send(groups);
|
54 |
+
});
|
55 |
+
|
56 |
+
router.post('/create', jsonParser, (request, response) => {
|
57 |
+
if (!request.body) {
|
58 |
+
return response.sendStatus(400);
|
59 |
+
}
|
60 |
+
|
61 |
+
const id = String(Date.now());
|
62 |
+
const groupMetadata = {
|
63 |
+
id: id,
|
64 |
+
name: request.body.name ?? 'New Group',
|
65 |
+
members: request.body.members ?? [],
|
66 |
+
avatar_url: request.body.avatar_url,
|
67 |
+
allow_self_responses: !!request.body.allow_self_responses,
|
68 |
+
activation_strategy: request.body.activation_strategy ?? 0,
|
69 |
+
generation_mode: request.body.generation_mode ?? 0,
|
70 |
+
disabled_members: request.body.disabled_members ?? [],
|
71 |
+
chat_metadata: request.body.chat_metadata ?? {},
|
72 |
+
fav: request.body.fav,
|
73 |
+
chat_id: request.body.chat_id ?? id,
|
74 |
+
chats: request.body.chats ?? [id],
|
75 |
+
auto_mode_delay: request.body.auto_mode_delay ?? 5,
|
76 |
+
generation_mode_join_prefix: request.body.generation_mode_join_prefix ?? '',
|
77 |
+
generation_mode_join_suffix: request.body.generation_mode_join_suffix ?? '',
|
78 |
+
};
|
79 |
+
const pathToFile = path.join(request.user.directories.groups, `${id}.json`);
|
80 |
+
const fileData = JSON.stringify(groupMetadata, null, 4);
|
81 |
+
|
82 |
+
if (!fs.existsSync(request.user.directories.groups)) {
|
83 |
+
fs.mkdirSync(request.user.directories.groups);
|
84 |
+
}
|
85 |
+
|
86 |
+
writeFileAtomicSync(pathToFile, fileData);
|
87 |
+
return response.send(groupMetadata);
|
88 |
+
});
|
89 |
+
|
90 |
+
router.post('/edit', jsonParser, (request, response) => {
|
91 |
+
if (!request.body || !request.body.id) {
|
92 |
+
return response.sendStatus(400);
|
93 |
+
}
|
94 |
+
const id = request.body.id;
|
95 |
+
const pathToFile = path.join(request.user.directories.groups, `${id}.json`);
|
96 |
+
const fileData = JSON.stringify(request.body, null, 4);
|
97 |
+
|
98 |
+
writeFileAtomicSync(pathToFile, fileData);
|
99 |
+
return response.send({ ok: true });
|
100 |
+
});
|
101 |
+
|
102 |
+
router.post('/delete', jsonParser, async (request, response) => {
|
103 |
+
if (!request.body || !request.body.id) {
|
104 |
+
return response.sendStatus(400);
|
105 |
+
}
|
106 |
+
|
107 |
+
const id = request.body.id;
|
108 |
+
const pathToGroup = path.join(request.user.directories.groups, sanitize(`${id}.json`));
|
109 |
+
|
110 |
+
try {
|
111 |
+
// Delete group chats
|
112 |
+
const group = JSON.parse(fs.readFileSync(pathToGroup, 'utf8'));
|
113 |
+
|
114 |
+
if (group && Array.isArray(group.chats)) {
|
115 |
+
for (const chat of group.chats) {
|
116 |
+
console.log('Deleting group chat', chat);
|
117 |
+
const pathToFile = path.join(request.user.directories.groupChats, `${id}.jsonl`);
|
118 |
+
|
119 |
+
if (fs.existsSync(pathToFile)) {
|
120 |
+
fs.rmSync(pathToFile);
|
121 |
+
}
|
122 |
+
}
|
123 |
+
}
|
124 |
+
} catch (error) {
|
125 |
+
console.error('Could not delete group chats. Clean them up manually.', error);
|
126 |
+
}
|
127 |
+
|
128 |
+
if (fs.existsSync(pathToGroup)) {
|
129 |
+
fs.rmSync(pathToGroup);
|
130 |
+
}
|
131 |
+
|
132 |
+
return response.send({ ok: true });
|
133 |
+
});
|
134 |
+
|
135 |
+
module.exports = { router };
|
src/endpoints/horde.js
ADDED
@@ -0,0 +1,382 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const fetch = require('node-fetch').default;
|
2 |
+
const express = require('express');
|
3 |
+
const { AIHorde, ModelGenerationInputStableSamplers, ModelInterrogationFormTypes, HordeAsyncRequestStates } = require('@zeldafan0225/ai_horde');
|
4 |
+
const { getVersion, delay, Cache } = require('../util');
|
5 |
+
const { readSecret, SECRET_KEYS } = require('./secrets');
|
6 |
+
const { jsonParser } = require('../express-common');
|
7 |
+
|
8 |
+
const ANONYMOUS_KEY = '0000000000';
|
9 |
+
const cache = new Cache(60 * 1000);
|
10 |
+
const router = express.Router();
|
11 |
+
|
12 |
+
/**
|
13 |
+
* Returns the AIHorde client agent.
|
14 |
+
* @returns {Promise<string>} AIHorde client agent
|
15 |
+
*/
|
16 |
+
async function getClientAgent() {
|
17 |
+
const version = await getVersion();
|
18 |
+
return version?.agent || 'SillyTavern:UNKNOWN:Cohee#1207';
|
19 |
+
}
|
20 |
+
|
21 |
+
/**
|
22 |
+
* Returns the AIHorde client.
|
23 |
+
* @returns {Promise<AIHorde>} AIHorde client
|
24 |
+
*/
|
25 |
+
async function getHordeClient() {
|
26 |
+
const ai_horde = new AIHorde({
|
27 |
+
client_agent: await getClientAgent(),
|
28 |
+
});
|
29 |
+
return ai_horde;
|
30 |
+
}
|
31 |
+
|
32 |
+
/**
|
33 |
+
* Removes dirty no-no words from the prompt.
|
34 |
+
* Taken verbatim from KAI Lite's implementation (AGPLv3).
|
35 |
+
* https://github.com/LostRuins/lite.koboldai.net/blob/main/index.html#L7786C2-L7811C1
|
36 |
+
* @param {string} prompt Prompt to sanitize
|
37 |
+
* @returns {string} Sanitized prompt
|
38 |
+
*/
|
39 |
+
function sanitizeHordeImagePrompt(prompt) {
|
40 |
+
if (!prompt) {
|
41 |
+
return '';
|
42 |
+
}
|
43 |
+
|
44 |
+
//to avoid flagging from some image models, always swap these words
|
45 |
+
prompt = prompt.replace(/\b(girl)\b/gmi, 'woman');
|
46 |
+
prompt = prompt.replace(/\b(boy)\b/gmi, 'man');
|
47 |
+
prompt = prompt.replace(/\b(girls)\b/gmi, 'women');
|
48 |
+
prompt = prompt.replace(/\b(boys)\b/gmi, 'men');
|
49 |
+
//always remove these high risk words from prompt, as they add little value to image gen while increasing the risk the prompt gets flagged
|
50 |
+
prompt = prompt.replace(/\b(under.age|under.aged|underage|underaged|loli|pedo|pedophile|(\w+).year.old|(\w+).years.old|minor|prepubescent|minors|shota)\b/gmi, '');
|
51 |
+
//replace risky subject nouns with person
|
52 |
+
prompt = prompt.replace(/\b(youngster|infant|baby|toddler|child|teen|kid|kiddie|kiddo|teenager|student|preteen|pre.teen)\b/gmi, 'person');
|
53 |
+
//remove risky adjectives and related words
|
54 |
+
prompt = prompt.replace(/\b(young|younger|youthful|youth|small|smaller|smallest|girly|boyish|lil|tiny|teenaged|lit[tl]le|school.aged|school|highschool|kindergarten|teens|children|kids)\b/gmi, '');
|
55 |
+
|
56 |
+
return prompt;
|
57 |
+
}
|
58 |
+
|
59 |
+
router.post('/text-workers', jsonParser, async (request, response) => {
|
60 |
+
try {
|
61 |
+
const cachedWorkers = cache.get('workers');
|
62 |
+
|
63 |
+
if (cachedWorkers && !request.body.force) {
|
64 |
+
return response.send(cachedWorkers);
|
65 |
+
}
|
66 |
+
|
67 |
+
const agent = await getClientAgent();
|
68 |
+
const fetchResult = await fetch('https://aihorde.net/api/v2/workers?type=text', {
|
69 |
+
headers: {
|
70 |
+
'Client-Agent': agent,
|
71 |
+
},
|
72 |
+
});
|
73 |
+
const data = await fetchResult.json();
|
74 |
+
cache.set('workers', data);
|
75 |
+
return response.send(data);
|
76 |
+
} catch (error) {
|
77 |
+
console.error(error);
|
78 |
+
response.sendStatus(500);
|
79 |
+
}
|
80 |
+
});
|
81 |
+
|
82 |
+
router.post('/text-models', jsonParser, async (request, response) => {
|
83 |
+
try {
|
84 |
+
const cachedModels = cache.get('models');
|
85 |
+
|
86 |
+
if (cachedModels && !request.body.force) {
|
87 |
+
return response.send(cachedModels);
|
88 |
+
}
|
89 |
+
|
90 |
+
const agent = await getClientAgent();
|
91 |
+
const fetchResult = await fetch('https://aihorde.net/api/v2/status/models?type=text', {
|
92 |
+
headers: {
|
93 |
+
'Client-Agent': agent,
|
94 |
+
},
|
95 |
+
});
|
96 |
+
|
97 |
+
const data = await fetchResult.json();
|
98 |
+
cache.set('models', data);
|
99 |
+
return response.send(data);
|
100 |
+
} catch (error) {
|
101 |
+
console.error(error);
|
102 |
+
response.sendStatus(500);
|
103 |
+
}
|
104 |
+
});
|
105 |
+
|
106 |
+
router.post('/status', jsonParser, async (_, response) => {
|
107 |
+
try {
|
108 |
+
const agent = await getClientAgent();
|
109 |
+
const fetchResult = await fetch('https://aihorde.net/api/v2/status/heartbeat', {
|
110 |
+
headers: {
|
111 |
+
'Client-Agent': agent,
|
112 |
+
},
|
113 |
+
});
|
114 |
+
|
115 |
+
return response.send({ ok: fetchResult.ok });
|
116 |
+
} catch (error) {
|
117 |
+
console.error(error);
|
118 |
+
response.sendStatus(500);
|
119 |
+
}
|
120 |
+
});
|
121 |
+
|
122 |
+
router.post('/cancel-task', jsonParser, async (request, response) => {
|
123 |
+
try {
|
124 |
+
const taskId = request.body.taskId;
|
125 |
+
const agent = await getClientAgent();
|
126 |
+
const fetchResult = await fetch(`https://aihorde.net/api/v2/generate/text/status/${taskId}`, {
|
127 |
+
method: 'DELETE',
|
128 |
+
headers: {
|
129 |
+
'Client-Agent': agent,
|
130 |
+
},
|
131 |
+
});
|
132 |
+
|
133 |
+
const data = await fetchResult.json();
|
134 |
+
console.log(`Cancelled Horde task ${taskId}`);
|
135 |
+
return response.send(data);
|
136 |
+
} catch (error) {
|
137 |
+
console.error(error);
|
138 |
+
response.sendStatus(500);
|
139 |
+
}
|
140 |
+
});
|
141 |
+
|
142 |
+
router.post('/task-status', jsonParser, async (request, response) => {
|
143 |
+
try {
|
144 |
+
const taskId = request.body.taskId;
|
145 |
+
const agent = await getClientAgent();
|
146 |
+
const fetchResult = await fetch(`https://aihorde.net/api/v2/generate/text/status/${taskId}`, {
|
147 |
+
headers: {
|
148 |
+
'Client-Agent': agent,
|
149 |
+
},
|
150 |
+
});
|
151 |
+
|
152 |
+
const data = await fetchResult.json();
|
153 |
+
console.log(`Horde task ${taskId} status:`, data);
|
154 |
+
return response.send(data);
|
155 |
+
} catch (error) {
|
156 |
+
console.error(error);
|
157 |
+
response.sendStatus(500);
|
158 |
+
}
|
159 |
+
});
|
160 |
+
|
161 |
+
router.post('/generate-text', jsonParser, async (request, response) => {
|
162 |
+
const apiKey = readSecret(request.user.directories, SECRET_KEYS.HORDE) || ANONYMOUS_KEY;
|
163 |
+
const url = 'https://aihorde.net/api/v2/generate/text/async';
|
164 |
+
const agent = await getClientAgent();
|
165 |
+
|
166 |
+
console.log(request.body);
|
167 |
+
try {
|
168 |
+
const result = await fetch(url, {
|
169 |
+
method: 'POST',
|
170 |
+
body: JSON.stringify(request.body),
|
171 |
+
headers: {
|
172 |
+
'Content-Type': 'application/json',
|
173 |
+
'apikey': apiKey,
|
174 |
+
'Client-Agent': agent,
|
175 |
+
},
|
176 |
+
});
|
177 |
+
|
178 |
+
if (!result.ok) {
|
179 |
+
const message = await result.text();
|
180 |
+
console.log('Horde returned an error:', message);
|
181 |
+
return response.send({ error: { message } });
|
182 |
+
}
|
183 |
+
|
184 |
+
const data = await result.json();
|
185 |
+
return response.send(data);
|
186 |
+
} catch (error) {
|
187 |
+
console.log(error);
|
188 |
+
return response.send({ error: true });
|
189 |
+
}
|
190 |
+
});
|
191 |
+
|
192 |
+
router.post('/sd-samplers', jsonParser, async (_, response) => {
|
193 |
+
try {
|
194 |
+
const samplers = Object.values(ModelGenerationInputStableSamplers);
|
195 |
+
response.send(samplers);
|
196 |
+
} catch (error) {
|
197 |
+
console.error(error);
|
198 |
+
response.sendStatus(500);
|
199 |
+
}
|
200 |
+
});
|
201 |
+
|
202 |
+
router.post('/sd-models', jsonParser, async (_, response) => {
|
203 |
+
try {
|
204 |
+
const ai_horde = await getHordeClient();
|
205 |
+
const models = await ai_horde.getModels();
|
206 |
+
response.send(models);
|
207 |
+
} catch (error) {
|
208 |
+
console.error(error);
|
209 |
+
response.sendStatus(500);
|
210 |
+
}
|
211 |
+
});
|
212 |
+
|
213 |
+
router.post('/caption-image', jsonParser, async (request, response) => {
|
214 |
+
try {
|
215 |
+
const api_key_horde = readSecret(request.user.directories, SECRET_KEYS.HORDE) || ANONYMOUS_KEY;
|
216 |
+
const ai_horde = await getHordeClient();
|
217 |
+
const result = await ai_horde.postAsyncInterrogate({
|
218 |
+
source_image: request.body.image,
|
219 |
+
forms: [{ name: ModelInterrogationFormTypes.caption }],
|
220 |
+
}, { token: api_key_horde });
|
221 |
+
|
222 |
+
if (!result.id) {
|
223 |
+
console.error('Image interrogation request is not satisfyable:', result.message || 'unknown error');
|
224 |
+
return response.sendStatus(400);
|
225 |
+
}
|
226 |
+
|
227 |
+
const MAX_ATTEMPTS = 200;
|
228 |
+
const CHECK_INTERVAL = 3000;
|
229 |
+
|
230 |
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
231 |
+
await delay(CHECK_INTERVAL);
|
232 |
+
const status = await ai_horde.getInterrogationStatus(result.id);
|
233 |
+
console.log(status);
|
234 |
+
|
235 |
+
if (status.state === HordeAsyncRequestStates.done) {
|
236 |
+
|
237 |
+
if (status.forms === undefined) {
|
238 |
+
console.error('Image interrogation request failed: no forms found.');
|
239 |
+
return response.sendStatus(500);
|
240 |
+
}
|
241 |
+
|
242 |
+
console.log('Image interrogation result:', status);
|
243 |
+
const caption = status?.forms[0]?.result?.caption || '';
|
244 |
+
|
245 |
+
if (!caption) {
|
246 |
+
console.error('Image interrogation request failed: no caption found.');
|
247 |
+
return response.sendStatus(500);
|
248 |
+
}
|
249 |
+
|
250 |
+
return response.send({ caption });
|
251 |
+
}
|
252 |
+
|
253 |
+
if (status.state === HordeAsyncRequestStates.faulted || status.state === HordeAsyncRequestStates.cancelled) {
|
254 |
+
console.log('Image interrogation request is not successful.');
|
255 |
+
return response.sendStatus(503);
|
256 |
+
}
|
257 |
+
}
|
258 |
+
|
259 |
+
} catch (error) {
|
260 |
+
console.error(error);
|
261 |
+
response.sendStatus(500);
|
262 |
+
}
|
263 |
+
});
|
264 |
+
|
265 |
+
router.post('/user-info', jsonParser, async (request, response) => {
|
266 |
+
const api_key_horde = readSecret(request.user.directories, SECRET_KEYS.HORDE);
|
267 |
+
|
268 |
+
if (!api_key_horde) {
|
269 |
+
return response.send({ anonymous: true });
|
270 |
+
}
|
271 |
+
|
272 |
+
try {
|
273 |
+
const ai_horde = await getHordeClient();
|
274 |
+
const user = await ai_horde.findUser({ token: api_key_horde });
|
275 |
+
return response.send(user);
|
276 |
+
} catch (error) {
|
277 |
+
console.error(error);
|
278 |
+
return response.sendStatus(500);
|
279 |
+
}
|
280 |
+
});
|
281 |
+
|
282 |
+
router.post('/generate-image', jsonParser, async (request, response) => {
|
283 |
+
if (!request.body.prompt) {
|
284 |
+
return response.sendStatus(400);
|
285 |
+
}
|
286 |
+
|
287 |
+
const MAX_ATTEMPTS = 200;
|
288 |
+
const CHECK_INTERVAL = 3000;
|
289 |
+
const PROMPT_THRESHOLD = 5000;
|
290 |
+
|
291 |
+
try {
|
292 |
+
const maxLength = PROMPT_THRESHOLD - String(request.body.negative_prompt).length - 5;
|
293 |
+
if (String(request.body.prompt).length > maxLength) {
|
294 |
+
console.log('Stable Horde prompt is too long, truncating...');
|
295 |
+
request.body.prompt = String(request.body.prompt).substring(0, maxLength);
|
296 |
+
}
|
297 |
+
|
298 |
+
// Sanitize prompt if requested
|
299 |
+
if (request.body.sanitize) {
|
300 |
+
const sanitized = sanitizeHordeImagePrompt(request.body.prompt);
|
301 |
+
|
302 |
+
if (request.body.prompt !== sanitized) {
|
303 |
+
console.log('Stable Horde prompt was sanitized.');
|
304 |
+
}
|
305 |
+
|
306 |
+
request.body.prompt = sanitized;
|
307 |
+
}
|
308 |
+
|
309 |
+
const api_key_horde = readSecret(request.user.directories, SECRET_KEYS.HORDE) || ANONYMOUS_KEY;
|
310 |
+
console.log('Stable Horde request:', request.body);
|
311 |
+
|
312 |
+
const ai_horde = await getHordeClient();
|
313 |
+
const generation = await ai_horde.postAsyncImageGenerate(
|
314 |
+
{
|
315 |
+
prompt: `${request.body.prompt} ### ${request.body.negative_prompt}`,
|
316 |
+
params:
|
317 |
+
{
|
318 |
+
sampler_name: request.body.sampler,
|
319 |
+
hires_fix: request.body.enable_hr,
|
320 |
+
// @ts-ignore - use_gfpgan param is not in the type definition, need to update to new ai_horde @ https://github.com/ZeldaFan0225/ai_horde/blob/main/index.ts
|
321 |
+
use_gfpgan: request.body.restore_faces,
|
322 |
+
cfg_scale: request.body.scale,
|
323 |
+
steps: request.body.steps,
|
324 |
+
width: request.body.width,
|
325 |
+
height: request.body.height,
|
326 |
+
karras: Boolean(request.body.karras),
|
327 |
+
clip_skip: request.body.clip_skip,
|
328 |
+
seed: request.body.seed >= 0 ? String(request.body.seed) : undefined,
|
329 |
+
n: 1,
|
330 |
+
},
|
331 |
+
r2: false,
|
332 |
+
nsfw: request.body.nfsw,
|
333 |
+
models: [request.body.model],
|
334 |
+
},
|
335 |
+
{ token: api_key_horde });
|
336 |
+
|
337 |
+
if (!generation.id) {
|
338 |
+
console.error('Image generation request is not satisfyable:', generation.message || 'unknown error');
|
339 |
+
return response.sendStatus(400);
|
340 |
+
}
|
341 |
+
|
342 |
+
console.log('Horde image generation request:', generation);
|
343 |
+
|
344 |
+
const controller = new AbortController();
|
345 |
+
request.socket.removeAllListeners('close');
|
346 |
+
request.socket.on('close', function () {
|
347 |
+
console.log('Horde image generation request aborted.');
|
348 |
+
controller.abort();
|
349 |
+
if (generation.id) ai_horde.deleteImageGenerationRequest(generation.id);
|
350 |
+
});
|
351 |
+
|
352 |
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
353 |
+
controller.signal.throwIfAborted();
|
354 |
+
await delay(CHECK_INTERVAL);
|
355 |
+
const check = await ai_horde.getImageGenerationCheck(generation.id);
|
356 |
+
console.log(check);
|
357 |
+
|
358 |
+
if (check.done) {
|
359 |
+
const result = await ai_horde.getImageGenerationStatus(generation.id);
|
360 |
+
if (result.generations === undefined) return response.sendStatus(500);
|
361 |
+
return response.send(result.generations[0].img);
|
362 |
+
}
|
363 |
+
|
364 |
+
/*
|
365 |
+
if (!check.is_possible) {
|
366 |
+
return response.sendStatus(503);
|
367 |
+
}
|
368 |
+
*/
|
369 |
+
|
370 |
+
if (check.faulted) {
|
371 |
+
return response.sendStatus(500);
|
372 |
+
}
|
373 |
+
}
|
374 |
+
|
375 |
+
return response.sendStatus(504);
|
376 |
+
} catch (error) {
|
377 |
+
console.error(error);
|
378 |
+
return response.sendStatus(500);
|
379 |
+
}
|
380 |
+
});
|
381 |
+
|
382 |
+
module.exports = { router };
|
src/endpoints/images.js
ADDED
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const fs = require('fs');
|
2 |
+
const path = require('path');
|
3 |
+
const express = require('express');
|
4 |
+
const sanitize = require('sanitize-filename');
|
5 |
+
|
6 |
+
const { jsonParser } = require('../express-common');
|
7 |
+
const { clientRelativePath, removeFileExtension, getImages } = require('../util');
|
8 |
+
|
9 |
+
/**
|
10 |
+
* Ensure the directory for the provided file path exists.
|
11 |
+
* If not, it will recursively create the directory.
|
12 |
+
*
|
13 |
+
* @param {string} filePath - The full path of the file for which the directory should be ensured.
|
14 |
+
*/
|
15 |
+
function ensureDirectoryExistence(filePath) {
|
16 |
+
const dirname = path.dirname(filePath);
|
17 |
+
if (fs.existsSync(dirname)) {
|
18 |
+
return true;
|
19 |
+
}
|
20 |
+
ensureDirectoryExistence(dirname);
|
21 |
+
fs.mkdirSync(dirname);
|
22 |
+
}
|
23 |
+
|
24 |
+
const router = express.Router();
|
25 |
+
|
26 |
+
/**
|
27 |
+
* Endpoint to handle image uploads.
|
28 |
+
* The image should be provided in the request body in base64 format.
|
29 |
+
* Optionally, a character name can be provided to save the image in a sub-folder.
|
30 |
+
*
|
31 |
+
* @route POST /api/images/upload
|
32 |
+
* @param {Object} request.body - The request payload.
|
33 |
+
* @param {string} request.body.image - The base64 encoded image data.
|
34 |
+
* @param {string} [request.body.ch_name] - Optional character name to determine the sub-directory.
|
35 |
+
* @returns {Object} response - The response object containing the path where the image was saved.
|
36 |
+
*/
|
37 |
+
router.post('/upload', jsonParser, async (request, response) => {
|
38 |
+
// Check for image data
|
39 |
+
if (!request.body || !request.body.image) {
|
40 |
+
return response.status(400).send({ error: 'No image data provided' });
|
41 |
+
}
|
42 |
+
|
43 |
+
try {
|
44 |
+
// Extracting the base64 data and the image format
|
45 |
+
const splitParts = request.body.image.split(',');
|
46 |
+
const format = splitParts[0].split(';')[0].split('/')[1];
|
47 |
+
const base64Data = splitParts[1];
|
48 |
+
const validFormat = ['png', 'jpg', 'webp', 'jpeg', 'gif'].includes(format);
|
49 |
+
if (!validFormat) {
|
50 |
+
return response.status(400).send({ error: 'Invalid image format' });
|
51 |
+
}
|
52 |
+
|
53 |
+
// Constructing filename and path
|
54 |
+
let filename;
|
55 |
+
if (request.body.filename) {
|
56 |
+
filename = `${removeFileExtension(request.body.filename)}.${format}`;
|
57 |
+
} else {
|
58 |
+
filename = `${Date.now()}.${format}`;
|
59 |
+
}
|
60 |
+
|
61 |
+
// if character is defined, save to a sub folder for that character
|
62 |
+
let pathToNewFile = path.join(request.user.directories.userImages, sanitize(filename));
|
63 |
+
if (request.body.ch_name) {
|
64 |
+
pathToNewFile = path.join(request.user.directories.userImages, sanitize(request.body.ch_name), sanitize(filename));
|
65 |
+
}
|
66 |
+
|
67 |
+
ensureDirectoryExistence(pathToNewFile);
|
68 |
+
const imageBuffer = Buffer.from(base64Data, 'base64');
|
69 |
+
await fs.promises.writeFile(pathToNewFile, imageBuffer);
|
70 |
+
response.send({ path: clientRelativePath(request.user.directories.root, pathToNewFile) });
|
71 |
+
} catch (error) {
|
72 |
+
console.log(error);
|
73 |
+
response.status(500).send({ error: 'Failed to save the image' });
|
74 |
+
}
|
75 |
+
});
|
76 |
+
|
77 |
+
router.post('/list/:folder', (request, response) => {
|
78 |
+
const directoryPath = path.join(request.user.directories.userImages, sanitize(request.params.folder));
|
79 |
+
|
80 |
+
if (!fs.existsSync(directoryPath)) {
|
81 |
+
fs.mkdirSync(directoryPath, { recursive: true });
|
82 |
+
}
|
83 |
+
|
84 |
+
try {
|
85 |
+
const images = getImages(directoryPath, 'date');
|
86 |
+
return response.send(images);
|
87 |
+
} catch (error) {
|
88 |
+
console.error(error);
|
89 |
+
return response.status(500).send({ error: 'Unable to retrieve files' });
|
90 |
+
}
|
91 |
+
});
|
92 |
+
|
93 |
+
module.exports = { router };
|
src/endpoints/moving-ui.js
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const path = require('path');
|
2 |
+
const express = require('express');
|
3 |
+
const sanitize = require('sanitize-filename');
|
4 |
+
const writeFileAtomicSync = require('write-file-atomic').sync;
|
5 |
+
|
6 |
+
const { jsonParser } = require('../express-common');
|
7 |
+
|
8 |
+
const router = express.Router();
|
9 |
+
|
10 |
+
router.post('/save', jsonParser, (request, response) => {
|
11 |
+
if (!request.body || !request.body.name) {
|
12 |
+
return response.sendStatus(400);
|
13 |
+
}
|
14 |
+
|
15 |
+
const filename = path.join(request.user.directories.movingUI, sanitize(request.body.name) + '.json');
|
16 |
+
writeFileAtomicSync(filename, JSON.stringify(request.body, null, 4), 'utf8');
|
17 |
+
|
18 |
+
return response.sendStatus(200);
|
19 |
+
});
|
20 |
+
|
21 |
+
module.exports = { router };
|
src/endpoints/novelai.js
ADDED
@@ -0,0 +1,381 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const fetch = require('node-fetch').default;
|
2 |
+
const express = require('express');
|
3 |
+
const util = require('util');
|
4 |
+
const { readSecret, SECRET_KEYS } = require('./secrets');
|
5 |
+
const { readAllChunks, extractFileFromZipBuffer, forwardFetchResponse } = require('../util');
|
6 |
+
const { jsonParser } = require('../express-common');
|
7 |
+
|
8 |
+
const API_NOVELAI = 'https://api.novelai.net';
|
9 |
+
const IMAGE_NOVELAI = 'https://image.novelai.net';
|
10 |
+
|
11 |
+
// Ban bracket generation, plus defaults
|
12 |
+
const badWordsList = [
|
13 |
+
[3], [49356], [1431], [31715], [34387], [20765], [30702], [10691], [49333], [1266],
|
14 |
+
[19438], [43145], [26523], [41471], [2936], [85, 85], [49332], [7286], [1115], [24],
|
15 |
+
];
|
16 |
+
|
17 |
+
const hypeBotBadWordsList = [
|
18 |
+
[58], [60], [90], [92], [685], [1391], [1782], [2361], [3693], [4083], [4357], [4895],
|
19 |
+
[5512], [5974], [7131], [8183], [8351], [8762], [8964], [8973], [9063], [11208],
|
20 |
+
[11709], [11907], [11919], [12878], [12962], [13018], [13412], [14631], [14692],
|
21 |
+
[14980], [15090], [15437], [16151], [16410], [16589], [17241], [17414], [17635],
|
22 |
+
[17816], [17912], [18083], [18161], [18477], [19629], [19779], [19953], [20520],
|
23 |
+
[20598], [20662], [20740], [21476], [21737], [22133], [22241], [22345], [22935],
|
24 |
+
[23330], [23785], [23834], [23884], [25295], [25597], [25719], [25787], [25915],
|
25 |
+
[26076], [26358], [26398], [26894], [26933], [27007], [27422], [28013], [29164],
|
26 |
+
[29225], [29342], [29565], [29795], [30072], [30109], [30138], [30866], [31161],
|
27 |
+
[31478], [32092], [32239], [32509], [33116], [33250], [33761], [34171], [34758],
|
28 |
+
[34949], [35944], [36338], [36463], [36563], [36786], [36796], [36937], [37250],
|
29 |
+
[37913], [37981], [38165], [38362], [38381], [38430], [38892], [39850], [39893],
|
30 |
+
[41832], [41888], [42535], [42669], [42785], [42924], [43839], [44438], [44587],
|
31 |
+
[44926], [45144], [45297], [46110], [46570], [46581], [46956], [47175], [47182],
|
32 |
+
[47527], [47715], [48600], [48683], [48688], [48874], [48999], [49074], [49082],
|
33 |
+
[49146], [49946], [10221], [4841], [1427], [2602, 834], [29343], [37405], [35780], [2602], [50256],
|
34 |
+
];
|
35 |
+
|
36 |
+
// Used for phrase repetition penalty
|
37 |
+
const repPenaltyAllowList = [
|
38 |
+
[49256, 49264, 49231, 49230, 49287, 85, 49255, 49399, 49262, 336, 333, 432, 363, 468, 492, 745, 401, 426, 623, 794,
|
39 |
+
1096, 2919, 2072, 7379, 1259, 2110, 620, 526, 487, 16562, 603, 805, 761, 2681, 942, 8917, 653, 3513, 506, 5301,
|
40 |
+
562, 5010, 614, 10942, 539, 2976, 462, 5189, 567, 2032, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 588,
|
41 |
+
803, 1040, 49209, 4, 5, 6, 7, 8, 9, 10, 11, 12],
|
42 |
+
];
|
43 |
+
|
44 |
+
// Ban the dinkus and asterism
|
45 |
+
const logitBiasExp = [
|
46 |
+
{ 'sequence': [23], 'bias': -0.08, 'ensure_sequence_finish': false, 'generate_once': false },
|
47 |
+
{ 'sequence': [21], 'bias': -0.08, 'ensure_sequence_finish': false, 'generate_once': false },
|
48 |
+
];
|
49 |
+
|
50 |
+
function getBadWordsList(model) {
|
51 |
+
let list = [];
|
52 |
+
|
53 |
+
if (model.includes('hypebot')) {
|
54 |
+
list = hypeBotBadWordsList;
|
55 |
+
}
|
56 |
+
|
57 |
+
if (model.includes('clio') || model.includes('kayra')) {
|
58 |
+
list = badWordsList;
|
59 |
+
}
|
60 |
+
|
61 |
+
// Clone the list so we don't modify the original
|
62 |
+
return list.slice();
|
63 |
+
}
|
64 |
+
|
65 |
+
const router = express.Router();
|
66 |
+
|
67 |
+
router.post('/status', jsonParser, async function (req, res) {
|
68 |
+
if (!req.body) return res.sendStatus(400);
|
69 |
+
const api_key_novel = readSecret(req.user.directories, SECRET_KEYS.NOVEL);
|
70 |
+
|
71 |
+
if (!api_key_novel) {
|
72 |
+
console.log('NovelAI Access Token is missing.');
|
73 |
+
return res.sendStatus(400);
|
74 |
+
}
|
75 |
+
|
76 |
+
try {
|
77 |
+
const response = await fetch(API_NOVELAI + '/user/subscription', {
|
78 |
+
method: 'GET',
|
79 |
+
headers: {
|
80 |
+
'Content-Type': 'application/json',
|
81 |
+
'Authorization': 'Bearer ' + api_key_novel,
|
82 |
+
},
|
83 |
+
});
|
84 |
+
|
85 |
+
if (response.ok) {
|
86 |
+
const data = await response.json();
|
87 |
+
return res.send(data);
|
88 |
+
} else if (response.status == 401) {
|
89 |
+
console.log('NovelAI Access Token is incorrect.');
|
90 |
+
return res.send({ error: true });
|
91 |
+
}
|
92 |
+
else {
|
93 |
+
console.log('NovelAI returned an error:', response.statusText);
|
94 |
+
return res.send({ error: true });
|
95 |
+
}
|
96 |
+
} catch (error) {
|
97 |
+
console.log(error);
|
98 |
+
return res.send({ error: true });
|
99 |
+
}
|
100 |
+
});
|
101 |
+
|
102 |
+
router.post('/generate', jsonParser, async function (req, res) {
|
103 |
+
if (!req.body) return res.sendStatus(400);
|
104 |
+
|
105 |
+
const api_key_novel = readSecret(req.user.directories, SECRET_KEYS.NOVEL);
|
106 |
+
|
107 |
+
if (!api_key_novel) {
|
108 |
+
console.log('NovelAI Access Token is missing.');
|
109 |
+
return res.sendStatus(400);
|
110 |
+
}
|
111 |
+
|
112 |
+
const controller = new AbortController();
|
113 |
+
req.socket.removeAllListeners('close');
|
114 |
+
req.socket.on('close', function () {
|
115 |
+
controller.abort();
|
116 |
+
});
|
117 |
+
|
118 |
+
const isNewModel = (req.body.model.includes('clio') || req.body.model.includes('kayra'));
|
119 |
+
const badWordsList = getBadWordsList(req.body.model);
|
120 |
+
|
121 |
+
// Add customized bad words for Clio and Kayra
|
122 |
+
if (isNewModel && Array.isArray(req.body.bad_words_ids)) {
|
123 |
+
for (const badWord of req.body.bad_words_ids) {
|
124 |
+
if (Array.isArray(badWord) && badWord.every(x => Number.isInteger(x))) {
|
125 |
+
badWordsList.push(badWord);
|
126 |
+
}
|
127 |
+
}
|
128 |
+
}
|
129 |
+
|
130 |
+
// Remove empty arrays from bad words list
|
131 |
+
for (const badWord of badWordsList) {
|
132 |
+
if (badWord.length === 0) {
|
133 |
+
badWordsList.splice(badWordsList.indexOf(badWord), 1);
|
134 |
+
}
|
135 |
+
}
|
136 |
+
|
137 |
+
// Add default biases for dinkus and asterism
|
138 |
+
const logit_bias_exp = isNewModel ? logitBiasExp.slice() : [];
|
139 |
+
|
140 |
+
if (Array.isArray(logit_bias_exp) && Array.isArray(req.body.logit_bias_exp)) {
|
141 |
+
logit_bias_exp.push(...req.body.logit_bias_exp);
|
142 |
+
}
|
143 |
+
|
144 |
+
const data = {
|
145 |
+
'input': req.body.input,
|
146 |
+
'model': req.body.model,
|
147 |
+
'parameters': {
|
148 |
+
'use_string': req.body.use_string ?? true,
|
149 |
+
'temperature': req.body.temperature,
|
150 |
+
'max_length': req.body.max_length,
|
151 |
+
'min_length': req.body.min_length,
|
152 |
+
'tail_free_sampling': req.body.tail_free_sampling,
|
153 |
+
'repetition_penalty': req.body.repetition_penalty,
|
154 |
+
'repetition_penalty_range': req.body.repetition_penalty_range,
|
155 |
+
'repetition_penalty_slope': req.body.repetition_penalty_slope,
|
156 |
+
'repetition_penalty_frequency': req.body.repetition_penalty_frequency,
|
157 |
+
'repetition_penalty_presence': req.body.repetition_penalty_presence,
|
158 |
+
'repetition_penalty_whitelist': isNewModel ? repPenaltyAllowList : null,
|
159 |
+
'top_a': req.body.top_a,
|
160 |
+
'top_p': req.body.top_p,
|
161 |
+
'top_k': req.body.top_k,
|
162 |
+
'typical_p': req.body.typical_p,
|
163 |
+
'mirostat_lr': req.body.mirostat_lr,
|
164 |
+
'mirostat_tau': req.body.mirostat_tau,
|
165 |
+
'cfg_scale': req.body.cfg_scale,
|
166 |
+
'cfg_uc': req.body.cfg_uc,
|
167 |
+
'phrase_rep_pen': req.body.phrase_rep_pen,
|
168 |
+
'stop_sequences': req.body.stop_sequences,
|
169 |
+
'bad_words_ids': badWordsList.length ? badWordsList : null,
|
170 |
+
'logit_bias_exp': logit_bias_exp,
|
171 |
+
'generate_until_sentence': req.body.generate_until_sentence,
|
172 |
+
'use_cache': req.body.use_cache,
|
173 |
+
'return_full_text': req.body.return_full_text,
|
174 |
+
'prefix': req.body.prefix,
|
175 |
+
'order': req.body.order,
|
176 |
+
'num_logprobs': req.body.num_logprobs,
|
177 |
+
},
|
178 |
+
};
|
179 |
+
|
180 |
+
// Tells the model to stop generation at '>'
|
181 |
+
if ('theme_textadventure' === req.body.prefix &&
|
182 |
+
(true === req.body.model.includes('clio') ||
|
183 |
+
true === req.body.model.includes('kayra'))) {
|
184 |
+
data.parameters.eos_token_id = 49405;
|
185 |
+
}
|
186 |
+
|
187 |
+
console.log(util.inspect(data, { depth: 4 }));
|
188 |
+
|
189 |
+
const args = {
|
190 |
+
body: JSON.stringify(data),
|
191 |
+
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + api_key_novel },
|
192 |
+
signal: controller.signal,
|
193 |
+
};
|
194 |
+
|
195 |
+
try {
|
196 |
+
const url = req.body.streaming ? `${API_NOVELAI}/ai/generate-stream` : `${API_NOVELAI}/ai/generate`;
|
197 |
+
const response = await fetch(url, { method: 'POST', timeout: 0, ...args });
|
198 |
+
|
199 |
+
if (req.body.streaming) {
|
200 |
+
// Pipe remote SSE stream to Express response
|
201 |
+
forwardFetchResponse(response, res);
|
202 |
+
} else {
|
203 |
+
if (!response.ok) {
|
204 |
+
const text = await response.text();
|
205 |
+
let message = text;
|
206 |
+
console.log(`Novel API returned error: ${response.status} ${response.statusText} ${text}`);
|
207 |
+
|
208 |
+
try {
|
209 |
+
const data = JSON.parse(text);
|
210 |
+
message = data.message;
|
211 |
+
}
|
212 |
+
catch {
|
213 |
+
// ignore
|
214 |
+
}
|
215 |
+
|
216 |
+
return res.status(response.status).send({ error: { message } });
|
217 |
+
}
|
218 |
+
|
219 |
+
const data = await response.json();
|
220 |
+
console.log('NovelAI Output', data?.output);
|
221 |
+
return res.send(data);
|
222 |
+
}
|
223 |
+
} catch (error) {
|
224 |
+
return res.send({ error: true });
|
225 |
+
}
|
226 |
+
});
|
227 |
+
|
228 |
+
router.post('/generate-image', jsonParser, async (request, response) => {
|
229 |
+
if (!request.body) {
|
230 |
+
return response.sendStatus(400);
|
231 |
+
}
|
232 |
+
|
233 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.NOVEL);
|
234 |
+
|
235 |
+
if (!key) {
|
236 |
+
console.log('NovelAI Access Token is missing.');
|
237 |
+
return response.sendStatus(400);
|
238 |
+
}
|
239 |
+
|
240 |
+
try {
|
241 |
+
console.log('NAI Diffusion request:', request.body);
|
242 |
+
const generateUrl = `${IMAGE_NOVELAI}/ai/generate-image`;
|
243 |
+
const generateResult = await fetch(generateUrl, {
|
244 |
+
method: 'POST',
|
245 |
+
headers: {
|
246 |
+
'Authorization': `Bearer ${key}`,
|
247 |
+
'Content-Type': 'application/json',
|
248 |
+
},
|
249 |
+
body: JSON.stringify({
|
250 |
+
action: 'generate',
|
251 |
+
input: request.body.prompt,
|
252 |
+
model: request.body.model ?? 'nai-diffusion',
|
253 |
+
parameters: {
|
254 |
+
negative_prompt: request.body.negative_prompt ?? '',
|
255 |
+
height: request.body.height ?? 512,
|
256 |
+
width: request.body.width ?? 512,
|
257 |
+
scale: request.body.scale ?? 9,
|
258 |
+
seed: request.body.seed >= 0 ? request.body.seed : Math.floor(Math.random() * 9999999999),
|
259 |
+
sampler: request.body.sampler ?? 'k_dpmpp_2m',
|
260 |
+
steps: request.body.steps ?? 28,
|
261 |
+
n_samples: 1,
|
262 |
+
// NAI handholding for prompts
|
263 |
+
ucPreset: 0,
|
264 |
+
qualityToggle: false,
|
265 |
+
add_original_image: false,
|
266 |
+
controlnet_strength: 1,
|
267 |
+
dynamic_thresholding: request.body.decrisper ?? false,
|
268 |
+
legacy: false,
|
269 |
+
sm: request.body.sm ?? false,
|
270 |
+
sm_dyn: request.body.sm_dyn ?? false,
|
271 |
+
uncond_scale: 1,
|
272 |
+
},
|
273 |
+
}),
|
274 |
+
});
|
275 |
+
|
276 |
+
if (!generateResult.ok) {
|
277 |
+
const text = await generateResult.text();
|
278 |
+
console.log('NovelAI returned an error.', generateResult.statusText, text);
|
279 |
+
return response.sendStatus(500);
|
280 |
+
}
|
281 |
+
|
282 |
+
const archiveBuffer = await generateResult.arrayBuffer();
|
283 |
+
const imageBuffer = await extractFileFromZipBuffer(archiveBuffer, '.png');
|
284 |
+
|
285 |
+
if (!imageBuffer) {
|
286 |
+
console.warn('NovelAI generated an image, but the PNG file was not found.');
|
287 |
+
return response.sendStatus(500);
|
288 |
+
}
|
289 |
+
|
290 |
+
const originalBase64 = imageBuffer.toString('base64');
|
291 |
+
|
292 |
+
// No upscaling
|
293 |
+
if (isNaN(request.body.upscale_ratio) || request.body.upscale_ratio <= 1) {
|
294 |
+
return response.send(originalBase64);
|
295 |
+
}
|
296 |
+
|
297 |
+
try {
|
298 |
+
console.debug('Upscaling image...');
|
299 |
+
const upscaleUrl = `${API_NOVELAI}/ai/upscale`;
|
300 |
+
const upscaleResult = await fetch(upscaleUrl, {
|
301 |
+
method: 'POST',
|
302 |
+
headers: {
|
303 |
+
'Authorization': `Bearer ${key}`,
|
304 |
+
'Content-Type': 'application/json',
|
305 |
+
},
|
306 |
+
body: JSON.stringify({
|
307 |
+
image: originalBase64,
|
308 |
+
height: request.body.height,
|
309 |
+
width: request.body.width,
|
310 |
+
scale: request.body.upscale_ratio,
|
311 |
+
}),
|
312 |
+
});
|
313 |
+
|
314 |
+
if (!upscaleResult.ok) {
|
315 |
+
throw new Error('NovelAI returned an error.');
|
316 |
+
}
|
317 |
+
|
318 |
+
const upscaledArchiveBuffer = await upscaleResult.arrayBuffer();
|
319 |
+
const upscaledImageBuffer = await extractFileFromZipBuffer(upscaledArchiveBuffer, '.png');
|
320 |
+
|
321 |
+
if (!upscaledImageBuffer) {
|
322 |
+
throw new Error('NovelAI upscaled an image, but the PNG file was not found.');
|
323 |
+
}
|
324 |
+
|
325 |
+
const upscaledBase64 = upscaledImageBuffer.toString('base64');
|
326 |
+
|
327 |
+
return response.send(upscaledBase64);
|
328 |
+
} catch (error) {
|
329 |
+
console.warn('NovelAI generated an image, but upscaling failed. Returning original image.');
|
330 |
+
return response.send(originalBase64);
|
331 |
+
}
|
332 |
+
} catch (error) {
|
333 |
+
console.log(error);
|
334 |
+
return response.sendStatus(500);
|
335 |
+
}
|
336 |
+
});
|
337 |
+
|
338 |
+
router.post('/generate-voice', jsonParser, async (request, response) => {
|
339 |
+
const token = readSecret(request.user.directories, SECRET_KEYS.NOVEL);
|
340 |
+
|
341 |
+
if (!token) {
|
342 |
+
console.log('NovelAI Access Token is missing.');
|
343 |
+
return response.sendStatus(400);
|
344 |
+
}
|
345 |
+
|
346 |
+
const text = request.body.text;
|
347 |
+
const voice = request.body.voice;
|
348 |
+
|
349 |
+
if (!text || !voice) {
|
350 |
+
return response.sendStatus(400);
|
351 |
+
}
|
352 |
+
|
353 |
+
try {
|
354 |
+
const url = `${API_NOVELAI}/ai/generate-voice?text=${encodeURIComponent(text)}&voice=-1&seed=${encodeURIComponent(voice)}&opus=false&version=v2`;
|
355 |
+
const result = await fetch(url, {
|
356 |
+
method: 'GET',
|
357 |
+
headers: {
|
358 |
+
'Authorization': `Bearer ${token}`,
|
359 |
+
'Accept': 'audio/mpeg',
|
360 |
+
},
|
361 |
+
timeout: 0,
|
362 |
+
});
|
363 |
+
|
364 |
+
if (!result.ok) {
|
365 |
+
const errorText = await result.text();
|
366 |
+
console.log('NovelAI returned an error.', result.statusText, errorText);
|
367 |
+
return response.sendStatus(500);
|
368 |
+
}
|
369 |
+
|
370 |
+
const chunks = await readAllChunks(result.body);
|
371 |
+
const buffer = Buffer.concat(chunks);
|
372 |
+
response.setHeader('Content-Type', 'audio/mpeg');
|
373 |
+
return response.send(buffer);
|
374 |
+
}
|
375 |
+
catch (error) {
|
376 |
+
console.error(error);
|
377 |
+
return response.sendStatus(500);
|
378 |
+
}
|
379 |
+
});
|
380 |
+
|
381 |
+
module.exports = { router };
|
src/endpoints/openai.js
ADDED
@@ -0,0 +1,329 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const { readSecret, SECRET_KEYS } = require('./secrets');
|
2 |
+
const fetch = require('node-fetch').default;
|
3 |
+
const express = require('express');
|
4 |
+
const FormData = require('form-data');
|
5 |
+
const fs = require('fs');
|
6 |
+
const { jsonParser, urlencodedParser } = require('../express-common');
|
7 |
+
const { getConfigValue, mergeObjectWithYaml, excludeKeysByYaml, trimV1 } = require('../util');
|
8 |
+
const { setAdditionalHeaders } = require('../additional-headers');
|
9 |
+
const { OPENROUTER_HEADERS } = require('../constants');
|
10 |
+
|
11 |
+
const router = express.Router();
|
12 |
+
|
13 |
+
router.post('/caption-image', jsonParser, async (request, response) => {
|
14 |
+
try {
|
15 |
+
let key = '';
|
16 |
+
let headers = {};
|
17 |
+
let bodyParams = {};
|
18 |
+
|
19 |
+
if (request.body.api === 'openai' && !request.body.reverse_proxy) {
|
20 |
+
key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);
|
21 |
+
}
|
22 |
+
|
23 |
+
if (request.body.api === 'openrouter' && !request.body.reverse_proxy) {
|
24 |
+
key = readSecret(request.user.directories, SECRET_KEYS.OPENROUTER);
|
25 |
+
}
|
26 |
+
|
27 |
+
if (request.body.reverse_proxy && request.body.proxy_password) {
|
28 |
+
key = request.body.proxy_password;
|
29 |
+
}
|
30 |
+
|
31 |
+
if (request.body.api === 'custom') {
|
32 |
+
key = readSecret(request.user.directories, SECRET_KEYS.CUSTOM);
|
33 |
+
mergeObjectWithYaml(bodyParams, request.body.custom_include_body);
|
34 |
+
mergeObjectWithYaml(headers, request.body.custom_include_headers);
|
35 |
+
}
|
36 |
+
|
37 |
+
if (request.body.api === 'ooba') {
|
38 |
+
key = readSecret(request.user.directories, SECRET_KEYS.OOBA);
|
39 |
+
bodyParams.temperature = 0.1;
|
40 |
+
}
|
41 |
+
|
42 |
+
if (request.body.api === 'koboldcpp') {
|
43 |
+
key = readSecret(request.user.directories, SECRET_KEYS.KOBOLDCPP);
|
44 |
+
}
|
45 |
+
|
46 |
+
if (request.body.api === 'vllm') {
|
47 |
+
key = readSecret(request.user.directories, SECRET_KEYS.VLLM);
|
48 |
+
}
|
49 |
+
|
50 |
+
if (request.body.api === 'zerooneai') {
|
51 |
+
key = readSecret(request.user.directories, SECRET_KEYS.ZEROONEAI);
|
52 |
+
}
|
53 |
+
|
54 |
+
if (!key && !request.body.reverse_proxy && ['custom', 'ooba', 'koboldcpp', 'vllm'].includes(request.body.api) === false) {
|
55 |
+
console.log('No key found for API', request.body.api);
|
56 |
+
return response.sendStatus(400);
|
57 |
+
}
|
58 |
+
|
59 |
+
const body = {
|
60 |
+
model: request.body.model,
|
61 |
+
messages: [
|
62 |
+
{
|
63 |
+
role: 'user',
|
64 |
+
content: [
|
65 |
+
{ type: 'text', text: request.body.prompt },
|
66 |
+
{ type: 'image_url', image_url: { 'url': request.body.image } },
|
67 |
+
],
|
68 |
+
},
|
69 |
+
],
|
70 |
+
...bodyParams,
|
71 |
+
};
|
72 |
+
|
73 |
+
const captionSystemPrompt = getConfigValue('openai.captionSystemPrompt');
|
74 |
+
if (captionSystemPrompt) {
|
75 |
+
body.messages.unshift({
|
76 |
+
role: 'system',
|
77 |
+
content: captionSystemPrompt,
|
78 |
+
});
|
79 |
+
}
|
80 |
+
|
81 |
+
if (request.body.api === 'custom') {
|
82 |
+
excludeKeysByYaml(body, request.body.custom_exclude_body);
|
83 |
+
}
|
84 |
+
|
85 |
+
console.log('Multimodal captioning request', body);
|
86 |
+
|
87 |
+
let apiUrl = '';
|
88 |
+
|
89 |
+
if (request.body.api === 'openrouter') {
|
90 |
+
apiUrl = 'https://openrouter.ai/api/v1/chat/completions';
|
91 |
+
Object.assign(headers, OPENROUTER_HEADERS);
|
92 |
+
}
|
93 |
+
|
94 |
+
if (request.body.api === 'openai') {
|
95 |
+
apiUrl = 'https://api.openai.com/v1/chat/completions';
|
96 |
+
}
|
97 |
+
|
98 |
+
if (request.body.reverse_proxy) {
|
99 |
+
apiUrl = `${request.body.reverse_proxy}/chat/completions`;
|
100 |
+
}
|
101 |
+
|
102 |
+
if (request.body.api === 'custom') {
|
103 |
+
apiUrl = `${request.body.server_url}/chat/completions`;
|
104 |
+
}
|
105 |
+
|
106 |
+
if (request.body.api === 'zerooneai') {
|
107 |
+
apiUrl = 'https://api.01.ai/v1/chat/completions';
|
108 |
+
}
|
109 |
+
|
110 |
+
if (request.body.api === 'ooba') {
|
111 |
+
apiUrl = `${trimV1(request.body.server_url)}/v1/chat/completions`;
|
112 |
+
const imgMessage = body.messages.pop();
|
113 |
+
body.messages.push({
|
114 |
+
role: 'user',
|
115 |
+
content: imgMessage?.content?.[0]?.text,
|
116 |
+
});
|
117 |
+
body.messages.push({
|
118 |
+
role: 'user',
|
119 |
+
content: [],
|
120 |
+
image_url: imgMessage?.content?.[1]?.image_url?.url,
|
121 |
+
});
|
122 |
+
}
|
123 |
+
|
124 |
+
if (request.body.api === 'koboldcpp' || request.body.api === 'vllm') {
|
125 |
+
apiUrl = `${trimV1(request.body.server_url)}/v1/chat/completions`;
|
126 |
+
}
|
127 |
+
|
128 |
+
setAdditionalHeaders(request, { headers }, apiUrl);
|
129 |
+
|
130 |
+
const result = await fetch(apiUrl, {
|
131 |
+
method: 'POST',
|
132 |
+
headers: {
|
133 |
+
'Content-Type': 'application/json',
|
134 |
+
Authorization: `Bearer ${key}`,
|
135 |
+
...headers,
|
136 |
+
},
|
137 |
+
body: JSON.stringify(body),
|
138 |
+
timeout: 0,
|
139 |
+
});
|
140 |
+
|
141 |
+
if (!result.ok) {
|
142 |
+
const text = await result.text();
|
143 |
+
console.log('Multimodal captioning request failed', result.statusText, text);
|
144 |
+
return response.status(500).send(text);
|
145 |
+
}
|
146 |
+
|
147 |
+
const data = await result.json();
|
148 |
+
console.log('Multimodal captioning response', data);
|
149 |
+
const caption = data?.choices[0]?.message?.content;
|
150 |
+
|
151 |
+
if (!caption) {
|
152 |
+
return response.status(500).send('No caption found');
|
153 |
+
}
|
154 |
+
|
155 |
+
return response.json({ caption });
|
156 |
+
}
|
157 |
+
catch (error) {
|
158 |
+
console.error(error);
|
159 |
+
response.status(500).send('Internal server error');
|
160 |
+
}
|
161 |
+
});
|
162 |
+
|
163 |
+
router.post('/transcribe-audio', urlencodedParser, async (request, response) => {
|
164 |
+
try {
|
165 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);
|
166 |
+
|
167 |
+
if (!key) {
|
168 |
+
console.log('No OpenAI key found');
|
169 |
+
return response.sendStatus(400);
|
170 |
+
}
|
171 |
+
|
172 |
+
if (!request.file) {
|
173 |
+
console.log('No audio file found');
|
174 |
+
return response.sendStatus(400);
|
175 |
+
}
|
176 |
+
|
177 |
+
const formData = new FormData();
|
178 |
+
console.log('Processing audio file', request.file.path);
|
179 |
+
formData.append('file', fs.createReadStream(request.file.path), { filename: 'audio.wav', contentType: 'audio/wav' });
|
180 |
+
formData.append('model', request.body.model);
|
181 |
+
|
182 |
+
if (request.body.language) {
|
183 |
+
formData.append('language', request.body.language);
|
184 |
+
}
|
185 |
+
|
186 |
+
const result = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
187 |
+
method: 'POST',
|
188 |
+
headers: {
|
189 |
+
'Authorization': `Bearer ${key}`,
|
190 |
+
...formData.getHeaders(),
|
191 |
+
},
|
192 |
+
body: formData,
|
193 |
+
});
|
194 |
+
|
195 |
+
if (!result.ok) {
|
196 |
+
const text = await result.text();
|
197 |
+
console.log('OpenAI request failed', result.statusText, text);
|
198 |
+
return response.status(500).send(text);
|
199 |
+
}
|
200 |
+
|
201 |
+
fs.rmSync(request.file.path);
|
202 |
+
const data = await result.json();
|
203 |
+
console.log('OpenAI transcription response', data);
|
204 |
+
return response.json(data);
|
205 |
+
} catch (error) {
|
206 |
+
console.error('OpenAI transcription failed', error);
|
207 |
+
response.status(500).send('Internal server error');
|
208 |
+
}
|
209 |
+
});
|
210 |
+
|
211 |
+
router.post('/generate-voice', jsonParser, async (request, response) => {
|
212 |
+
try {
|
213 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);
|
214 |
+
|
215 |
+
if (!key) {
|
216 |
+
console.log('No OpenAI key found');
|
217 |
+
return response.sendStatus(400);
|
218 |
+
}
|
219 |
+
|
220 |
+
const result = await fetch('https://api.openai.com/v1/audio/speech', {
|
221 |
+
method: 'POST',
|
222 |
+
headers: {
|
223 |
+
'Content-Type': 'application/json',
|
224 |
+
Authorization: `Bearer ${key}`,
|
225 |
+
},
|
226 |
+
body: JSON.stringify({
|
227 |
+
input: request.body.text,
|
228 |
+
response_format: 'mp3',
|
229 |
+
voice: request.body.voice ?? 'alloy',
|
230 |
+
speed: request.body.speed ?? 1,
|
231 |
+
model: request.body.model ?? 'tts-1',
|
232 |
+
}),
|
233 |
+
});
|
234 |
+
|
235 |
+
if (!result.ok) {
|
236 |
+
const text = await result.text();
|
237 |
+
console.log('OpenAI request failed', result.statusText, text);
|
238 |
+
return response.status(500).send(text);
|
239 |
+
}
|
240 |
+
|
241 |
+
const buffer = await result.arrayBuffer();
|
242 |
+
response.setHeader('Content-Type', 'audio/mpeg');
|
243 |
+
return response.send(Buffer.from(buffer));
|
244 |
+
} catch (error) {
|
245 |
+
console.error('OpenAI TTS generation failed', error);
|
246 |
+
response.status(500).send('Internal server error');
|
247 |
+
}
|
248 |
+
});
|
249 |
+
|
250 |
+
router.post('/generate-image', jsonParser, async (request, response) => {
|
251 |
+
try {
|
252 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);
|
253 |
+
|
254 |
+
if (!key) {
|
255 |
+
console.log('No OpenAI key found');
|
256 |
+
return response.sendStatus(400);
|
257 |
+
}
|
258 |
+
|
259 |
+
console.log('OpenAI request', request.body);
|
260 |
+
|
261 |
+
const result = await fetch('https://api.openai.com/v1/images/generations', {
|
262 |
+
method: 'POST',
|
263 |
+
headers: {
|
264 |
+
'Content-Type': 'application/json',
|
265 |
+
Authorization: `Bearer ${key}`,
|
266 |
+
},
|
267 |
+
body: JSON.stringify(request.body),
|
268 |
+
timeout: 0,
|
269 |
+
});
|
270 |
+
|
271 |
+
if (!result.ok) {
|
272 |
+
const text = await result.text();
|
273 |
+
console.log('OpenAI request failed', result.statusText, text);
|
274 |
+
return response.status(500).send(text);
|
275 |
+
}
|
276 |
+
|
277 |
+
const data = await result.json();
|
278 |
+
return response.json(data);
|
279 |
+
} catch (error) {
|
280 |
+
console.error(error);
|
281 |
+
response.status(500).send('Internal server error');
|
282 |
+
}
|
283 |
+
});
|
284 |
+
|
285 |
+
const custom = express.Router();
|
286 |
+
|
287 |
+
custom.post('/generate-voice', jsonParser, async (request, response) => {
|
288 |
+
try {
|
289 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.CUSTOM_OPENAI_TTS);
|
290 |
+
const { input, provider_endpoint, response_format, voice, speed, model } = request.body;
|
291 |
+
|
292 |
+
if (!provider_endpoint) {
|
293 |
+
console.log('No OpenAI-compatible TTS provider endpoint provided');
|
294 |
+
return response.sendStatus(400);
|
295 |
+
}
|
296 |
+
|
297 |
+
const result = await fetch(provider_endpoint, {
|
298 |
+
method: 'POST',
|
299 |
+
headers: {
|
300 |
+
'Content-Type': 'application/json',
|
301 |
+
Authorization: `Bearer ${key ?? ''}`,
|
302 |
+
},
|
303 |
+
body: JSON.stringify({
|
304 |
+
input: input ?? '',
|
305 |
+
response_format: response_format ?? 'mp3',
|
306 |
+
voice: voice ?? 'alloy',
|
307 |
+
speed: speed ?? 1,
|
308 |
+
model: model ?? 'tts-1',
|
309 |
+
}),
|
310 |
+
});
|
311 |
+
|
312 |
+
if (!result.ok) {
|
313 |
+
const text = await result.text();
|
314 |
+
console.log('OpenAI request failed', result.statusText, text);
|
315 |
+
return response.status(500).send(text);
|
316 |
+
}
|
317 |
+
|
318 |
+
const buffer = await result.arrayBuffer();
|
319 |
+
response.setHeader('Content-Type', 'audio/mpeg');
|
320 |
+
return response.send(Buffer.from(buffer));
|
321 |
+
} catch (error) {
|
322 |
+
console.error('OpenAI TTS generation failed', error);
|
323 |
+
response.status(500).send('Internal server error');
|
324 |
+
}
|
325 |
+
});
|
326 |
+
|
327 |
+
router.use('/custom', custom);
|
328 |
+
|
329 |
+
module.exports = { router };
|
src/endpoints/presets.js
ADDED
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const fs = require('fs');
|
2 |
+
const path = require('path');
|
3 |
+
const express = require('express');
|
4 |
+
const sanitize = require('sanitize-filename');
|
5 |
+
const writeFileAtomicSync = require('write-file-atomic').sync;
|
6 |
+
const { getDefaultPresetFile, getDefaultPresets } = require('./content-manager');
|
7 |
+
const { jsonParser } = require('../express-common');
|
8 |
+
|
9 |
+
/**
|
10 |
+
* Gets the folder and extension for the preset settings based on the API source ID.
|
11 |
+
* @param {string} apiId API source ID
|
12 |
+
* @param {import('../users').UserDirectoryList} directories User directories
|
13 |
+
* @returns {object} Object containing the folder and extension for the preset settings
|
14 |
+
*/
|
15 |
+
function getPresetSettingsByAPI(apiId, directories) {
|
16 |
+
switch (apiId) {
|
17 |
+
case 'kobold':
|
18 |
+
case 'koboldhorde':
|
19 |
+
return { folder: directories.koboldAI_Settings, extension: '.json' };
|
20 |
+
case 'novel':
|
21 |
+
return { folder: directories.novelAI_Settings, extension: '.json' };
|
22 |
+
case 'textgenerationwebui':
|
23 |
+
return { folder: directories.textGen_Settings, extension: '.json' };
|
24 |
+
case 'openai':
|
25 |
+
return { folder: directories.openAI_Settings, extension: '.json' };
|
26 |
+
case 'instruct':
|
27 |
+
return { folder: directories.instruct, extension: '.json' };
|
28 |
+
case 'context':
|
29 |
+
return { folder: directories.context, extension: '.json' };
|
30 |
+
default:
|
31 |
+
return { folder: null, extension: null };
|
32 |
+
}
|
33 |
+
}
|
34 |
+
|
35 |
+
const router = express.Router();
|
36 |
+
|
37 |
+
router.post('/save', jsonParser, function (request, response) {
|
38 |
+
const name = sanitize(request.body.name);
|
39 |
+
if (!request.body.preset || !name) {
|
40 |
+
return response.sendStatus(400);
|
41 |
+
}
|
42 |
+
|
43 |
+
const settings = getPresetSettingsByAPI(request.body.apiId, request.user.directories);
|
44 |
+
const filename = name + settings.extension;
|
45 |
+
|
46 |
+
if (!settings.folder) {
|
47 |
+
return response.sendStatus(400);
|
48 |
+
}
|
49 |
+
|
50 |
+
const fullpath = path.join(settings.folder, filename);
|
51 |
+
writeFileAtomicSync(fullpath, JSON.stringify(request.body.preset, null, 4), 'utf-8');
|
52 |
+
return response.send({ name });
|
53 |
+
});
|
54 |
+
|
55 |
+
router.post('/delete', jsonParser, function (request, response) {
|
56 |
+
const name = sanitize(request.body.name);
|
57 |
+
if (!name) {
|
58 |
+
return response.sendStatus(400);
|
59 |
+
}
|
60 |
+
|
61 |
+
const settings = getPresetSettingsByAPI(request.body.apiId, request.user.directories);
|
62 |
+
const filename = name + settings.extension;
|
63 |
+
|
64 |
+
if (!settings.folder) {
|
65 |
+
return response.sendStatus(400);
|
66 |
+
}
|
67 |
+
|
68 |
+
const fullpath = path.join(settings.folder, filename);
|
69 |
+
|
70 |
+
if (fs.existsSync(fullpath)) {
|
71 |
+
fs.unlinkSync(fullpath);
|
72 |
+
return response.sendStatus(200);
|
73 |
+
} else {
|
74 |
+
return response.sendStatus(404);
|
75 |
+
}
|
76 |
+
});
|
77 |
+
|
78 |
+
router.post('/restore', jsonParser, function (request, response) {
|
79 |
+
try {
|
80 |
+
const settings = getPresetSettingsByAPI(request.body.apiId, request.user.directories);
|
81 |
+
const name = sanitize(request.body.name);
|
82 |
+
const defaultPresets = getDefaultPresets(request.user.directories);
|
83 |
+
|
84 |
+
const defaultPreset = defaultPresets.find(p => p.name === name && p.folder === settings.folder);
|
85 |
+
|
86 |
+
const result = { isDefault: false, preset: {} };
|
87 |
+
|
88 |
+
if (defaultPreset) {
|
89 |
+
result.isDefault = true;
|
90 |
+
result.preset = getDefaultPresetFile(defaultPreset.filename) || {};
|
91 |
+
}
|
92 |
+
|
93 |
+
return response.send(result);
|
94 |
+
} catch (error) {
|
95 |
+
console.log(error);
|
96 |
+
return response.sendStatus(500);
|
97 |
+
}
|
98 |
+
});
|
99 |
+
|
100 |
+
// TODO: Merge with /api/presets/save
|
101 |
+
router.post('/save-openai', jsonParser, function (request, response) {
|
102 |
+
if (!request.body || typeof request.query.name !== 'string') return response.sendStatus(400);
|
103 |
+
const name = sanitize(request.query.name);
|
104 |
+
if (!name) return response.sendStatus(400);
|
105 |
+
|
106 |
+
const filename = `${name}.json`;
|
107 |
+
const fullpath = path.join(request.user.directories.openAI_Settings, filename);
|
108 |
+
writeFileAtomicSync(fullpath, JSON.stringify(request.body, null, 4), 'utf-8');
|
109 |
+
return response.send({ name });
|
110 |
+
});
|
111 |
+
|
112 |
+
// TODO: Merge with /api/presets/delete
|
113 |
+
router.post('/delete-openai', jsonParser, function (request, response) {
|
114 |
+
if (!request.body || !request.body.name) {
|
115 |
+
return response.sendStatus(400);
|
116 |
+
}
|
117 |
+
|
118 |
+
const name = request.body.name;
|
119 |
+
const pathToFile = path.join(request.user.directories.openAI_Settings, `${name}.json`);
|
120 |
+
|
121 |
+
if (fs.existsSync(pathToFile)) {
|
122 |
+
fs.rmSync(pathToFile);
|
123 |
+
return response.send({ ok: true });
|
124 |
+
}
|
125 |
+
|
126 |
+
return response.send({ error: true });
|
127 |
+
});
|
128 |
+
|
129 |
+
module.exports = { router };
|
src/endpoints/quick-replies.js
ADDED
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const fs = require('fs');
|
2 |
+
const path = require('path');
|
3 |
+
const express = require('express');
|
4 |
+
const sanitize = require('sanitize-filename');
|
5 |
+
const writeFileAtomicSync = require('write-file-atomic').sync;
|
6 |
+
|
7 |
+
const { jsonParser } = require('../express-common');
|
8 |
+
|
9 |
+
const router = express.Router();
|
10 |
+
|
11 |
+
router.post('/save', jsonParser, (request, response) => {
|
12 |
+
if (!request.body || !request.body.name) {
|
13 |
+
return response.sendStatus(400);
|
14 |
+
}
|
15 |
+
|
16 |
+
const filename = path.join(request.user.directories.quickreplies, sanitize(request.body.name) + '.json');
|
17 |
+
writeFileAtomicSync(filename, JSON.stringify(request.body, null, 4), 'utf8');
|
18 |
+
|
19 |
+
return response.sendStatus(200);
|
20 |
+
});
|
21 |
+
|
22 |
+
router.post('/delete', jsonParser, (request, response) => {
|
23 |
+
if (!request.body || !request.body.name) {
|
24 |
+
return response.sendStatus(400);
|
25 |
+
}
|
26 |
+
|
27 |
+
const filename = path.join(request.user.directories.quickreplies, sanitize(request.body.name) + '.json');
|
28 |
+
if (fs.existsSync(filename)) {
|
29 |
+
fs.unlinkSync(filename);
|
30 |
+
}
|
31 |
+
|
32 |
+
return response.sendStatus(200);
|
33 |
+
});
|
34 |
+
|
35 |
+
module.exports = { router };
|
src/endpoints/search.js
ADDED
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const fetch = require('node-fetch').default;
|
2 |
+
const express = require('express');
|
3 |
+
const { readSecret, SECRET_KEYS } = require('./secrets');
|
4 |
+
const { jsonParser } = require('../express-common');
|
5 |
+
|
6 |
+
const router = express.Router();
|
7 |
+
|
8 |
+
// Cosplay as Chrome
|
9 |
+
const visitHeaders = {
|
10 |
+
'Accept': 'text/html',
|
11 |
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
|
12 |
+
'Accept-Language': 'en-US,en;q=0.5',
|
13 |
+
'Accept-Encoding': 'gzip, deflate, br',
|
14 |
+
'Connection': 'keep-alive',
|
15 |
+
'Cache-Control': 'no-cache',
|
16 |
+
'Pragma': 'no-cache',
|
17 |
+
'TE': 'trailers',
|
18 |
+
'DNT': '1',
|
19 |
+
'Sec-Fetch-Dest': 'document',
|
20 |
+
'Sec-Fetch-Mode': 'navigate',
|
21 |
+
'Sec-Fetch-Site': 'none',
|
22 |
+
'Sec-Fetch-User': '?1',
|
23 |
+
};
|
24 |
+
|
25 |
+
router.post('/serpapi', jsonParser, async (request, response) => {
|
26 |
+
try {
|
27 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.SERPAPI);
|
28 |
+
|
29 |
+
if (!key) {
|
30 |
+
console.log('No SerpApi key found');
|
31 |
+
return response.sendStatus(400);
|
32 |
+
}
|
33 |
+
|
34 |
+
const { query } = request.body;
|
35 |
+
const result = await fetch(`https://serpapi.com/search.json?q=${encodeURIComponent(query)}&api_key=${key}`);
|
36 |
+
|
37 |
+
console.log('SerpApi query', query);
|
38 |
+
|
39 |
+
if (!result.ok) {
|
40 |
+
const text = await result.text();
|
41 |
+
console.log('SerpApi request failed', result.statusText, text);
|
42 |
+
return response.status(500).send(text);
|
43 |
+
}
|
44 |
+
|
45 |
+
const data = await result.json();
|
46 |
+
return response.json(data);
|
47 |
+
} catch (error) {
|
48 |
+
console.log(error);
|
49 |
+
return response.sendStatus(500);
|
50 |
+
}
|
51 |
+
});
|
52 |
+
|
53 |
+
/**
|
54 |
+
* Get the transcript of a YouTube video
|
55 |
+
* @copyright https://github.com/Kakulukian/youtube-transcript (MIT License)
|
56 |
+
*/
|
57 |
+
router.post('/transcript', jsonParser, async (request, response) => {
|
58 |
+
try {
|
59 |
+
const he = require('he');
|
60 |
+
const RE_XML_TRANSCRIPT = /<text start="([^"]*)" dur="([^"]*)">([^<]*)<\/text>/g;
|
61 |
+
const id = request.body.id;
|
62 |
+
const lang = request.body.lang;
|
63 |
+
|
64 |
+
if (!id) {
|
65 |
+
console.log('Id is required for /transcript');
|
66 |
+
return response.sendStatus(400);
|
67 |
+
}
|
68 |
+
|
69 |
+
const videoPageResponse = await fetch(`https://www.youtube.com/watch?v=${id}`, {
|
70 |
+
headers: {
|
71 |
+
...(lang && { 'Accept-Language': lang }),
|
72 |
+
'User-Agent': visitHeaders['User-Agent'],
|
73 |
+
},
|
74 |
+
});
|
75 |
+
|
76 |
+
const videoPageBody = await videoPageResponse.text();
|
77 |
+
const splittedHTML = videoPageBody.split('"captions":');
|
78 |
+
|
79 |
+
if (splittedHTML.length <= 1) {
|
80 |
+
if (videoPageBody.includes('class="g-recaptcha"')) {
|
81 |
+
throw new Error('Too many requests');
|
82 |
+
}
|
83 |
+
if (!videoPageBody.includes('"playabilityStatus":')) {
|
84 |
+
throw new Error('Video is not available');
|
85 |
+
}
|
86 |
+
throw new Error('Transcript not available');
|
87 |
+
}
|
88 |
+
|
89 |
+
const captions = (() => {
|
90 |
+
try {
|
91 |
+
return JSON.parse(splittedHTML[1].split(',"videoDetails')[0].replace('\n', ''));
|
92 |
+
} catch (e) {
|
93 |
+
return undefined;
|
94 |
+
}
|
95 |
+
})()?.['playerCaptionsTracklistRenderer'];
|
96 |
+
|
97 |
+
if (!captions) {
|
98 |
+
throw new Error('Transcript disabled');
|
99 |
+
}
|
100 |
+
|
101 |
+
if (!('captionTracks' in captions)) {
|
102 |
+
throw new Error('Transcript not available');
|
103 |
+
}
|
104 |
+
|
105 |
+
if (lang && !captions.captionTracks.some(track => track.languageCode === lang)) {
|
106 |
+
throw new Error('Transcript not available in this language');
|
107 |
+
}
|
108 |
+
|
109 |
+
const transcriptURL = (lang ? captions.captionTracks.find(track => track.languageCode === lang) : captions.captionTracks[0]).baseUrl;
|
110 |
+
const transcriptResponse = await fetch(transcriptURL, {
|
111 |
+
headers: {
|
112 |
+
...(lang && { 'Accept-Language': lang }),
|
113 |
+
'User-Agent': visitHeaders['User-Agent'],
|
114 |
+
},
|
115 |
+
});
|
116 |
+
|
117 |
+
if (!transcriptResponse.ok) {
|
118 |
+
throw new Error('Transcript request failed');
|
119 |
+
}
|
120 |
+
|
121 |
+
const transcriptBody = await transcriptResponse.text();
|
122 |
+
const results = [...transcriptBody.matchAll(RE_XML_TRANSCRIPT)];
|
123 |
+
const transcript = results.map((result) => ({
|
124 |
+
text: result[3],
|
125 |
+
duration: parseFloat(result[2]),
|
126 |
+
offset: parseFloat(result[1]),
|
127 |
+
lang: lang ?? captions.captionTracks[0].languageCode,
|
128 |
+
}));
|
129 |
+
// The text is double-encoded
|
130 |
+
const transcriptText = transcript.map((line) => he.decode(he.decode(line.text))).join(' ');
|
131 |
+
|
132 |
+
return response.send(transcriptText);
|
133 |
+
} catch (error) {
|
134 |
+
console.log(error);
|
135 |
+
return response.sendStatus(500);
|
136 |
+
}
|
137 |
+
});
|
138 |
+
|
139 |
+
router.post('/searxng', jsonParser, async (request, response) => {
|
140 |
+
try {
|
141 |
+
const { baseUrl, query } = request.body;
|
142 |
+
|
143 |
+
if (!baseUrl || !query) {
|
144 |
+
console.log('Missing required parameters for /searxng');
|
145 |
+
return response.sendStatus(400);
|
146 |
+
}
|
147 |
+
|
148 |
+
console.log('SearXNG query', baseUrl, query);
|
149 |
+
|
150 |
+
const mainPageUrl = new URL(baseUrl);
|
151 |
+
const mainPageRequest = await fetch(mainPageUrl, { headers: visitHeaders });
|
152 |
+
|
153 |
+
if (!mainPageRequest.ok) {
|
154 |
+
console.log('SearXNG request failed', mainPageRequest.statusText);
|
155 |
+
return response.sendStatus(500);
|
156 |
+
}
|
157 |
+
|
158 |
+
const mainPageText = await mainPageRequest.text();
|
159 |
+
const clientHref = mainPageText.match(/href="(\/client.+\.css)"/)?.[1];
|
160 |
+
|
161 |
+
if (clientHref) {
|
162 |
+
const clientUrl = new URL(clientHref, baseUrl);
|
163 |
+
await fetch(clientUrl, { headers: visitHeaders });
|
164 |
+
}
|
165 |
+
|
166 |
+
const searchUrl = new URL('/search', baseUrl);
|
167 |
+
const searchParams = new URLSearchParams();
|
168 |
+
searchParams.append('q', query);
|
169 |
+
searchUrl.search = searchParams.toString();
|
170 |
+
|
171 |
+
const searchResult = await fetch(searchUrl, { headers: visitHeaders });
|
172 |
+
|
173 |
+
if (!searchResult.ok) {
|
174 |
+
const text = await searchResult.text();
|
175 |
+
console.log('SearXNG request failed', searchResult.statusText, text);
|
176 |
+
return response.sendStatus(500);
|
177 |
+
}
|
178 |
+
|
179 |
+
const data = await searchResult.text();
|
180 |
+
return response.send(data);
|
181 |
+
} catch (error) {
|
182 |
+
console.log('SearXNG request failed', error);
|
183 |
+
return response.sendStatus(500);
|
184 |
+
}
|
185 |
+
});
|
186 |
+
|
187 |
+
router.post('/visit', jsonParser, async (request, response) => {
|
188 |
+
try {
|
189 |
+
const url = request.body.url;
|
190 |
+
|
191 |
+
if (!url) {
|
192 |
+
console.log('No url provided for /visit');
|
193 |
+
return response.sendStatus(400);
|
194 |
+
}
|
195 |
+
|
196 |
+
try {
|
197 |
+
const urlObj = new URL(url);
|
198 |
+
|
199 |
+
// Reject relative URLs
|
200 |
+
if (urlObj.protocol === null || urlObj.host === null) {
|
201 |
+
throw new Error('Invalid URL format');
|
202 |
+
}
|
203 |
+
|
204 |
+
// Reject non-HTTP URLs
|
205 |
+
if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') {
|
206 |
+
throw new Error('Invalid protocol');
|
207 |
+
}
|
208 |
+
|
209 |
+
// Reject URLs with a non-standard port
|
210 |
+
if (urlObj.port !== '') {
|
211 |
+
throw new Error('Invalid port');
|
212 |
+
}
|
213 |
+
|
214 |
+
// Reject IP addresses
|
215 |
+
if (urlObj.hostname.match(/^\d+\.\d+\.\d+\.\d+$/)) {
|
216 |
+
throw new Error('Invalid hostname');
|
217 |
+
}
|
218 |
+
} catch (error) {
|
219 |
+
console.log('Invalid url provided for /visit', url);
|
220 |
+
return response.sendStatus(400);
|
221 |
+
}
|
222 |
+
|
223 |
+
console.log('Visiting web URL', url);
|
224 |
+
|
225 |
+
const result = await fetch(url, { headers: visitHeaders });
|
226 |
+
|
227 |
+
if (!result.ok) {
|
228 |
+
console.log(`Visit failed ${result.status} ${result.statusText}`);
|
229 |
+
return response.sendStatus(500);
|
230 |
+
}
|
231 |
+
|
232 |
+
const contentType = String(result.headers.get('content-type'));
|
233 |
+
if (!contentType.includes('text/html')) {
|
234 |
+
console.log(`Visit failed, content-type is ${contentType}, expected text/html`);
|
235 |
+
return response.sendStatus(500);
|
236 |
+
}
|
237 |
+
|
238 |
+
const text = await result.text();
|
239 |
+
return response.send(text);
|
240 |
+
} catch (error) {
|
241 |
+
console.log(error);
|
242 |
+
return response.sendStatus(500);
|
243 |
+
}
|
244 |
+
});
|
245 |
+
|
246 |
+
module.exports = { router };
|
src/endpoints/secrets.js
ADDED
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const fs = require('fs');
|
2 |
+
const path = require('path');
|
3 |
+
const express = require('express');
|
4 |
+
const { getConfigValue } = require('../util');
|
5 |
+
const writeFileAtomicSync = require('write-file-atomic').sync;
|
6 |
+
const { jsonParser } = require('../express-common');
|
7 |
+
|
8 |
+
const SECRETS_FILE = 'secrets.json';
|
9 |
+
const SECRET_KEYS = {
|
10 |
+
HORDE: 'api_key_horde',
|
11 |
+
MANCER: 'api_key_mancer',
|
12 |
+
VLLM: 'api_key_vllm',
|
13 |
+
APHRODITE: 'api_key_aphrodite',
|
14 |
+
TABBY: 'api_key_tabby',
|
15 |
+
OPENAI: 'api_key_openai',
|
16 |
+
NOVEL: 'api_key_novel',
|
17 |
+
CLAUDE: 'api_key_claude',
|
18 |
+
DEEPL: 'deepl',
|
19 |
+
LIBRE: 'libre',
|
20 |
+
LIBRE_URL: 'libre_url',
|
21 |
+
LINGVA_URL: 'lingva_url',
|
22 |
+
OPENROUTER: 'api_key_openrouter',
|
23 |
+
SCALE: 'api_key_scale',
|
24 |
+
AI21: 'api_key_ai21',
|
25 |
+
SCALE_COOKIE: 'scale_cookie',
|
26 |
+
ONERING_URL: 'oneringtranslator_url',
|
27 |
+
DEEPLX_URL: 'deeplx_url',
|
28 |
+
MAKERSUITE: 'api_key_makersuite',
|
29 |
+
SERPAPI: 'api_key_serpapi',
|
30 |
+
TOGETHERAI: 'api_key_togetherai',
|
31 |
+
MISTRALAI: 'api_key_mistralai',
|
32 |
+
CUSTOM: 'api_key_custom',
|
33 |
+
OOBA: 'api_key_ooba',
|
34 |
+
INFERMATICAI: 'api_key_infermaticai',
|
35 |
+
DREAMGEN: 'api_key_dreamgen',
|
36 |
+
NOMICAI: 'api_key_nomicai',
|
37 |
+
KOBOLDCPP: 'api_key_koboldcpp',
|
38 |
+
LLAMACPP: 'api_key_llamacpp',
|
39 |
+
COHERE: 'api_key_cohere',
|
40 |
+
PERPLEXITY: 'api_key_perplexity',
|
41 |
+
GROQ: 'api_key_groq',
|
42 |
+
AZURE_TTS: 'api_key_azure_tts',
|
43 |
+
FEATHERLESS: 'api_key_featherless',
|
44 |
+
ZEROONEAI: 'api_key_01ai',
|
45 |
+
HUGGINGFACE: 'api_key_huggingface',
|
46 |
+
STABILITY: 'api_key_stability',
|
47 |
+
BLOCKENTROPY: 'api_key_blockentropy',
|
48 |
+
CUSTOM_OPENAI_TTS: 'api_key_custom_openai_tts',
|
49 |
+
};
|
50 |
+
|
51 |
+
// These are the keys that are safe to expose, even if allowKeysExposure is false
|
52 |
+
const EXPORTABLE_KEYS = [
|
53 |
+
SECRET_KEYS.LIBRE_URL,
|
54 |
+
SECRET_KEYS.LINGVA_URL,
|
55 |
+
SECRET_KEYS.ONERING_URL,
|
56 |
+
SECRET_KEYS.DEEPLX_URL,
|
57 |
+
];
|
58 |
+
|
59 |
+
/**
|
60 |
+
* Writes a secret to the secrets file
|
61 |
+
* @param {import('../users').UserDirectoryList} directories User directories
|
62 |
+
* @param {string} key Secret key
|
63 |
+
* @param {string} value Secret value
|
64 |
+
*/
|
65 |
+
function writeSecret(directories, key, value) {
|
66 |
+
const filePath = path.join(directories.root, SECRETS_FILE);
|
67 |
+
|
68 |
+
if (!fs.existsSync(filePath)) {
|
69 |
+
const emptyFile = JSON.stringify({});
|
70 |
+
writeFileAtomicSync(filePath, emptyFile, 'utf-8');
|
71 |
+
}
|
72 |
+
|
73 |
+
const fileContents = fs.readFileSync(filePath, 'utf-8');
|
74 |
+
const secrets = JSON.parse(fileContents);
|
75 |
+
secrets[key] = value;
|
76 |
+
writeFileAtomicSync(filePath, JSON.stringify(secrets, null, 4), 'utf-8');
|
77 |
+
}
|
78 |
+
|
79 |
+
/**
|
80 |
+
* Deletes a secret from the secrets file
|
81 |
+
* @param {import('../users').UserDirectoryList} directories User directories
|
82 |
+
* @param {string} key Secret key
|
83 |
+
* @returns
|
84 |
+
*/
|
85 |
+
function deleteSecret(directories, key) {
|
86 |
+
const filePath = path.join(directories.root, SECRETS_FILE);
|
87 |
+
|
88 |
+
if (!fs.existsSync(filePath)) {
|
89 |
+
return;
|
90 |
+
}
|
91 |
+
|
92 |
+
const fileContents = fs.readFileSync(filePath, 'utf-8');
|
93 |
+
const secrets = JSON.parse(fileContents);
|
94 |
+
delete secrets[key];
|
95 |
+
writeFileAtomicSync(filePath, JSON.stringify(secrets, null, 4), 'utf-8');
|
96 |
+
}
|
97 |
+
|
98 |
+
/**
|
99 |
+
* Reads a secret from the secrets file
|
100 |
+
* @param {import('../users').UserDirectoryList} directories User directories
|
101 |
+
* @param {string} key Secret key
|
102 |
+
* @returns {string} Secret value
|
103 |
+
*/
|
104 |
+
function readSecret(directories, key) {
|
105 |
+
const filePath = path.join(directories.root, SECRETS_FILE);
|
106 |
+
|
107 |
+
if (!fs.existsSync(filePath)) {
|
108 |
+
return '';
|
109 |
+
}
|
110 |
+
|
111 |
+
const fileContents = fs.readFileSync(filePath, 'utf-8');
|
112 |
+
const secrets = JSON.parse(fileContents);
|
113 |
+
return secrets[key];
|
114 |
+
}
|
115 |
+
|
116 |
+
/**
|
117 |
+
* Reads the secret state from the secrets file
|
118 |
+
* @param {import('../users').UserDirectoryList} directories User directories
|
119 |
+
* @returns {object} Secret state
|
120 |
+
*/
|
121 |
+
function readSecretState(directories) {
|
122 |
+
const filePath = path.join(directories.root, SECRETS_FILE);
|
123 |
+
|
124 |
+
if (!fs.existsSync(filePath)) {
|
125 |
+
return {};
|
126 |
+
}
|
127 |
+
|
128 |
+
const fileContents = fs.readFileSync(filePath, 'utf8');
|
129 |
+
const secrets = JSON.parse(fileContents);
|
130 |
+
const state = {};
|
131 |
+
|
132 |
+
for (const key of Object.values(SECRET_KEYS)) {
|
133 |
+
state[key] = !!secrets[key]; // convert to boolean
|
134 |
+
}
|
135 |
+
|
136 |
+
return state;
|
137 |
+
}
|
138 |
+
|
139 |
+
/**
|
140 |
+
* Reads all secrets from the secrets file
|
141 |
+
* @param {import('../users').UserDirectoryList} directories User directories
|
142 |
+
* @returns {Record<string, string> | undefined} Secrets
|
143 |
+
*/
|
144 |
+
function getAllSecrets(directories) {
|
145 |
+
const filePath = path.join(directories.root, SECRETS_FILE);
|
146 |
+
|
147 |
+
if (!fs.existsSync(filePath)) {
|
148 |
+
console.log('Secrets file does not exist');
|
149 |
+
return undefined;
|
150 |
+
}
|
151 |
+
|
152 |
+
const fileContents = fs.readFileSync(filePath, 'utf8');
|
153 |
+
const secrets = JSON.parse(fileContents);
|
154 |
+
return secrets;
|
155 |
+
}
|
156 |
+
|
157 |
+
const router = express.Router();
|
158 |
+
|
159 |
+
router.post('/write', jsonParser, (request, response) => {
|
160 |
+
const key = request.body.key;
|
161 |
+
const value = request.body.value;
|
162 |
+
|
163 |
+
writeSecret(request.user.directories, key, value);
|
164 |
+
return response.send('ok');
|
165 |
+
});
|
166 |
+
|
167 |
+
router.post('/read', jsonParser, (request, response) => {
|
168 |
+
try {
|
169 |
+
const state = readSecretState(request.user.directories);
|
170 |
+
return response.send(state);
|
171 |
+
} catch (error) {
|
172 |
+
console.error(error);
|
173 |
+
return response.send({});
|
174 |
+
}
|
175 |
+
});
|
176 |
+
|
177 |
+
router.post('/view', jsonParser, async (request, response) => {
|
178 |
+
const allowKeysExposure = getConfigValue('allowKeysExposure', false);
|
179 |
+
|
180 |
+
if (!allowKeysExposure) {
|
181 |
+
console.error('secrets.json could not be viewed unless the value of allowKeysExposure in config.yaml is set to true');
|
182 |
+
return response.sendStatus(403);
|
183 |
+
}
|
184 |
+
|
185 |
+
try {
|
186 |
+
const secrets = getAllSecrets(request.user.directories);
|
187 |
+
|
188 |
+
if (!secrets) {
|
189 |
+
return response.sendStatus(404);
|
190 |
+
}
|
191 |
+
|
192 |
+
return response.send(secrets);
|
193 |
+
} catch (error) {
|
194 |
+
console.error(error);
|
195 |
+
return response.sendStatus(500);
|
196 |
+
}
|
197 |
+
});
|
198 |
+
|
199 |
+
router.post('/find', jsonParser, (request, response) => {
|
200 |
+
const allowKeysExposure = getConfigValue('allowKeysExposure', false);
|
201 |
+
const key = request.body.key;
|
202 |
+
|
203 |
+
if (!allowKeysExposure && !EXPORTABLE_KEYS.includes(key)) {
|
204 |
+
console.error('Cannot fetch secrets unless allowKeysExposure in config.yaml is set to true');
|
205 |
+
return response.sendStatus(403);
|
206 |
+
}
|
207 |
+
|
208 |
+
try {
|
209 |
+
const secret = readSecret(request.user.directories, key);
|
210 |
+
|
211 |
+
if (!secret) {
|
212 |
+
response.sendStatus(404);
|
213 |
+
}
|
214 |
+
|
215 |
+
return response.send({ value: secret });
|
216 |
+
} catch (error) {
|
217 |
+
console.error(error);
|
218 |
+
return response.sendStatus(500);
|
219 |
+
}
|
220 |
+
});
|
221 |
+
|
222 |
+
module.exports = {
|
223 |
+
writeSecret,
|
224 |
+
readSecret,
|
225 |
+
deleteSecret,
|
226 |
+
readSecretState,
|
227 |
+
getAllSecrets,
|
228 |
+
SECRET_KEYS,
|
229 |
+
router,
|
230 |
+
};
|
src/endpoints/settings.js
ADDED
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const fs = require('fs');
|
2 |
+
const path = require('path');
|
3 |
+
const express = require('express');
|
4 |
+
const _ = require('lodash');
|
5 |
+
const writeFileAtomicSync = require('write-file-atomic').sync;
|
6 |
+
const { SETTINGS_FILE } = require('../constants');
|
7 |
+
const { getConfigValue, generateTimestamp, removeOldBackups } = require('../util');
|
8 |
+
const { jsonParser } = require('../express-common');
|
9 |
+
const { getAllUserHandles, getUserDirectories } = require('../users');
|
10 |
+
|
11 |
+
const ENABLE_EXTENSIONS = getConfigValue('enableExtensions', true);
|
12 |
+
const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false);
|
13 |
+
|
14 |
+
// 10 minutes
|
15 |
+
const AUTOSAVE_INTERVAL = 10 * 60 * 1000;
|
16 |
+
|
17 |
+
/**
|
18 |
+
* Map of functions to trigger settings autosave for a user.
|
19 |
+
* @type {Map<string, function>}
|
20 |
+
*/
|
21 |
+
const AUTOSAVE_FUNCTIONS = new Map();
|
22 |
+
|
23 |
+
/**
|
24 |
+
* Triggers autosave for a user every 10 minutes.
|
25 |
+
* @param {string} handle User handle
|
26 |
+
* @returns {void}
|
27 |
+
*/
|
28 |
+
function triggerAutoSave(handle) {
|
29 |
+
if (!AUTOSAVE_FUNCTIONS.has(handle)) {
|
30 |
+
const throttledAutoSave = _.throttle(() => backupUserSettings(handle, true), AUTOSAVE_INTERVAL);
|
31 |
+
AUTOSAVE_FUNCTIONS.set(handle, throttledAutoSave);
|
32 |
+
}
|
33 |
+
|
34 |
+
const functionToCall = AUTOSAVE_FUNCTIONS.get(handle);
|
35 |
+
if (functionToCall && typeof functionToCall === 'function') {
|
36 |
+
functionToCall();
|
37 |
+
}
|
38 |
+
}
|
39 |
+
|
40 |
+
/**
|
41 |
+
* Reads and parses files from a directory.
|
42 |
+
* @param {string} directoryPath Path to the directory
|
43 |
+
* @param {string} fileExtension File extension
|
44 |
+
* @returns {Array} Parsed files
|
45 |
+
*/
|
46 |
+
function readAndParseFromDirectory(directoryPath, fileExtension = '.json') {
|
47 |
+
const files = fs
|
48 |
+
.readdirSync(directoryPath)
|
49 |
+
.filter(x => path.parse(x).ext == fileExtension)
|
50 |
+
.sort();
|
51 |
+
|
52 |
+
const parsedFiles = [];
|
53 |
+
|
54 |
+
files.forEach(item => {
|
55 |
+
try {
|
56 |
+
const file = fs.readFileSync(path.join(directoryPath, item), 'utf-8');
|
57 |
+
parsedFiles.push(fileExtension == '.json' ? JSON.parse(file) : file);
|
58 |
+
}
|
59 |
+
catch {
|
60 |
+
// skip
|
61 |
+
}
|
62 |
+
});
|
63 |
+
|
64 |
+
return parsedFiles;
|
65 |
+
}
|
66 |
+
|
67 |
+
/**
|
68 |
+
* Gets a sort function for sorting strings.
|
69 |
+
* @param {*} _
|
70 |
+
* @returns {(a: string, b: string) => number} Sort function
|
71 |
+
*/
|
72 |
+
function sortByName(_) {
|
73 |
+
return (a, b) => a.localeCompare(b);
|
74 |
+
}
|
75 |
+
|
76 |
+
/**
|
77 |
+
* Gets backup file prefix for user settings.
|
78 |
+
* @param {string} handle User handle
|
79 |
+
* @returns {string} File prefix
|
80 |
+
*/
|
81 |
+
function getFilePrefix(handle) {
|
82 |
+
return `settings_${handle}_`;
|
83 |
+
}
|
84 |
+
|
85 |
+
function readPresetsFromDirectory(directoryPath, options = {}) {
|
86 |
+
const {
|
87 |
+
sortFunction,
|
88 |
+
removeFileExtension = false,
|
89 |
+
fileExtension = '.json',
|
90 |
+
} = options;
|
91 |
+
|
92 |
+
const files = fs.readdirSync(directoryPath).sort(sortFunction).filter(x => path.parse(x).ext == fileExtension);
|
93 |
+
const fileContents = [];
|
94 |
+
const fileNames = [];
|
95 |
+
|
96 |
+
files.forEach(item => {
|
97 |
+
try {
|
98 |
+
const file = fs.readFileSync(path.join(directoryPath, item), 'utf8');
|
99 |
+
JSON.parse(file);
|
100 |
+
fileContents.push(file);
|
101 |
+
fileNames.push(removeFileExtension ? item.replace(/\.[^/.]+$/, '') : item);
|
102 |
+
} catch {
|
103 |
+
// skip
|
104 |
+
console.log(`${item} is not a valid JSON`);
|
105 |
+
}
|
106 |
+
});
|
107 |
+
|
108 |
+
return { fileContents, fileNames };
|
109 |
+
}
|
110 |
+
|
111 |
+
async function backupSettings() {
|
112 |
+
try {
|
113 |
+
const userHandles = await getAllUserHandles();
|
114 |
+
|
115 |
+
for (const handle of userHandles) {
|
116 |
+
backupUserSettings(handle, true);
|
117 |
+
}
|
118 |
+
} catch (err) {
|
119 |
+
console.log('Could not backup settings file', err);
|
120 |
+
}
|
121 |
+
}
|
122 |
+
|
123 |
+
/**
|
124 |
+
* Makes a backup of the user's settings file.
|
125 |
+
* @param {string} handle User handle
|
126 |
+
* @param {boolean} preventDuplicates Prevent duplicate backups
|
127 |
+
* @returns {void}
|
128 |
+
*/
|
129 |
+
function backupUserSettings(handle, preventDuplicates) {
|
130 |
+
const userDirectories = getUserDirectories(handle);
|
131 |
+
const backupFile = path.join(userDirectories.backups, `${getFilePrefix(handle)}${generateTimestamp()}.json`);
|
132 |
+
const sourceFile = path.join(userDirectories.root, SETTINGS_FILE);
|
133 |
+
|
134 |
+
if (preventDuplicates && isDuplicateBackup(handle, sourceFile)) {
|
135 |
+
return;
|
136 |
+
}
|
137 |
+
|
138 |
+
if (!fs.existsSync(sourceFile)) {
|
139 |
+
return;
|
140 |
+
}
|
141 |
+
|
142 |
+
fs.copyFileSync(sourceFile, backupFile);
|
143 |
+
removeOldBackups(userDirectories.backups, `settings_${handle}`);
|
144 |
+
}
|
145 |
+
|
146 |
+
/**
|
147 |
+
* Checks if the backup would be a duplicate.
|
148 |
+
* @param {string} handle User handle
|
149 |
+
* @param {string} sourceFile Source file path
|
150 |
+
* @returns {boolean} True if the backup is a duplicate
|
151 |
+
*/
|
152 |
+
function isDuplicateBackup(handle, sourceFile) {
|
153 |
+
const latestBackup = getLatestBackup(handle);
|
154 |
+
if (!latestBackup) {
|
155 |
+
return false;
|
156 |
+
}
|
157 |
+
return areFilesEqual(latestBackup, sourceFile);
|
158 |
+
}
|
159 |
+
|
160 |
+
/**
|
161 |
+
* Returns true if the two files are equal.
|
162 |
+
* @param {string} file1 File path
|
163 |
+
* @param {string} file2 File path
|
164 |
+
*/
|
165 |
+
function areFilesEqual(file1, file2) {
|
166 |
+
if (!fs.existsSync(file1) || !fs.existsSync(file2)) {
|
167 |
+
return false;
|
168 |
+
}
|
169 |
+
|
170 |
+
const content1 = fs.readFileSync(file1);
|
171 |
+
const content2 = fs.readFileSync(file2);
|
172 |
+
return content1.toString() === content2.toString();
|
173 |
+
}
|
174 |
+
|
175 |
+
/**
|
176 |
+
* Gets the latest backup file for a user.
|
177 |
+
* @param {string} handle User handle
|
178 |
+
* @returns {string|null} Latest backup file. Null if no backup exists.
|
179 |
+
*/
|
180 |
+
function getLatestBackup(handle) {
|
181 |
+
const userDirectories = getUserDirectories(handle);
|
182 |
+
const backupFiles = fs.readdirSync(userDirectories.backups)
|
183 |
+
.filter(x => x.startsWith(getFilePrefix(handle)))
|
184 |
+
.map(x => ({ name: x, ctime: fs.statSync(path.join(userDirectories.backups, x)).ctimeMs }));
|
185 |
+
const latestBackup = backupFiles.sort((a, b) => b.ctime - a.ctime)[0]?.name;
|
186 |
+
if (!latestBackup) {
|
187 |
+
return null;
|
188 |
+
}
|
189 |
+
return path.join(userDirectories.backups, latestBackup);
|
190 |
+
}
|
191 |
+
|
192 |
+
const router = express.Router();
|
193 |
+
|
194 |
+
router.post('/save', jsonParser, function (request, response) {
|
195 |
+
try {
|
196 |
+
const pathToSettings = path.join(request.user.directories.root, SETTINGS_FILE);
|
197 |
+
writeFileAtomicSync(pathToSettings, JSON.stringify(request.body, null, 4), 'utf8');
|
198 |
+
triggerAutoSave(request.user.profile.handle);
|
199 |
+
response.send({ result: 'ok' });
|
200 |
+
} catch (err) {
|
201 |
+
console.log(err);
|
202 |
+
response.send(err);
|
203 |
+
}
|
204 |
+
});
|
205 |
+
|
206 |
+
// Wintermute's code
|
207 |
+
router.post('/get', jsonParser, (request, response) => {
|
208 |
+
let settings;
|
209 |
+
try {
|
210 |
+
const pathToSettings = path.join(request.user.directories.root, SETTINGS_FILE);
|
211 |
+
settings = fs.readFileSync(pathToSettings, 'utf8');
|
212 |
+
} catch (e) {
|
213 |
+
return response.sendStatus(500);
|
214 |
+
}
|
215 |
+
|
216 |
+
// NovelAI Settings
|
217 |
+
const { fileContents: novelai_settings, fileNames: novelai_setting_names }
|
218 |
+
= readPresetsFromDirectory(request.user.directories.novelAI_Settings, {
|
219 |
+
sortFunction: sortByName(request.user.directories.novelAI_Settings),
|
220 |
+
removeFileExtension: true,
|
221 |
+
});
|
222 |
+
|
223 |
+
// OpenAI Settings
|
224 |
+
const { fileContents: openai_settings, fileNames: openai_setting_names }
|
225 |
+
= readPresetsFromDirectory(request.user.directories.openAI_Settings, {
|
226 |
+
sortFunction: sortByName(request.user.directories.openAI_Settings), removeFileExtension: true,
|
227 |
+
});
|
228 |
+
|
229 |
+
// TextGenerationWebUI Settings
|
230 |
+
const { fileContents: textgenerationwebui_presets, fileNames: textgenerationwebui_preset_names }
|
231 |
+
= readPresetsFromDirectory(request.user.directories.textGen_Settings, {
|
232 |
+
sortFunction: sortByName(request.user.directories.textGen_Settings), removeFileExtension: true,
|
233 |
+
});
|
234 |
+
|
235 |
+
//Kobold
|
236 |
+
const { fileContents: koboldai_settings, fileNames: koboldai_setting_names }
|
237 |
+
= readPresetsFromDirectory(request.user.directories.koboldAI_Settings, {
|
238 |
+
sortFunction: sortByName(request.user.directories.koboldAI_Settings), removeFileExtension: true,
|
239 |
+
});
|
240 |
+
|
241 |
+
const worldFiles = fs
|
242 |
+
.readdirSync(request.user.directories.worlds)
|
243 |
+
.filter(file => path.extname(file).toLowerCase() === '.json')
|
244 |
+
.sort((a, b) => a.localeCompare(b));
|
245 |
+
const world_names = worldFiles.map(item => path.parse(item).name);
|
246 |
+
|
247 |
+
const themes = readAndParseFromDirectory(request.user.directories.themes);
|
248 |
+
const movingUIPresets = readAndParseFromDirectory(request.user.directories.movingUI);
|
249 |
+
const quickReplyPresets = readAndParseFromDirectory(request.user.directories.quickreplies);
|
250 |
+
|
251 |
+
const instruct = readAndParseFromDirectory(request.user.directories.instruct);
|
252 |
+
const context = readAndParseFromDirectory(request.user.directories.context);
|
253 |
+
|
254 |
+
response.send({
|
255 |
+
settings,
|
256 |
+
koboldai_settings,
|
257 |
+
koboldai_setting_names,
|
258 |
+
world_names,
|
259 |
+
novelai_settings,
|
260 |
+
novelai_setting_names,
|
261 |
+
openai_settings,
|
262 |
+
openai_setting_names,
|
263 |
+
textgenerationwebui_presets,
|
264 |
+
textgenerationwebui_preset_names,
|
265 |
+
themes,
|
266 |
+
movingUIPresets,
|
267 |
+
quickReplyPresets,
|
268 |
+
instruct,
|
269 |
+
context,
|
270 |
+
enable_extensions: ENABLE_EXTENSIONS,
|
271 |
+
enable_accounts: ENABLE_ACCOUNTS,
|
272 |
+
});
|
273 |
+
});
|
274 |
+
|
275 |
+
router.post('/get-snapshots', jsonParser, async (request, response) => {
|
276 |
+
try {
|
277 |
+
const snapshots = fs.readdirSync(request.user.directories.backups);
|
278 |
+
const userFilesPattern = getFilePrefix(request.user.profile.handle);
|
279 |
+
const userSnapshots = snapshots.filter(x => x.startsWith(userFilesPattern));
|
280 |
+
|
281 |
+
const result = userSnapshots.map(x => {
|
282 |
+
const stat = fs.statSync(path.join(request.user.directories.backups, x));
|
283 |
+
return { date: stat.ctimeMs, name: x, size: stat.size };
|
284 |
+
});
|
285 |
+
|
286 |
+
response.json(result);
|
287 |
+
} catch (error) {
|
288 |
+
console.log(error);
|
289 |
+
response.sendStatus(500);
|
290 |
+
}
|
291 |
+
});
|
292 |
+
|
293 |
+
router.post('/load-snapshot', jsonParser, async (request, response) => {
|
294 |
+
try {
|
295 |
+
const userFilesPattern = getFilePrefix(request.user.profile.handle);
|
296 |
+
|
297 |
+
if (!request.body.name || !request.body.name.startsWith(userFilesPattern)) {
|
298 |
+
return response.status(400).send({ error: 'Invalid snapshot name' });
|
299 |
+
}
|
300 |
+
|
301 |
+
const snapshotName = request.body.name;
|
302 |
+
const snapshotPath = path.join(request.user.directories.backups, snapshotName);
|
303 |
+
|
304 |
+
if (!fs.existsSync(snapshotPath)) {
|
305 |
+
return response.sendStatus(404);
|
306 |
+
}
|
307 |
+
|
308 |
+
const content = fs.readFileSync(snapshotPath, 'utf8');
|
309 |
+
|
310 |
+
response.send(content);
|
311 |
+
} catch (error) {
|
312 |
+
console.log(error);
|
313 |
+
response.sendStatus(500);
|
314 |
+
}
|
315 |
+
});
|
316 |
+
|
317 |
+
router.post('/make-snapshot', jsonParser, async (request, response) => {
|
318 |
+
try {
|
319 |
+
backupUserSettings(request.user.profile.handle, false);
|
320 |
+
response.sendStatus(204);
|
321 |
+
} catch (error) {
|
322 |
+
console.log(error);
|
323 |
+
response.sendStatus(500);
|
324 |
+
}
|
325 |
+
});
|
326 |
+
|
327 |
+
router.post('/restore-snapshot', jsonParser, async (request, response) => {
|
328 |
+
try {
|
329 |
+
const userFilesPattern = getFilePrefix(request.user.profile.handle);
|
330 |
+
|
331 |
+
if (!request.body.name || !request.body.name.startsWith(userFilesPattern)) {
|
332 |
+
return response.status(400).send({ error: 'Invalid snapshot name' });
|
333 |
+
}
|
334 |
+
|
335 |
+
const snapshotName = request.body.name;
|
336 |
+
const snapshotPath = path.join(request.user.directories.backups, snapshotName);
|
337 |
+
|
338 |
+
if (!fs.existsSync(snapshotPath)) {
|
339 |
+
return response.sendStatus(404);
|
340 |
+
}
|
341 |
+
|
342 |
+
const pathToSettings = path.join(request.user.directories.root, SETTINGS_FILE);
|
343 |
+
fs.rmSync(pathToSettings, { force: true });
|
344 |
+
fs.copyFileSync(snapshotPath, pathToSettings);
|
345 |
+
|
346 |
+
response.sendStatus(204);
|
347 |
+
} catch (error) {
|
348 |
+
console.log(error);
|
349 |
+
response.sendStatus(500);
|
350 |
+
}
|
351 |
+
});
|
352 |
+
|
353 |
+
/**
|
354 |
+
* Initializes the settings endpoint
|
355 |
+
*/
|
356 |
+
async function init() {
|
357 |
+
await backupSettings();
|
358 |
+
}
|
359 |
+
|
360 |
+
module.exports = { router, init };
|
src/endpoints/speech.js
ADDED
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const express = require('express');
|
2 |
+
const { jsonParser } = require('../express-common');
|
3 |
+
|
4 |
+
const router = express.Router();
|
5 |
+
|
6 |
+
/**
|
7 |
+
* Gets the audio data from a base64-encoded audio file.
|
8 |
+
* @param {string} audio Base64-encoded audio
|
9 |
+
* @returns {Float64Array} Audio data
|
10 |
+
*/
|
11 |
+
function getWaveFile(audio) {
|
12 |
+
const wavefile = require('wavefile');
|
13 |
+
const wav = new wavefile.WaveFile();
|
14 |
+
wav.fromDataURI(audio);
|
15 |
+
wav.toBitDepth('32f');
|
16 |
+
wav.toSampleRate(16000);
|
17 |
+
let audioData = wav.getSamples();
|
18 |
+
if (Array.isArray(audioData)) {
|
19 |
+
if (audioData.length > 1) {
|
20 |
+
const SCALING_FACTOR = Math.sqrt(2);
|
21 |
+
|
22 |
+
// Merge channels (into first channel to save memory)
|
23 |
+
for (let i = 0; i < audioData[0].length; ++i) {
|
24 |
+
audioData[0][i] = SCALING_FACTOR * (audioData[0][i] + audioData[1][i]) / 2;
|
25 |
+
}
|
26 |
+
}
|
27 |
+
|
28 |
+
// Select first channel
|
29 |
+
audioData = audioData[0];
|
30 |
+
}
|
31 |
+
|
32 |
+
return audioData;
|
33 |
+
}
|
34 |
+
|
35 |
+
router.post('/recognize', jsonParser, async (req, res) => {
|
36 |
+
try {
|
37 |
+
const TASK = 'automatic-speech-recognition';
|
38 |
+
const { model, audio, lang } = req.body;
|
39 |
+
const module = await import('../transformers.mjs');
|
40 |
+
const pipe = await module.default.getPipeline(TASK, model);
|
41 |
+
const wav = getWaveFile(audio);
|
42 |
+
const start = performance.now();
|
43 |
+
const result = await pipe(wav, { language: lang || null, task: 'transcribe' });
|
44 |
+
const end = performance.now();
|
45 |
+
console.log(`Execution duration: ${(end - start) / 1000} seconds`);
|
46 |
+
console.log('Transcribed audio:', result.text);
|
47 |
+
|
48 |
+
return res.json({ text: result.text });
|
49 |
+
} catch (error) {
|
50 |
+
console.error(error);
|
51 |
+
return res.sendStatus(500);
|
52 |
+
}
|
53 |
+
});
|
54 |
+
|
55 |
+
router.post('/synthesize', jsonParser, async (req, res) => {
|
56 |
+
try {
|
57 |
+
const wavefile = require('wavefile');
|
58 |
+
const TASK = 'text-to-speech';
|
59 |
+
const { text, model, speaker } = req.body;
|
60 |
+
const module = await import('../transformers.mjs');
|
61 |
+
const pipe = await module.default.getPipeline(TASK, model);
|
62 |
+
const speaker_embeddings = speaker
|
63 |
+
? new Float32Array(new Uint8Array(Buffer.from(speaker.startsWith('data:') ? speaker.split(',')[1] : speaker, 'base64')).buffer)
|
64 |
+
: null;
|
65 |
+
const start = performance.now();
|
66 |
+
const result = await pipe(text, { speaker_embeddings: speaker_embeddings });
|
67 |
+
const end = performance.now();
|
68 |
+
console.log(`Execution duration: ${(end - start) / 1000} seconds`);
|
69 |
+
|
70 |
+
const wav = new wavefile.WaveFile();
|
71 |
+
wav.fromScratch(1, result.sampling_rate, '32f', result.audio);
|
72 |
+
const buffer = wav.toBuffer();
|
73 |
+
|
74 |
+
res.set('Content-Type', 'audio/wav');
|
75 |
+
return res.send(Buffer.from(buffer));
|
76 |
+
} catch (error) {
|
77 |
+
console.error(error);
|
78 |
+
return res.sendStatus(500);
|
79 |
+
}
|
80 |
+
});
|
81 |
+
|
82 |
+
module.exports = { router };
|
src/endpoints/sprites.js
ADDED
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
const fs = require('fs');
|
3 |
+
const path = require('path');
|
4 |
+
const express = require('express');
|
5 |
+
const mime = require('mime-types');
|
6 |
+
const sanitize = require('sanitize-filename');
|
7 |
+
const writeFileAtomicSync = require('write-file-atomic').sync;
|
8 |
+
const { getImageBuffers } = require('../util');
|
9 |
+
const { jsonParser, urlencodedParser } = require('../express-common');
|
10 |
+
|
11 |
+
/**
|
12 |
+
* Gets the path to the sprites folder for the provided character name
|
13 |
+
* @param {import('../users').UserDirectoryList} directories - User directories
|
14 |
+
* @param {string} name - The name of the character
|
15 |
+
* @param {boolean} isSubfolder - Whether the name contains a subfolder
|
16 |
+
* @returns {string | null} The path to the sprites folder. Null if the name is invalid.
|
17 |
+
*/
|
18 |
+
function getSpritesPath(directories, name, isSubfolder) {
|
19 |
+
if (isSubfolder) {
|
20 |
+
const nameParts = name.split('/');
|
21 |
+
const characterName = sanitize(nameParts[0]);
|
22 |
+
const subfolderName = sanitize(nameParts[1]);
|
23 |
+
|
24 |
+
if (!characterName || !subfolderName) {
|
25 |
+
return null;
|
26 |
+
}
|
27 |
+
|
28 |
+
return path.join(directories.characters, characterName, subfolderName);
|
29 |
+
}
|
30 |
+
|
31 |
+
name = sanitize(name);
|
32 |
+
|
33 |
+
if (!name) {
|
34 |
+
return null;
|
35 |
+
}
|
36 |
+
|
37 |
+
return path.join(directories.characters, name);
|
38 |
+
}
|
39 |
+
|
40 |
+
/**
|
41 |
+
* Imports base64 encoded sprites from RisuAI character data.
|
42 |
+
* The sprites are saved in the character's sprites folder.
|
43 |
+
* The additionalAssets and emotions are removed from the data.
|
44 |
+
* @param {import('../users').UserDirectoryList} directories User directories
|
45 |
+
* @param {object} data RisuAI character data
|
46 |
+
* @returns {void}
|
47 |
+
*/
|
48 |
+
function importRisuSprites(directories, data) {
|
49 |
+
try {
|
50 |
+
const name = data?.data?.name;
|
51 |
+
const risuData = data?.data?.extensions?.risuai;
|
52 |
+
|
53 |
+
// Not a Risu AI character
|
54 |
+
if (!risuData || !name) {
|
55 |
+
return;
|
56 |
+
}
|
57 |
+
|
58 |
+
let images = [];
|
59 |
+
|
60 |
+
if (Array.isArray(risuData.additionalAssets)) {
|
61 |
+
images = images.concat(risuData.additionalAssets);
|
62 |
+
}
|
63 |
+
|
64 |
+
if (Array.isArray(risuData.emotions)) {
|
65 |
+
images = images.concat(risuData.emotions);
|
66 |
+
}
|
67 |
+
|
68 |
+
// No sprites to import
|
69 |
+
if (images.length === 0) {
|
70 |
+
return;
|
71 |
+
}
|
72 |
+
|
73 |
+
// Create sprites folder if it doesn't exist
|
74 |
+
const spritesPath = path.join(directories.characters, name);
|
75 |
+
if (!fs.existsSync(spritesPath)) {
|
76 |
+
fs.mkdirSync(spritesPath);
|
77 |
+
}
|
78 |
+
|
79 |
+
// Path to sprites is not a directory. This should never happen.
|
80 |
+
if (!fs.statSync(spritesPath).isDirectory()) {
|
81 |
+
return;
|
82 |
+
}
|
83 |
+
|
84 |
+
console.log(`RisuAI: Found ${images.length} sprites for ${name}. Writing to disk.`);
|
85 |
+
const files = fs.readdirSync(spritesPath);
|
86 |
+
|
87 |
+
outer: for (const [label, fileBase64] of images) {
|
88 |
+
// Remove existing sprite with the same label
|
89 |
+
for (const file of files) {
|
90 |
+
if (path.parse(file).name === label) {
|
91 |
+
console.log(`RisuAI: The sprite ${label} for ${name} already exists. Skipping.`);
|
92 |
+
continue outer;
|
93 |
+
}
|
94 |
+
}
|
95 |
+
|
96 |
+
const filename = label + '.png';
|
97 |
+
const pathToFile = path.join(spritesPath, filename);
|
98 |
+
writeFileAtomicSync(pathToFile, fileBase64, { encoding: 'base64' });
|
99 |
+
}
|
100 |
+
|
101 |
+
// Remove additionalAssets and emotions from data (they are now in the sprites folder)
|
102 |
+
delete data.data.extensions.risuai.additionalAssets;
|
103 |
+
delete data.data.extensions.risuai.emotions;
|
104 |
+
} catch (error) {
|
105 |
+
console.error(error);
|
106 |
+
}
|
107 |
+
}
|
108 |
+
|
109 |
+
const router = express.Router();
|
110 |
+
|
111 |
+
router.get('/get', jsonParser, function (request, response) {
|
112 |
+
const name = String(request.query.name);
|
113 |
+
const isSubfolder = name.includes('/');
|
114 |
+
const spritesPath = getSpritesPath(request.user.directories, name, isSubfolder);
|
115 |
+
let sprites = [];
|
116 |
+
|
117 |
+
try {
|
118 |
+
if (spritesPath && fs.existsSync(spritesPath) && fs.statSync(spritesPath).isDirectory()) {
|
119 |
+
sprites = fs.readdirSync(spritesPath)
|
120 |
+
.filter(file => {
|
121 |
+
const mimeType = mime.lookup(file);
|
122 |
+
return mimeType && mimeType.startsWith('image/');
|
123 |
+
})
|
124 |
+
.map((file) => {
|
125 |
+
const pathToSprite = path.join(spritesPath, file);
|
126 |
+
return {
|
127 |
+
label: path.parse(pathToSprite).name.toLowerCase(),
|
128 |
+
path: `/characters/${name}/${file}`,
|
129 |
+
};
|
130 |
+
});
|
131 |
+
}
|
132 |
+
}
|
133 |
+
catch (err) {
|
134 |
+
console.log(err);
|
135 |
+
}
|
136 |
+
return response.send(sprites);
|
137 |
+
});
|
138 |
+
|
139 |
+
router.post('/delete', jsonParser, async (request, response) => {
|
140 |
+
const label = request.body.label;
|
141 |
+
const name = request.body.name;
|
142 |
+
|
143 |
+
if (!label || !name) {
|
144 |
+
return response.sendStatus(400);
|
145 |
+
}
|
146 |
+
|
147 |
+
try {
|
148 |
+
const spritesPath = path.join(request.user.directories.characters, name);
|
149 |
+
|
150 |
+
// No sprites folder exists, or not a directory
|
151 |
+
if (!fs.existsSync(spritesPath) || !fs.statSync(spritesPath).isDirectory()) {
|
152 |
+
return response.sendStatus(404);
|
153 |
+
}
|
154 |
+
|
155 |
+
const files = fs.readdirSync(spritesPath);
|
156 |
+
|
157 |
+
// Remove existing sprite with the same label
|
158 |
+
for (const file of files) {
|
159 |
+
if (path.parse(file).name === label) {
|
160 |
+
fs.rmSync(path.join(spritesPath, file));
|
161 |
+
}
|
162 |
+
}
|
163 |
+
|
164 |
+
return response.sendStatus(200);
|
165 |
+
} catch (error) {
|
166 |
+
console.error(error);
|
167 |
+
return response.sendStatus(500);
|
168 |
+
}
|
169 |
+
});
|
170 |
+
|
171 |
+
router.post('/upload-zip', urlencodedParser, async (request, response) => {
|
172 |
+
const file = request.file;
|
173 |
+
const name = request.body.name;
|
174 |
+
|
175 |
+
if (!file || !name) {
|
176 |
+
return response.sendStatus(400);
|
177 |
+
}
|
178 |
+
|
179 |
+
try {
|
180 |
+
const spritesPath = path.join(request.user.directories.characters, name);
|
181 |
+
|
182 |
+
// Create sprites folder if it doesn't exist
|
183 |
+
if (!fs.existsSync(spritesPath)) {
|
184 |
+
fs.mkdirSync(spritesPath);
|
185 |
+
}
|
186 |
+
|
187 |
+
// Path to sprites is not a directory. This should never happen.
|
188 |
+
if (!fs.statSync(spritesPath).isDirectory()) {
|
189 |
+
return response.sendStatus(404);
|
190 |
+
}
|
191 |
+
|
192 |
+
const spritePackPath = path.join(file.destination, file.filename);
|
193 |
+
const sprites = await getImageBuffers(spritePackPath);
|
194 |
+
const files = fs.readdirSync(spritesPath);
|
195 |
+
|
196 |
+
for (const [filename, buffer] of sprites) {
|
197 |
+
// Remove existing sprite with the same label
|
198 |
+
const existingFile = files.find(file => path.parse(file).name === path.parse(filename).name);
|
199 |
+
|
200 |
+
if (existingFile) {
|
201 |
+
fs.rmSync(path.join(spritesPath, existingFile));
|
202 |
+
}
|
203 |
+
|
204 |
+
// Write sprite buffer to disk
|
205 |
+
const pathToSprite = path.join(spritesPath, filename);
|
206 |
+
writeFileAtomicSync(pathToSprite, buffer);
|
207 |
+
}
|
208 |
+
|
209 |
+
// Remove uploaded ZIP file
|
210 |
+
fs.rmSync(spritePackPath);
|
211 |
+
return response.send({ count: sprites.length });
|
212 |
+
} catch (error) {
|
213 |
+
console.error(error);
|
214 |
+
return response.sendStatus(500);
|
215 |
+
}
|
216 |
+
});
|
217 |
+
|
218 |
+
router.post('/upload', urlencodedParser, async (request, response) => {
|
219 |
+
const file = request.file;
|
220 |
+
const label = request.body.label;
|
221 |
+
const name = request.body.name;
|
222 |
+
|
223 |
+
if (!file || !label || !name) {
|
224 |
+
return response.sendStatus(400);
|
225 |
+
}
|
226 |
+
|
227 |
+
try {
|
228 |
+
const spritesPath = path.join(request.user.directories.characters, name);
|
229 |
+
|
230 |
+
// Create sprites folder if it doesn't exist
|
231 |
+
if (!fs.existsSync(spritesPath)) {
|
232 |
+
fs.mkdirSync(spritesPath);
|
233 |
+
}
|
234 |
+
|
235 |
+
// Path to sprites is not a directory. This should never happen.
|
236 |
+
if (!fs.statSync(spritesPath).isDirectory()) {
|
237 |
+
return response.sendStatus(404);
|
238 |
+
}
|
239 |
+
|
240 |
+
const files = fs.readdirSync(spritesPath);
|
241 |
+
|
242 |
+
// Remove existing sprite with the same label
|
243 |
+
for (const file of files) {
|
244 |
+
if (path.parse(file).name === label) {
|
245 |
+
fs.rmSync(path.join(spritesPath, file));
|
246 |
+
}
|
247 |
+
}
|
248 |
+
|
249 |
+
const filename = label + path.parse(file.originalname).ext;
|
250 |
+
const spritePath = path.join(file.destination, file.filename);
|
251 |
+
const pathToFile = path.join(spritesPath, filename);
|
252 |
+
// Copy uploaded file to sprites folder
|
253 |
+
fs.cpSync(spritePath, pathToFile);
|
254 |
+
// Remove uploaded file
|
255 |
+
fs.rmSync(spritePath);
|
256 |
+
return response.sendStatus(200);
|
257 |
+
} catch (error) {
|
258 |
+
console.error(error);
|
259 |
+
return response.sendStatus(500);
|
260 |
+
}
|
261 |
+
});
|
262 |
+
|
263 |
+
module.exports = {
|
264 |
+
router,
|
265 |
+
importRisuSprites,
|
266 |
+
};
|
src/endpoints/stable-diffusion.js
ADDED
@@ -0,0 +1,1042 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const express = require('express');
|
2 |
+
const fetch = require('node-fetch').default;
|
3 |
+
const sanitize = require('sanitize-filename');
|
4 |
+
const { getBasicAuthHeader, delay, getHexString } = require('../util.js');
|
5 |
+
const fs = require('fs');
|
6 |
+
const path = require('path');
|
7 |
+
const writeFileAtomicSync = require('write-file-atomic').sync;
|
8 |
+
const { jsonParser } = require('../express-common');
|
9 |
+
const { readSecret, SECRET_KEYS } = require('./secrets.js');
|
10 |
+
const FormData = require('form-data');
|
11 |
+
|
12 |
+
/**
|
13 |
+
* Sanitizes a string.
|
14 |
+
* @param {string} x String to sanitize
|
15 |
+
* @returns {string} Sanitized string
|
16 |
+
*/
|
17 |
+
function safeStr(x) {
|
18 |
+
x = String(x);
|
19 |
+
x = x.replace(/ +/g, ' ');
|
20 |
+
x = x.trim();
|
21 |
+
x = x.replace(/^[\s,.]+|[\s,.]+$/g, '');
|
22 |
+
return x;
|
23 |
+
}
|
24 |
+
|
25 |
+
const splitStrings = [
|
26 |
+
', extremely',
|
27 |
+
', intricate,',
|
28 |
+
];
|
29 |
+
|
30 |
+
const dangerousPatterns = '[]【】()()|::';
|
31 |
+
|
32 |
+
/**
|
33 |
+
* Removes patterns from a string.
|
34 |
+
* @param {string} x String to sanitize
|
35 |
+
* @param {string} pattern Pattern to remove
|
36 |
+
* @returns {string} Sanitized string
|
37 |
+
*/
|
38 |
+
function removePattern(x, pattern) {
|
39 |
+
for (let i = 0; i < pattern.length; i++) {
|
40 |
+
let p = pattern[i];
|
41 |
+
let regex = new RegExp('\\' + p, 'g');
|
42 |
+
x = x.replace(regex, '');
|
43 |
+
}
|
44 |
+
return x;
|
45 |
+
}
|
46 |
+
|
47 |
+
/**
|
48 |
+
* Gets the comfy workflows.
|
49 |
+
* @param {import('../users.js').UserDirectoryList} directories
|
50 |
+
* @returns {string[]} List of comfy workflows
|
51 |
+
*/
|
52 |
+
function getComfyWorkflows(directories) {
|
53 |
+
return fs
|
54 |
+
.readdirSync(directories.comfyWorkflows)
|
55 |
+
.filter(file => file[0] != '.' && file.toLowerCase().endsWith('.json'))
|
56 |
+
.sort(Intl.Collator().compare);
|
57 |
+
}
|
58 |
+
|
59 |
+
const router = express.Router();
|
60 |
+
|
61 |
+
router.post('/ping', jsonParser, async (request, response) => {
|
62 |
+
try {
|
63 |
+
const url = new URL(request.body.url);
|
64 |
+
url.pathname = '/sdapi/v1/options';
|
65 |
+
|
66 |
+
const result = await fetch(url, {
|
67 |
+
method: 'GET',
|
68 |
+
headers: {
|
69 |
+
'Authorization': getBasicAuthHeader(request.body.auth),
|
70 |
+
},
|
71 |
+
});
|
72 |
+
|
73 |
+
if (!result.ok) {
|
74 |
+
throw new Error('SD WebUI returned an error.');
|
75 |
+
}
|
76 |
+
|
77 |
+
return response.sendStatus(200);
|
78 |
+
} catch (error) {
|
79 |
+
console.log(error);
|
80 |
+
return response.sendStatus(500);
|
81 |
+
}
|
82 |
+
});
|
83 |
+
|
84 |
+
router.post('/upscalers', jsonParser, async (request, response) => {
|
85 |
+
try {
|
86 |
+
async function getUpscalerModels() {
|
87 |
+
const url = new URL(request.body.url);
|
88 |
+
url.pathname = '/sdapi/v1/upscalers';
|
89 |
+
|
90 |
+
const result = await fetch(url, {
|
91 |
+
method: 'GET',
|
92 |
+
headers: {
|
93 |
+
'Authorization': getBasicAuthHeader(request.body.auth),
|
94 |
+
},
|
95 |
+
});
|
96 |
+
|
97 |
+
if (!result.ok) {
|
98 |
+
throw new Error('SD WebUI returned an error.');
|
99 |
+
}
|
100 |
+
|
101 |
+
const data = await result.json();
|
102 |
+
const names = data.map(x => x.name);
|
103 |
+
return names;
|
104 |
+
}
|
105 |
+
|
106 |
+
async function getLatentUpscalers() {
|
107 |
+
const url = new URL(request.body.url);
|
108 |
+
url.pathname = '/sdapi/v1/latent-upscale-modes';
|
109 |
+
|
110 |
+
const result = await fetch(url, {
|
111 |
+
method: 'GET',
|
112 |
+
headers: {
|
113 |
+
'Authorization': getBasicAuthHeader(request.body.auth),
|
114 |
+
},
|
115 |
+
});
|
116 |
+
|
117 |
+
if (!result.ok) {
|
118 |
+
throw new Error('SD WebUI returned an error.');
|
119 |
+
}
|
120 |
+
|
121 |
+
const data = await result.json();
|
122 |
+
const names = data.map(x => x.name);
|
123 |
+
return names;
|
124 |
+
}
|
125 |
+
|
126 |
+
const [upscalers, latentUpscalers] = await Promise.all([getUpscalerModels(), getLatentUpscalers()]);
|
127 |
+
|
128 |
+
// 0 = None, then Latent Upscalers, then Upscalers
|
129 |
+
upscalers.splice(1, 0, ...latentUpscalers);
|
130 |
+
|
131 |
+
return response.send(upscalers);
|
132 |
+
} catch (error) {
|
133 |
+
console.log(error);
|
134 |
+
return response.sendStatus(500);
|
135 |
+
}
|
136 |
+
});
|
137 |
+
|
138 |
+
router.post('/vaes', jsonParser, async (request, response) => {
|
139 |
+
try {
|
140 |
+
const url = new URL(request.body.url);
|
141 |
+
url.pathname = '/sdapi/v1/sd-vae';
|
142 |
+
|
143 |
+
const result = await fetch(url, {
|
144 |
+
method: 'GET',
|
145 |
+
headers: {
|
146 |
+
'Authorization': getBasicAuthHeader(request.body.auth),
|
147 |
+
},
|
148 |
+
});
|
149 |
+
|
150 |
+
if (!result.ok) {
|
151 |
+
throw new Error('SD WebUI returned an error.');
|
152 |
+
}
|
153 |
+
|
154 |
+
const data = await result.json();
|
155 |
+
const names = data.map(x => x.model_name);
|
156 |
+
return response.send(names);
|
157 |
+
} catch (error) {
|
158 |
+
console.log(error);
|
159 |
+
return response.sendStatus(500);
|
160 |
+
}
|
161 |
+
});
|
162 |
+
|
163 |
+
router.post('/samplers', jsonParser, async (request, response) => {
|
164 |
+
try {
|
165 |
+
const url = new URL(request.body.url);
|
166 |
+
url.pathname = '/sdapi/v1/samplers';
|
167 |
+
|
168 |
+
const result = await fetch(url, {
|
169 |
+
method: 'GET',
|
170 |
+
headers: {
|
171 |
+
'Authorization': getBasicAuthHeader(request.body.auth),
|
172 |
+
},
|
173 |
+
});
|
174 |
+
|
175 |
+
if (!result.ok) {
|
176 |
+
throw new Error('SD WebUI returned an error.');
|
177 |
+
}
|
178 |
+
|
179 |
+
const data = await result.json();
|
180 |
+
const names = data.map(x => x.name);
|
181 |
+
return response.send(names);
|
182 |
+
|
183 |
+
} catch (error) {
|
184 |
+
console.log(error);
|
185 |
+
return response.sendStatus(500);
|
186 |
+
}
|
187 |
+
});
|
188 |
+
|
189 |
+
router.post('/schedulers', jsonParser, async (request, response) => {
|
190 |
+
try {
|
191 |
+
const url = new URL(request.body.url);
|
192 |
+
url.pathname = '/sdapi/v1/schedulers';
|
193 |
+
|
194 |
+
const result = await fetch(url, {
|
195 |
+
method: 'GET',
|
196 |
+
headers: {
|
197 |
+
'Authorization': getBasicAuthHeader(request.body.auth),
|
198 |
+
},
|
199 |
+
});
|
200 |
+
|
201 |
+
if (!result.ok) {
|
202 |
+
throw new Error('SD WebUI returned an error.');
|
203 |
+
}
|
204 |
+
|
205 |
+
const data = await result.json();
|
206 |
+
const names = data.map(x => x.name);
|
207 |
+
return response.send(names);
|
208 |
+
} catch (error) {
|
209 |
+
console.log(error);
|
210 |
+
return response.sendStatus(500);
|
211 |
+
}
|
212 |
+
});
|
213 |
+
|
214 |
+
router.post('/models', jsonParser, async (request, response) => {
|
215 |
+
try {
|
216 |
+
const url = new URL(request.body.url);
|
217 |
+
url.pathname = '/sdapi/v1/sd-models';
|
218 |
+
|
219 |
+
const result = await fetch(url, {
|
220 |
+
method: 'GET',
|
221 |
+
headers: {
|
222 |
+
'Authorization': getBasicAuthHeader(request.body.auth),
|
223 |
+
},
|
224 |
+
});
|
225 |
+
|
226 |
+
if (!result.ok) {
|
227 |
+
throw new Error('SD WebUI returned an error.');
|
228 |
+
}
|
229 |
+
|
230 |
+
const data = await result.json();
|
231 |
+
const models = data.map(x => ({ value: x.title, text: x.title }));
|
232 |
+
return response.send(models);
|
233 |
+
} catch (error) {
|
234 |
+
console.log(error);
|
235 |
+
return response.sendStatus(500);
|
236 |
+
}
|
237 |
+
});
|
238 |
+
|
239 |
+
router.post('/get-model', jsonParser, async (request, response) => {
|
240 |
+
try {
|
241 |
+
const url = new URL(request.body.url);
|
242 |
+
url.pathname = '/sdapi/v1/options';
|
243 |
+
|
244 |
+
const result = await fetch(url, {
|
245 |
+
method: 'GET',
|
246 |
+
headers: {
|
247 |
+
'Authorization': getBasicAuthHeader(request.body.auth),
|
248 |
+
},
|
249 |
+
});
|
250 |
+
const data = await result.json();
|
251 |
+
return response.send(data['sd_model_checkpoint']);
|
252 |
+
} catch (error) {
|
253 |
+
console.log(error);
|
254 |
+
return response.sendStatus(500);
|
255 |
+
}
|
256 |
+
});
|
257 |
+
|
258 |
+
router.post('/set-model', jsonParser, async (request, response) => {
|
259 |
+
try {
|
260 |
+
async function getProgress() {
|
261 |
+
const url = new URL(request.body.url);
|
262 |
+
url.pathname = '/sdapi/v1/progress';
|
263 |
+
|
264 |
+
const result = await fetch(url, {
|
265 |
+
method: 'GET',
|
266 |
+
headers: {
|
267 |
+
'Authorization': getBasicAuthHeader(request.body.auth),
|
268 |
+
},
|
269 |
+
timeout: 0,
|
270 |
+
});
|
271 |
+
const data = await result.json();
|
272 |
+
return data;
|
273 |
+
}
|
274 |
+
|
275 |
+
const url = new URL(request.body.url);
|
276 |
+
url.pathname = '/sdapi/v1/options';
|
277 |
+
|
278 |
+
const options = {
|
279 |
+
sd_model_checkpoint: request.body.model,
|
280 |
+
};
|
281 |
+
|
282 |
+
const result = await fetch(url, {
|
283 |
+
method: 'POST',
|
284 |
+
body: JSON.stringify(options),
|
285 |
+
headers: {
|
286 |
+
'Content-Type': 'application/json',
|
287 |
+
'Authorization': getBasicAuthHeader(request.body.auth),
|
288 |
+
},
|
289 |
+
timeout: 0,
|
290 |
+
});
|
291 |
+
|
292 |
+
if (!result.ok) {
|
293 |
+
throw new Error('SD WebUI returned an error.');
|
294 |
+
}
|
295 |
+
|
296 |
+
const MAX_ATTEMPTS = 10;
|
297 |
+
const CHECK_INTERVAL = 2000;
|
298 |
+
|
299 |
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
300 |
+
const progressState = await getProgress();
|
301 |
+
|
302 |
+
const progress = progressState['progress'];
|
303 |
+
const jobCount = progressState['state']['job_count'];
|
304 |
+
if (progress == 0.0 && jobCount === 0) {
|
305 |
+
break;
|
306 |
+
}
|
307 |
+
|
308 |
+
console.log(`Waiting for SD WebUI to finish model loading... Progress: ${progress}; Job count: ${jobCount}`);
|
309 |
+
await delay(CHECK_INTERVAL);
|
310 |
+
}
|
311 |
+
|
312 |
+
return response.sendStatus(200);
|
313 |
+
} catch (error) {
|
314 |
+
console.log(error);
|
315 |
+
return response.sendStatus(500);
|
316 |
+
}
|
317 |
+
});
|
318 |
+
|
319 |
+
router.post('/generate', jsonParser, async (request, response) => {
|
320 |
+
try {
|
321 |
+
console.log('SD WebUI request:', request.body);
|
322 |
+
|
323 |
+
const url = new URL(request.body.url);
|
324 |
+
url.pathname = '/sdapi/v1/txt2img';
|
325 |
+
|
326 |
+
const controller = new AbortController();
|
327 |
+
request.socket.removeAllListeners('close');
|
328 |
+
request.socket.on('close', function () {
|
329 |
+
if (!response.writableEnded) {
|
330 |
+
const url = new URL(request.body.url);
|
331 |
+
url.pathname = '/sdapi/v1/interrupt';
|
332 |
+
fetch(url, { method: 'POST', headers: { 'Authorization': getBasicAuthHeader(request.body.auth) } });
|
333 |
+
}
|
334 |
+
controller.abort();
|
335 |
+
});
|
336 |
+
|
337 |
+
const result = await fetch(url, {
|
338 |
+
method: 'POST',
|
339 |
+
body: JSON.stringify(request.body),
|
340 |
+
headers: {
|
341 |
+
'Content-Type': 'application/json',
|
342 |
+
'Authorization': getBasicAuthHeader(request.body.auth),
|
343 |
+
},
|
344 |
+
timeout: 0,
|
345 |
+
// @ts-ignore
|
346 |
+
signal: controller.signal,
|
347 |
+
});
|
348 |
+
|
349 |
+
if (!result.ok) {
|
350 |
+
const text = await result.text();
|
351 |
+
throw new Error('SD WebUI returned an error.', { cause: text });
|
352 |
+
}
|
353 |
+
|
354 |
+
const data = await result.json();
|
355 |
+
return response.send(data);
|
356 |
+
} catch (error) {
|
357 |
+
console.log(error);
|
358 |
+
return response.sendStatus(500);
|
359 |
+
}
|
360 |
+
});
|
361 |
+
|
362 |
+
router.post('/sd-next/upscalers', jsonParser, async (request, response) => {
|
363 |
+
try {
|
364 |
+
const url = new URL(request.body.url);
|
365 |
+
url.pathname = '/sdapi/v1/upscalers';
|
366 |
+
|
367 |
+
const result = await fetch(url, {
|
368 |
+
method: 'GET',
|
369 |
+
headers: {
|
370 |
+
'Authorization': getBasicAuthHeader(request.body.auth),
|
371 |
+
},
|
372 |
+
});
|
373 |
+
|
374 |
+
if (!result.ok) {
|
375 |
+
throw new Error('SD WebUI returned an error.');
|
376 |
+
}
|
377 |
+
|
378 |
+
// Vlad doesn't provide Latent Upscalers in the API, so we have to hardcode them here
|
379 |
+
const latentUpscalers = ['Latent', 'Latent (antialiased)', 'Latent (bicubic)', 'Latent (bicubic antialiased)', 'Latent (nearest)', 'Latent (nearest-exact)'];
|
380 |
+
|
381 |
+
const data = await result.json();
|
382 |
+
const names = data.map(x => x.name);
|
383 |
+
|
384 |
+
// 0 = None, then Latent Upscalers, then Upscalers
|
385 |
+
names.splice(1, 0, ...latentUpscalers);
|
386 |
+
|
387 |
+
return response.send(names);
|
388 |
+
} catch (error) {
|
389 |
+
console.log(error);
|
390 |
+
return response.sendStatus(500);
|
391 |
+
}
|
392 |
+
});
|
393 |
+
|
394 |
+
/**
|
395 |
+
* SD prompt expansion using GPT-2 text generation model.
|
396 |
+
* Adapted from: https://github.com/lllyasviel/Fooocus/blob/main/modules/expansion.py
|
397 |
+
*/
|
398 |
+
router.post('/expand', jsonParser, async (request, response) => {
|
399 |
+
const originalPrompt = request.body.prompt;
|
400 |
+
|
401 |
+
if (!originalPrompt) {
|
402 |
+
console.warn('No prompt provided for SD expansion.');
|
403 |
+
return response.send({ prompt: '' });
|
404 |
+
}
|
405 |
+
|
406 |
+
console.log('Refine prompt input:', originalPrompt);
|
407 |
+
const splitString = splitStrings[Math.floor(Math.random() * splitStrings.length)];
|
408 |
+
let prompt = safeStr(originalPrompt) + splitString;
|
409 |
+
|
410 |
+
try {
|
411 |
+
const task = 'text-generation';
|
412 |
+
const module = await import('../transformers.mjs');
|
413 |
+
const pipe = await module.default.getPipeline(task);
|
414 |
+
|
415 |
+
const result = await pipe(prompt, { num_beams: 1, max_new_tokens: 256, do_sample: true });
|
416 |
+
|
417 |
+
const newText = result[0].generated_text;
|
418 |
+
const newPrompt = safeStr(removePattern(newText, dangerousPatterns));
|
419 |
+
console.log('Refine prompt output:', newPrompt);
|
420 |
+
|
421 |
+
return response.send({ prompt: newPrompt });
|
422 |
+
} catch {
|
423 |
+
console.warn('Failed to load transformers.js pipeline.');
|
424 |
+
return response.send({ prompt: originalPrompt });
|
425 |
+
}
|
426 |
+
});
|
427 |
+
|
428 |
+
const comfy = express.Router();
|
429 |
+
|
430 |
+
comfy.post('/ping', jsonParser, async (request, response) => {
|
431 |
+
try {
|
432 |
+
const url = new URL(request.body.url);
|
433 |
+
url.pathname = '/system_stats';
|
434 |
+
|
435 |
+
const result = await fetch(url);
|
436 |
+
if (!result.ok) {
|
437 |
+
throw new Error('ComfyUI returned an error.');
|
438 |
+
}
|
439 |
+
|
440 |
+
return response.sendStatus(200);
|
441 |
+
} catch (error) {
|
442 |
+
console.log(error);
|
443 |
+
return response.sendStatus(500);
|
444 |
+
}
|
445 |
+
});
|
446 |
+
|
447 |
+
comfy.post('/samplers', jsonParser, async (request, response) => {
|
448 |
+
try {
|
449 |
+
const url = new URL(request.body.url);
|
450 |
+
url.pathname = '/object_info';
|
451 |
+
|
452 |
+
const result = await fetch(url);
|
453 |
+
if (!result.ok) {
|
454 |
+
throw new Error('ComfyUI returned an error.');
|
455 |
+
}
|
456 |
+
|
457 |
+
const data = await result.json();
|
458 |
+
return response.send(data.KSampler.input.required.sampler_name[0]);
|
459 |
+
} catch (error) {
|
460 |
+
console.log(error);
|
461 |
+
return response.sendStatus(500);
|
462 |
+
}
|
463 |
+
});
|
464 |
+
|
465 |
+
comfy.post('/models', jsonParser, async (request, response) => {
|
466 |
+
try {
|
467 |
+
const url = new URL(request.body.url);
|
468 |
+
url.pathname = '/object_info';
|
469 |
+
|
470 |
+
const result = await fetch(url);
|
471 |
+
if (!result.ok) {
|
472 |
+
throw new Error('ComfyUI returned an error.');
|
473 |
+
}
|
474 |
+
const data = await result.json();
|
475 |
+
return response.send(data.CheckpointLoaderSimple.input.required.ckpt_name[0].map(it => ({ value: it, text: it })));
|
476 |
+
} catch (error) {
|
477 |
+
console.log(error);
|
478 |
+
return response.sendStatus(500);
|
479 |
+
}
|
480 |
+
});
|
481 |
+
|
482 |
+
comfy.post('/schedulers', jsonParser, async (request, response) => {
|
483 |
+
try {
|
484 |
+
const url = new URL(request.body.url);
|
485 |
+
url.pathname = '/object_info';
|
486 |
+
|
487 |
+
const result = await fetch(url);
|
488 |
+
if (!result.ok) {
|
489 |
+
throw new Error('ComfyUI returned an error.');
|
490 |
+
}
|
491 |
+
|
492 |
+
const data = await result.json();
|
493 |
+
return response.send(data.KSampler.input.required.scheduler[0]);
|
494 |
+
} catch (error) {
|
495 |
+
console.log(error);
|
496 |
+
return response.sendStatus(500);
|
497 |
+
}
|
498 |
+
});
|
499 |
+
|
500 |
+
comfy.post('/vaes', jsonParser, async (request, response) => {
|
501 |
+
try {
|
502 |
+
const url = new URL(request.body.url);
|
503 |
+
url.pathname = '/object_info';
|
504 |
+
|
505 |
+
const result = await fetch(url);
|
506 |
+
if (!result.ok) {
|
507 |
+
throw new Error('ComfyUI returned an error.');
|
508 |
+
}
|
509 |
+
|
510 |
+
const data = await result.json();
|
511 |
+
return response.send(data.VAELoader.input.required.vae_name[0]);
|
512 |
+
} catch (error) {
|
513 |
+
console.log(error);
|
514 |
+
return response.sendStatus(500);
|
515 |
+
}
|
516 |
+
});
|
517 |
+
|
518 |
+
comfy.post('/workflows', jsonParser, async (request, response) => {
|
519 |
+
try {
|
520 |
+
const data = getComfyWorkflows(request.user.directories);
|
521 |
+
return response.send(data);
|
522 |
+
} catch (error) {
|
523 |
+
console.log(error);
|
524 |
+
return response.sendStatus(500);
|
525 |
+
}
|
526 |
+
});
|
527 |
+
|
528 |
+
comfy.post('/workflow', jsonParser, async (request, response) => {
|
529 |
+
try {
|
530 |
+
let filePath = path.join(request.user.directories.comfyWorkflows, sanitize(String(request.body.file_name)));
|
531 |
+
if (!fs.existsSync(filePath)) {
|
532 |
+
filePath = path.join(request.user.directories.comfyWorkflows, 'Default_Comfy_Workflow.json');
|
533 |
+
}
|
534 |
+
const data = fs.readFileSync(filePath, { encoding: 'utf-8' });
|
535 |
+
return response.send(JSON.stringify(data));
|
536 |
+
} catch (error) {
|
537 |
+
console.log(error);
|
538 |
+
return response.sendStatus(500);
|
539 |
+
}
|
540 |
+
});
|
541 |
+
|
542 |
+
comfy.post('/save-workflow', jsonParser, async (request, response) => {
|
543 |
+
try {
|
544 |
+
const filePath = path.join(request.user.directories.comfyWorkflows, sanitize(String(request.body.file_name)));
|
545 |
+
writeFileAtomicSync(filePath, request.body.workflow, 'utf8');
|
546 |
+
const data = getComfyWorkflows(request.user.directories);
|
547 |
+
return response.send(data);
|
548 |
+
} catch (error) {
|
549 |
+
console.log(error);
|
550 |
+
return response.sendStatus(500);
|
551 |
+
}
|
552 |
+
});
|
553 |
+
|
554 |
+
comfy.post('/delete-workflow', jsonParser, async (request, response) => {
|
555 |
+
try {
|
556 |
+
const filePath = path.join(request.user.directories.comfyWorkflows, sanitize(String(request.body.file_name)));
|
557 |
+
if (fs.existsSync(filePath)) {
|
558 |
+
fs.unlinkSync(filePath);
|
559 |
+
}
|
560 |
+
return response.sendStatus(200);
|
561 |
+
} catch (error) {
|
562 |
+
console.log(error);
|
563 |
+
return response.sendStatus(500);
|
564 |
+
}
|
565 |
+
});
|
566 |
+
|
567 |
+
comfy.post('/generate', jsonParser, async (request, response) => {
|
568 |
+
try {
|
569 |
+
const url = new URL(request.body.url);
|
570 |
+
url.pathname = '/prompt';
|
571 |
+
|
572 |
+
const controller = new AbortController();
|
573 |
+
request.socket.removeAllListeners('close');
|
574 |
+
request.socket.on('close', function () {
|
575 |
+
if (!response.writableEnded && !item) {
|
576 |
+
const interruptUrl = new URL(request.body.url);
|
577 |
+
interruptUrl.pathname = '/interrupt';
|
578 |
+
fetch(interruptUrl, { method: 'POST', headers: { 'Authorization': getBasicAuthHeader(request.body.auth) } });
|
579 |
+
}
|
580 |
+
controller.abort();
|
581 |
+
});
|
582 |
+
|
583 |
+
const promptResult = await fetch(url, {
|
584 |
+
method: 'POST',
|
585 |
+
body: request.body.prompt,
|
586 |
+
});
|
587 |
+
if (!promptResult.ok) {
|
588 |
+
throw new Error('ComfyUI returned an error.');
|
589 |
+
}
|
590 |
+
|
591 |
+
const data = await promptResult.json();
|
592 |
+
const id = data.prompt_id;
|
593 |
+
let item;
|
594 |
+
const historyUrl = new URL(request.body.url);
|
595 |
+
historyUrl.pathname = '/history';
|
596 |
+
while (true) {
|
597 |
+
const result = await fetch(historyUrl);
|
598 |
+
if (!result.ok) {
|
599 |
+
throw new Error('ComfyUI returned an error.');
|
600 |
+
}
|
601 |
+
const history = await result.json();
|
602 |
+
item = history[id];
|
603 |
+
if (item) {
|
604 |
+
break;
|
605 |
+
}
|
606 |
+
await delay(100);
|
607 |
+
}
|
608 |
+
if (item.status.status_str === 'error') {
|
609 |
+
throw new Error('ComfyUI generation did not succeed.');
|
610 |
+
}
|
611 |
+
const imgInfo = Object.keys(item.outputs).map(it => item.outputs[it].images).flat()[0];
|
612 |
+
const imgUrl = new URL(request.body.url);
|
613 |
+
imgUrl.pathname = '/view';
|
614 |
+
imgUrl.search = `?filename=${imgInfo.filename}&subfolder=${imgInfo.subfolder}&type=${imgInfo.type}`;
|
615 |
+
const imgResponse = await fetch(imgUrl);
|
616 |
+
if (!imgResponse.ok) {
|
617 |
+
throw new Error('ComfyUI returned an error.');
|
618 |
+
}
|
619 |
+
const imgBuffer = await imgResponse.buffer();
|
620 |
+
return response.send(imgBuffer.toString('base64'));
|
621 |
+
} catch (error) {
|
622 |
+
console.log(error);
|
623 |
+
return response.sendStatus(500);
|
624 |
+
}
|
625 |
+
});
|
626 |
+
|
627 |
+
const together = express.Router();
|
628 |
+
|
629 |
+
together.post('/models', jsonParser, async (request, response) => {
|
630 |
+
try {
|
631 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.TOGETHERAI);
|
632 |
+
|
633 |
+
if (!key) {
|
634 |
+
console.log('TogetherAI key not found.');
|
635 |
+
return response.sendStatus(400);
|
636 |
+
}
|
637 |
+
|
638 |
+
const modelsResponse = await fetch('https://api.together.xyz/api/models', {
|
639 |
+
method: 'GET',
|
640 |
+
headers: {
|
641 |
+
'Authorization': `Bearer ${key}`,
|
642 |
+
},
|
643 |
+
});
|
644 |
+
|
645 |
+
if (!modelsResponse.ok) {
|
646 |
+
console.log('TogetherAI returned an error.');
|
647 |
+
return response.sendStatus(500);
|
648 |
+
}
|
649 |
+
|
650 |
+
const data = await modelsResponse.json();
|
651 |
+
|
652 |
+
if (!Array.isArray(data)) {
|
653 |
+
console.log('TogetherAI returned invalid data.');
|
654 |
+
return response.sendStatus(500);
|
655 |
+
}
|
656 |
+
|
657 |
+
const models = data
|
658 |
+
.filter(x => x.display_type === 'image')
|
659 |
+
.map(x => ({ value: x.name, text: x.display_name }));
|
660 |
+
|
661 |
+
return response.send(models);
|
662 |
+
} catch (error) {
|
663 |
+
console.log(error);
|
664 |
+
return response.sendStatus(500);
|
665 |
+
}
|
666 |
+
});
|
667 |
+
|
668 |
+
together.post('/generate', jsonParser, async (request, response) => {
|
669 |
+
try {
|
670 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.TOGETHERAI);
|
671 |
+
|
672 |
+
if (!key) {
|
673 |
+
console.log('TogetherAI key not found.');
|
674 |
+
return response.sendStatus(400);
|
675 |
+
}
|
676 |
+
|
677 |
+
console.log('TogetherAI request:', request.body);
|
678 |
+
|
679 |
+
const result = await fetch('https://api.together.xyz/api/inference', {
|
680 |
+
method: 'POST',
|
681 |
+
body: JSON.stringify({
|
682 |
+
request_type: 'image-model-inference',
|
683 |
+
prompt: request.body.prompt,
|
684 |
+
negative_prompt: request.body.negative_prompt,
|
685 |
+
height: request.body.height,
|
686 |
+
width: request.body.width,
|
687 |
+
model: request.body.model,
|
688 |
+
steps: request.body.steps,
|
689 |
+
n: 1,
|
690 |
+
// Limited to 10000 on playground, works fine with more.
|
691 |
+
seed: request.body.seed >= 0 ? request.body.seed : Math.floor(Math.random() * 10_000_000),
|
692 |
+
// Don't know if that's supposed to be random or not. It works either way.
|
693 |
+
sessionKey: getHexString(40),
|
694 |
+
}),
|
695 |
+
headers: {
|
696 |
+
'Content-Type': 'application/json',
|
697 |
+
'Authorization': `Bearer ${key}`,
|
698 |
+
},
|
699 |
+
});
|
700 |
+
|
701 |
+
if (!result.ok) {
|
702 |
+
console.log('TogetherAI returned an error.');
|
703 |
+
return response.sendStatus(500);
|
704 |
+
}
|
705 |
+
|
706 |
+
const data = await result.json();
|
707 |
+
console.log('TogetherAI response:', data);
|
708 |
+
|
709 |
+
if (data.status !== 'finished') {
|
710 |
+
console.log('TogetherAI job failed.');
|
711 |
+
return response.sendStatus(500);
|
712 |
+
}
|
713 |
+
|
714 |
+
return response.send(data);
|
715 |
+
} catch (error) {
|
716 |
+
console.log(error);
|
717 |
+
return response.sendStatus(500);
|
718 |
+
}
|
719 |
+
});
|
720 |
+
|
721 |
+
const drawthings = express.Router();
|
722 |
+
|
723 |
+
drawthings.post('/ping', jsonParser, async (request, response) => {
|
724 |
+
try {
|
725 |
+
const url = new URL(request.body.url);
|
726 |
+
url.pathname = '/';
|
727 |
+
|
728 |
+
const result = await fetch(url, {
|
729 |
+
method: 'HEAD',
|
730 |
+
});
|
731 |
+
|
732 |
+
if (!result.ok) {
|
733 |
+
throw new Error('SD DrawThings API returned an error.');
|
734 |
+
}
|
735 |
+
|
736 |
+
return response.sendStatus(200);
|
737 |
+
} catch (error) {
|
738 |
+
console.log(error);
|
739 |
+
return response.sendStatus(500);
|
740 |
+
}
|
741 |
+
});
|
742 |
+
|
743 |
+
drawthings.post('/get-model', jsonParser, async (request, response) => {
|
744 |
+
try {
|
745 |
+
const url = new URL(request.body.url);
|
746 |
+
url.pathname = '/';
|
747 |
+
|
748 |
+
const result = await fetch(url, {
|
749 |
+
method: 'GET',
|
750 |
+
});
|
751 |
+
const data = await result.json();
|
752 |
+
|
753 |
+
return response.send(data['model']);
|
754 |
+
} catch (error) {
|
755 |
+
console.log(error);
|
756 |
+
return response.sendStatus(500);
|
757 |
+
}
|
758 |
+
});
|
759 |
+
|
760 |
+
drawthings.post('/get-upscaler', jsonParser, async (request, response) => {
|
761 |
+
try {
|
762 |
+
const url = new URL(request.body.url);
|
763 |
+
url.pathname = '/';
|
764 |
+
|
765 |
+
const result = await fetch(url, {
|
766 |
+
method: 'GET',
|
767 |
+
});
|
768 |
+
const data = await result.json();
|
769 |
+
|
770 |
+
return response.send(data['upscaler']);
|
771 |
+
} catch (error) {
|
772 |
+
console.log(error);
|
773 |
+
return response.sendStatus(500);
|
774 |
+
}
|
775 |
+
});
|
776 |
+
|
777 |
+
drawthings.post('/generate', jsonParser, async (request, response) => {
|
778 |
+
try {
|
779 |
+
console.log('SD DrawThings API request:', request.body);
|
780 |
+
|
781 |
+
const url = new URL(request.body.url);
|
782 |
+
url.pathname = '/sdapi/v1/txt2img';
|
783 |
+
|
784 |
+
const body = { ...request.body };
|
785 |
+
const auth = getBasicAuthHeader(request.body.auth);
|
786 |
+
delete body.url;
|
787 |
+
delete body.auth;
|
788 |
+
|
789 |
+
const result = await fetch(url, {
|
790 |
+
method: 'POST',
|
791 |
+
body: JSON.stringify(body),
|
792 |
+
headers: {
|
793 |
+
'Content-Type': 'application/json',
|
794 |
+
'Authorization': auth,
|
795 |
+
},
|
796 |
+
timeout: 0,
|
797 |
+
});
|
798 |
+
|
799 |
+
if (!result.ok) {
|
800 |
+
const text = await result.text();
|
801 |
+
throw new Error('SD DrawThings API returned an error.', { cause: text });
|
802 |
+
}
|
803 |
+
|
804 |
+
const data = await result.json();
|
805 |
+
return response.send(data);
|
806 |
+
} catch (error) {
|
807 |
+
console.log(error);
|
808 |
+
return response.sendStatus(500);
|
809 |
+
}
|
810 |
+
});
|
811 |
+
|
812 |
+
const pollinations = express.Router();
|
813 |
+
|
814 |
+
pollinations.post('/generate', jsonParser, async (request, response) => {
|
815 |
+
try {
|
816 |
+
const promptUrl = new URL(`https://image.pollinations.ai/prompt/${encodeURIComponent(request.body.prompt)}`);
|
817 |
+
const params = new URLSearchParams({
|
818 |
+
model: String(request.body.model),
|
819 |
+
negative_prompt: String(request.body.negative_prompt),
|
820 |
+
seed: String(request.body.seed >= 0 ? request.body.seed : Math.floor(Math.random() * 10_000_000)),
|
821 |
+
enhance: String(request.body.enhance ?? false),
|
822 |
+
refine: String(request.body.refine ?? false),
|
823 |
+
width: String(request.body.width ?? 1024),
|
824 |
+
height: String(request.body.height ?? 1024),
|
825 |
+
nologo: String(true),
|
826 |
+
nofeed: String(true),
|
827 |
+
referer: 'sillytavern',
|
828 |
+
});
|
829 |
+
promptUrl.search = params.toString();
|
830 |
+
|
831 |
+
console.log('Pollinations request URL:', promptUrl.toString());
|
832 |
+
|
833 |
+
const result = await fetch(promptUrl);
|
834 |
+
|
835 |
+
if (!result.ok) {
|
836 |
+
console.log('Pollinations returned an error.', result.status, result.statusText);
|
837 |
+
throw new Error('Pollinations request failed.');
|
838 |
+
}
|
839 |
+
|
840 |
+
const buffer = await result.buffer();
|
841 |
+
const base64 = buffer.toString('base64');
|
842 |
+
|
843 |
+
return response.send({ image: base64 });
|
844 |
+
} catch (error) {
|
845 |
+
console.log(error);
|
846 |
+
return response.sendStatus(500);
|
847 |
+
}
|
848 |
+
});
|
849 |
+
|
850 |
+
const stability = express.Router();
|
851 |
+
|
852 |
+
stability.post('/generate', jsonParser, async (request, response) => {
|
853 |
+
try {
|
854 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.STABILITY);
|
855 |
+
|
856 |
+
if (!key) {
|
857 |
+
console.log('Stability AI key not found.');
|
858 |
+
return response.sendStatus(400);
|
859 |
+
}
|
860 |
+
|
861 |
+
const { payload, model } = request.body;
|
862 |
+
|
863 |
+
console.log('Stability AI request:', model, payload);
|
864 |
+
|
865 |
+
const formData = new FormData();
|
866 |
+
for (const [key, value] of Object.entries(payload)) {
|
867 |
+
if (value !== undefined) {
|
868 |
+
formData.append(key, String(value));
|
869 |
+
}
|
870 |
+
}
|
871 |
+
|
872 |
+
let apiUrl;
|
873 |
+
switch (model) {
|
874 |
+
case 'stable-image-ultra':
|
875 |
+
apiUrl = 'https://api.stability.ai/v2beta/stable-image/generate/ultra';
|
876 |
+
break;
|
877 |
+
case 'stable-image-core':
|
878 |
+
apiUrl = 'https://api.stability.ai/v2beta/stable-image/generate/core';
|
879 |
+
break;
|
880 |
+
case 'stable-diffusion-3':
|
881 |
+
apiUrl = 'https://api.stability.ai/v2beta/stable-image/generate/sd3';
|
882 |
+
break;
|
883 |
+
default:
|
884 |
+
throw new Error('Invalid Stability AI model selected');
|
885 |
+
}
|
886 |
+
|
887 |
+
const result = await fetch(apiUrl, {
|
888 |
+
method: 'POST',
|
889 |
+
headers: {
|
890 |
+
'Authorization': `Bearer ${key}`,
|
891 |
+
'Accept': 'image/*',
|
892 |
+
},
|
893 |
+
body: formData,
|
894 |
+
timeout: 0,
|
895 |
+
});
|
896 |
+
|
897 |
+
if (!result.ok) {
|
898 |
+
const text = await result.text();
|
899 |
+
console.log('Stability AI returned an error.', result.status, result.statusText, text);
|
900 |
+
return response.sendStatus(500);
|
901 |
+
}
|
902 |
+
|
903 |
+
const buffer = await result.buffer();
|
904 |
+
return response.send(buffer.toString('base64'));
|
905 |
+
} catch (error) {
|
906 |
+
console.log(error);
|
907 |
+
return response.sendStatus(500);
|
908 |
+
}
|
909 |
+
});
|
910 |
+
|
911 |
+
const blockentropy = express.Router();
|
912 |
+
|
913 |
+
blockentropy.post('/models', jsonParser, async (request, response) => {
|
914 |
+
try {
|
915 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.BLOCKENTROPY);
|
916 |
+
|
917 |
+
if (!key) {
|
918 |
+
console.log('Block Entropy key not found.');
|
919 |
+
return response.sendStatus(400);
|
920 |
+
}
|
921 |
+
|
922 |
+
const modelsResponse = await fetch('https://api.blockentropy.ai/sdapi/v1/sd-models', {
|
923 |
+
method: 'GET',
|
924 |
+
headers: {
|
925 |
+
'Authorization': `Bearer ${key}`,
|
926 |
+
},
|
927 |
+
});
|
928 |
+
|
929 |
+
if (!modelsResponse.ok) {
|
930 |
+
console.log('Block Entropy returned an error.');
|
931 |
+
return response.sendStatus(500);
|
932 |
+
}
|
933 |
+
|
934 |
+
const data = await modelsResponse.json();
|
935 |
+
|
936 |
+
if (!Array.isArray(data)) {
|
937 |
+
console.log('Block Entropy returned invalid data.');
|
938 |
+
return response.sendStatus(500);
|
939 |
+
}
|
940 |
+
const models = data.map(x => ({ value: x.name, text: x.name }));
|
941 |
+
return response.send(models);
|
942 |
+
|
943 |
+
} catch (error) {
|
944 |
+
console.log(error);
|
945 |
+
return response.sendStatus(500);
|
946 |
+
}
|
947 |
+
});
|
948 |
+
|
949 |
+
blockentropy.post('/generate', jsonParser, async (request, response) => {
|
950 |
+
try {
|
951 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.BLOCKENTROPY);
|
952 |
+
|
953 |
+
if (!key) {
|
954 |
+
console.log('Block Entropy key not found.');
|
955 |
+
return response.sendStatus(400);
|
956 |
+
}
|
957 |
+
|
958 |
+
console.log('Block Entropy request:', request.body);
|
959 |
+
|
960 |
+
const result = await fetch('https://api.blockentropy.ai/sdapi/v1/txt2img', {
|
961 |
+
method: 'POST',
|
962 |
+
body: JSON.stringify({
|
963 |
+
prompt: request.body.prompt,
|
964 |
+
negative_prompt: request.body.negative_prompt,
|
965 |
+
model: request.body.model,
|
966 |
+
steps: request.body.steps,
|
967 |
+
width: request.body.width,
|
968 |
+
height: request.body.height,
|
969 |
+
// Random seed if negative.
|
970 |
+
seed: request.body.seed >= 0 ? request.body.seed : Math.floor(Math.random() * 10_000_000),
|
971 |
+
}),
|
972 |
+
headers: {
|
973 |
+
'Content-Type': 'application/json',
|
974 |
+
'Authorization': `Bearer ${key}`,
|
975 |
+
},
|
976 |
+
});
|
977 |
+
|
978 |
+
if (!result.ok) {
|
979 |
+
console.log('Block Entropy returned an error.');
|
980 |
+
return response.sendStatus(500);
|
981 |
+
}
|
982 |
+
|
983 |
+
const data = await result.json();
|
984 |
+
console.log('Block Entropy response:', data);
|
985 |
+
|
986 |
+
return response.send(data);
|
987 |
+
} catch (error) {
|
988 |
+
console.log(error);
|
989 |
+
return response.sendStatus(500);
|
990 |
+
}
|
991 |
+
});
|
992 |
+
|
993 |
+
|
994 |
+
const huggingface = express.Router();
|
995 |
+
|
996 |
+
huggingface.post('/generate', jsonParser, async (request, response) => {
|
997 |
+
try {
|
998 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.HUGGINGFACE);
|
999 |
+
|
1000 |
+
if (!key) {
|
1001 |
+
console.log('Hugging Face key not found.');
|
1002 |
+
return response.sendStatus(400);
|
1003 |
+
}
|
1004 |
+
|
1005 |
+
console.log('Hugging Face request:', request.body);
|
1006 |
+
|
1007 |
+
const result = await fetch(`https://api-inference.huggingface.co/models/${request.body.model}`, {
|
1008 |
+
method: 'POST',
|
1009 |
+
body: JSON.stringify({
|
1010 |
+
inputs: request.body.prompt,
|
1011 |
+
}),
|
1012 |
+
headers: {
|
1013 |
+
'Content-Type': 'application/json',
|
1014 |
+
'Authorization': `Bearer ${key}`,
|
1015 |
+
},
|
1016 |
+
});
|
1017 |
+
|
1018 |
+
if (!result.ok) {
|
1019 |
+
console.log('Hugging Face returned an error.');
|
1020 |
+
return response.sendStatus(500);
|
1021 |
+
}
|
1022 |
+
|
1023 |
+
const buffer = await result.buffer();
|
1024 |
+
return response.send({
|
1025 |
+
image: buffer.toString('base64'),
|
1026 |
+
});
|
1027 |
+
} catch (error) {
|
1028 |
+
console.log(error);
|
1029 |
+
return response.sendStatus(500);
|
1030 |
+
}
|
1031 |
+
});
|
1032 |
+
|
1033 |
+
|
1034 |
+
router.use('/comfy', comfy);
|
1035 |
+
router.use('/together', together);
|
1036 |
+
router.use('/drawthings', drawthings);
|
1037 |
+
router.use('/pollinations', pollinations);
|
1038 |
+
router.use('/stability', stability);
|
1039 |
+
router.use('/blockentropy', blockentropy);
|
1040 |
+
router.use('/huggingface', huggingface);
|
1041 |
+
|
1042 |
+
module.exports = { router };
|
src/endpoints/stats.js
ADDED
@@ -0,0 +1,474 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const fs = require('fs');
|
2 |
+
const path = require('path');
|
3 |
+
const express = require('express');
|
4 |
+
const writeFileAtomic = require('write-file-atomic');
|
5 |
+
const crypto = require('crypto');
|
6 |
+
|
7 |
+
const readFile = fs.promises.readFile;
|
8 |
+
const readdir = fs.promises.readdir;
|
9 |
+
|
10 |
+
const { jsonParser } = require('../express-common');
|
11 |
+
const { getAllUserHandles, getUserDirectories } = require('../users');
|
12 |
+
|
13 |
+
const STATS_FILE = 'stats.json';
|
14 |
+
|
15 |
+
/**
|
16 |
+
* @type {Map<string, Object>} The stats object for each user.
|
17 |
+
*/
|
18 |
+
const STATS = new Map();
|
19 |
+
/**
|
20 |
+
* @type {Map<string, number>} The timestamps for each user.
|
21 |
+
*/
|
22 |
+
const TIMESTAMPS = new Map();
|
23 |
+
|
24 |
+
/**
|
25 |
+
* Convert a timestamp to an integer timestamp.
|
26 |
+
* (sorry, it's momentless for now, didn't want to add a package just for this)
|
27 |
+
* This function can handle several different timestamp formats:
|
28 |
+
* 1. Unix timestamps (the number of seconds since the Unix Epoch)
|
29 |
+
* 2. ST "humanized" timestamps, formatted like "YYYY-MM-DD @HHh MMm SSs ms"
|
30 |
+
* 3. Date strings in the format "Month DD, YYYY H:MMam/pm"
|
31 |
+
*
|
32 |
+
* The function returns the timestamp as the number of milliseconds since
|
33 |
+
* the Unix Epoch, which can be converted to a JavaScript Date object with new Date().
|
34 |
+
*
|
35 |
+
* @param {string|number} timestamp - The timestamp to convert.
|
36 |
+
* @returns {number} The timestamp in milliseconds since the Unix Epoch, or 0 if the input cannot be parsed.
|
37 |
+
*
|
38 |
+
* @example
|
39 |
+
* // Unix timestamp
|
40 |
+
* timestampToMoment(1609459200);
|
41 |
+
* // ST humanized timestamp
|
42 |
+
* timestampToMoment("2021-01-01 \@00h 00m 00s 000ms");
|
43 |
+
* // Date string
|
44 |
+
* timestampToMoment("January 1, 2021 12:00am");
|
45 |
+
*/
|
46 |
+
function timestampToMoment(timestamp) {
|
47 |
+
if (!timestamp) {
|
48 |
+
return 0;
|
49 |
+
}
|
50 |
+
|
51 |
+
if (typeof timestamp === 'number') {
|
52 |
+
return timestamp;
|
53 |
+
}
|
54 |
+
|
55 |
+
const pattern1 =
|
56 |
+
/(\d{4})-(\d{1,2})-(\d{1,2}) @(\d{1,2})h (\d{1,2})m (\d{1,2})s (\d{1,3})ms/;
|
57 |
+
const replacement1 = (
|
58 |
+
match,
|
59 |
+
year,
|
60 |
+
month,
|
61 |
+
day,
|
62 |
+
hour,
|
63 |
+
minute,
|
64 |
+
second,
|
65 |
+
millisecond,
|
66 |
+
) => {
|
67 |
+
return `${year}-${month.padStart(2, '0')}-${day.padStart(
|
68 |
+
2,
|
69 |
+
'0',
|
70 |
+
)}T${hour.padStart(2, '0')}:${minute.padStart(
|
71 |
+
2,
|
72 |
+
'0',
|
73 |
+
)}:${second.padStart(2, '0')}.${millisecond.padStart(3, '0')}Z`;
|
74 |
+
};
|
75 |
+
const isoTimestamp1 = timestamp.replace(pattern1, replacement1);
|
76 |
+
if (!isNaN(Number(new Date(isoTimestamp1)))) {
|
77 |
+
return new Date(isoTimestamp1).getTime();
|
78 |
+
}
|
79 |
+
|
80 |
+
const pattern2 = /(\w+)\s(\d{1,2}),\s(\d{4})\s(\d{1,2}):(\d{1,2})(am|pm)/i;
|
81 |
+
const replacement2 = (match, month, day, year, hour, minute, meridiem) => {
|
82 |
+
const monthNames = [
|
83 |
+
'January',
|
84 |
+
'February',
|
85 |
+
'March',
|
86 |
+
'April',
|
87 |
+
'May',
|
88 |
+
'June',
|
89 |
+
'July',
|
90 |
+
'August',
|
91 |
+
'September',
|
92 |
+
'October',
|
93 |
+
'November',
|
94 |
+
'December',
|
95 |
+
];
|
96 |
+
const monthNum = monthNames.indexOf(month) + 1;
|
97 |
+
const hour24 =
|
98 |
+
meridiem.toLowerCase() === 'pm'
|
99 |
+
? (parseInt(hour, 10) % 12) + 12
|
100 |
+
: parseInt(hour, 10) % 12;
|
101 |
+
return `${year}-${monthNum.toString().padStart(2, '0')}-${day.padStart(
|
102 |
+
2,
|
103 |
+
'0',
|
104 |
+
)}T${hour24.toString().padStart(2, '0')}:${minute.padStart(
|
105 |
+
2,
|
106 |
+
'0',
|
107 |
+
)}:00Z`;
|
108 |
+
};
|
109 |
+
const isoTimestamp2 = timestamp.replace(pattern2, replacement2);
|
110 |
+
if (!isNaN(Number(new Date(isoTimestamp2)))) {
|
111 |
+
return new Date(isoTimestamp2).getTime();
|
112 |
+
}
|
113 |
+
|
114 |
+
return 0;
|
115 |
+
}
|
116 |
+
|
117 |
+
/**
|
118 |
+
* Collects and aggregates stats for all characters.
|
119 |
+
*
|
120 |
+
* @param {string} chatsPath - The path to the directory containing the chat files.
|
121 |
+
* @param {string} charactersPath - The path to the directory containing the character files.
|
122 |
+
* @returns {Promise<Object>} The aggregated stats object.
|
123 |
+
*/
|
124 |
+
async function collectAndCreateStats(chatsPath, charactersPath) {
|
125 |
+
const files = await readdir(charactersPath);
|
126 |
+
|
127 |
+
const pngFiles = files.filter((file) => file.endsWith('.png'));
|
128 |
+
|
129 |
+
let processingPromises = pngFiles.map((file) =>
|
130 |
+
calculateStats(chatsPath, file),
|
131 |
+
);
|
132 |
+
const statsArr = await Promise.all(processingPromises);
|
133 |
+
|
134 |
+
let finalStats = {};
|
135 |
+
for (let stat of statsArr) {
|
136 |
+
finalStats = { ...finalStats, ...stat };
|
137 |
+
}
|
138 |
+
// tag with timestamp on when stats were generated
|
139 |
+
finalStats.timestamp = Date.now();
|
140 |
+
return finalStats;
|
141 |
+
}
|
142 |
+
|
143 |
+
/**
|
144 |
+
* Recreates the stats object for a user.
|
145 |
+
* @param {string} handle User handle
|
146 |
+
* @param {string} chatsPath Path to the directory containing the chat files.
|
147 |
+
* @param {string} charactersPath Path to the directory containing the character files.
|
148 |
+
*/
|
149 |
+
async function recreateStats(handle, chatsPath, charactersPath) {
|
150 |
+
console.log('Collecting and creating stats for user:', handle);
|
151 |
+
const stats = await collectAndCreateStats(chatsPath, charactersPath);
|
152 |
+
STATS.set(handle, stats);
|
153 |
+
await saveStatsToFile();
|
154 |
+
}
|
155 |
+
|
156 |
+
/**
|
157 |
+
* Loads the stats file into memory. If the file doesn't exist or is invalid,
|
158 |
+
* initializes stats by collecting and creating them for each character.
|
159 |
+
*/
|
160 |
+
async function init() {
|
161 |
+
try {
|
162 |
+
const userHandles = await getAllUserHandles();
|
163 |
+
for (const handle of userHandles) {
|
164 |
+
const directories = getUserDirectories(handle);
|
165 |
+
try {
|
166 |
+
const statsFilePath = path.join(directories.root, STATS_FILE);
|
167 |
+
const statsFileContent = await readFile(statsFilePath, 'utf-8');
|
168 |
+
STATS.set(handle, JSON.parse(statsFileContent));
|
169 |
+
} catch (err) {
|
170 |
+
// If the file doesn't exist or is invalid, initialize stats
|
171 |
+
if (err.code === 'ENOENT' || err instanceof SyntaxError) {
|
172 |
+
await recreateStats(handle, directories.chats, directories.characters);
|
173 |
+
} else {
|
174 |
+
throw err; // Rethrow the error if it's something we didn't expect
|
175 |
+
}
|
176 |
+
}
|
177 |
+
}
|
178 |
+
} catch (err) {
|
179 |
+
console.error('Failed to initialize stats:', err);
|
180 |
+
}
|
181 |
+
// Save stats every 5 minutes
|
182 |
+
setInterval(saveStatsToFile, 5 * 60 * 1000);
|
183 |
+
}
|
184 |
+
/**
|
185 |
+
* Saves the current state of charStats to a file, only if the data has changed since the last save.
|
186 |
+
*/
|
187 |
+
async function saveStatsToFile() {
|
188 |
+
const userHandles = await getAllUserHandles();
|
189 |
+
for (const handle of userHandles) {
|
190 |
+
if (!STATS.has(handle)) {
|
191 |
+
continue;
|
192 |
+
}
|
193 |
+
const charStats = STATS.get(handle);
|
194 |
+
const lastSaveTimestamp = TIMESTAMPS.get(handle) || 0;
|
195 |
+
if (charStats.timestamp > lastSaveTimestamp) {
|
196 |
+
try {
|
197 |
+
const directories = getUserDirectories(handle);
|
198 |
+
const statsFilePath = path.join(directories.root, STATS_FILE);
|
199 |
+
await writeFileAtomic(statsFilePath, JSON.stringify(charStats));
|
200 |
+
TIMESTAMPS.set(handle, Date.now());
|
201 |
+
} catch (error) {
|
202 |
+
console.log('Failed to save stats to file.', error);
|
203 |
+
}
|
204 |
+
}
|
205 |
+
}
|
206 |
+
}
|
207 |
+
|
208 |
+
/**
|
209 |
+
* Attempts to save charStats to a file and then terminates the process.
|
210 |
+
* If an error occurs during the file write, it logs the error before exiting.
|
211 |
+
*/
|
212 |
+
async function onExit() {
|
213 |
+
try {
|
214 |
+
await saveStatsToFile();
|
215 |
+
} catch (err) {
|
216 |
+
console.error('Failed to write stats to file:', err);
|
217 |
+
}
|
218 |
+
}
|
219 |
+
|
220 |
+
/**
|
221 |
+
* Reads the contents of a file and returns the lines in the file as an array.
|
222 |
+
*
|
223 |
+
* @param {string} filepath - The path of the file to be read.
|
224 |
+
* @returns {Array<string>} - The lines in the file.
|
225 |
+
* @throws Will throw an error if the file cannot be read.
|
226 |
+
*/
|
227 |
+
function readAndParseFile(filepath) {
|
228 |
+
try {
|
229 |
+
let file = fs.readFileSync(filepath, 'utf8');
|
230 |
+
let lines = file.split('\n');
|
231 |
+
return lines;
|
232 |
+
} catch (error) {
|
233 |
+
console.error(`Error reading file at ${filepath}: ${error}`);
|
234 |
+
return [];
|
235 |
+
}
|
236 |
+
}
|
237 |
+
|
238 |
+
/**
|
239 |
+
* Calculates the time difference between two dates.
|
240 |
+
*
|
241 |
+
* @param {string} gen_started - The start time in ISO 8601 format.
|
242 |
+
* @param {string} gen_finished - The finish time in ISO 8601 format.
|
243 |
+
* @returns {number} - The difference in time in milliseconds.
|
244 |
+
*/
|
245 |
+
function calculateGenTime(gen_started, gen_finished) {
|
246 |
+
let startDate = new Date(gen_started);
|
247 |
+
let endDate = new Date(gen_finished);
|
248 |
+
return Number(endDate) - Number(startDate);
|
249 |
+
}
|
250 |
+
|
251 |
+
/**
|
252 |
+
* Counts the number of words in a string.
|
253 |
+
*
|
254 |
+
* @param {string} str - The string to count words in.
|
255 |
+
* @returns {number} - The number of words in the string.
|
256 |
+
*/
|
257 |
+
function countWordsInString(str) {
|
258 |
+
const match = str.match(/\b\w+\b/g);
|
259 |
+
return match ? match.length : 0;
|
260 |
+
}
|
261 |
+
|
262 |
+
/**
|
263 |
+
* calculateStats - Calculate statistics for a given character chat directory.
|
264 |
+
*
|
265 |
+
* @param {string} chatsPath The directory containing the chat files.
|
266 |
+
* @param {string} item The name of the character.
|
267 |
+
* @return {object} An object containing the calculated statistics.
|
268 |
+
*/
|
269 |
+
const calculateStats = (chatsPath, item) => {
|
270 |
+
const chatDir = path.join(chatsPath, item.replace('.png', ''));
|
271 |
+
const stats = {
|
272 |
+
total_gen_time: 0,
|
273 |
+
user_word_count: 0,
|
274 |
+
non_user_word_count: 0,
|
275 |
+
user_msg_count: 0,
|
276 |
+
non_user_msg_count: 0,
|
277 |
+
total_swipe_count: 0,
|
278 |
+
chat_size: 0,
|
279 |
+
date_last_chat: 0,
|
280 |
+
date_first_chat: new Date('9999-12-31T23:59:59.999Z').getTime(),
|
281 |
+
};
|
282 |
+
let uniqueGenStartTimes = new Set();
|
283 |
+
|
284 |
+
if (fs.existsSync(chatDir)) {
|
285 |
+
const chats = fs.readdirSync(chatDir);
|
286 |
+
if (Array.isArray(chats) && chats.length) {
|
287 |
+
for (const chat of chats) {
|
288 |
+
const result = calculateTotalGenTimeAndWordCount(
|
289 |
+
chatDir,
|
290 |
+
chat,
|
291 |
+
uniqueGenStartTimes,
|
292 |
+
);
|
293 |
+
stats.total_gen_time += result.totalGenTime || 0;
|
294 |
+
stats.user_word_count += result.userWordCount || 0;
|
295 |
+
stats.non_user_word_count += result.nonUserWordCount || 0;
|
296 |
+
stats.user_msg_count += result.userMsgCount || 0;
|
297 |
+
stats.non_user_msg_count += result.nonUserMsgCount || 0;
|
298 |
+
stats.total_swipe_count += result.totalSwipeCount || 0;
|
299 |
+
|
300 |
+
const chatStat = fs.statSync(path.join(chatDir, chat));
|
301 |
+
stats.chat_size += chatStat.size;
|
302 |
+
stats.date_last_chat = Math.max(
|
303 |
+
stats.date_last_chat,
|
304 |
+
Math.floor(chatStat.mtimeMs),
|
305 |
+
);
|
306 |
+
stats.date_first_chat = Math.min(
|
307 |
+
stats.date_first_chat,
|
308 |
+
result.firstChatTime,
|
309 |
+
);
|
310 |
+
}
|
311 |
+
}
|
312 |
+
}
|
313 |
+
|
314 |
+
return { [item]: stats };
|
315 |
+
};
|
316 |
+
|
317 |
+
/**
|
318 |
+
* Sets the current charStats object.
|
319 |
+
* @param {string} handle - The user handle.
|
320 |
+
* @param {Object} stats - The new charStats object.
|
321 |
+
**/
|
322 |
+
function setCharStats(handle, stats) {
|
323 |
+
stats.timestamp = Date.now();
|
324 |
+
STATS.set(handle, stats);
|
325 |
+
}
|
326 |
+
|
327 |
+
/**
|
328 |
+
* Calculates the total generation time and word count for a chat with a character.
|
329 |
+
*
|
330 |
+
* @param {string} chatDir - The directory path where character chat files are stored.
|
331 |
+
* @param {string} chat - The name of the chat file.
|
332 |
+
* @returns {Object} - An object containing the total generation time, user word count, and non-user word count.
|
333 |
+
* @throws Will throw an error if the file cannot be read or parsed.
|
334 |
+
*/
|
335 |
+
function calculateTotalGenTimeAndWordCount(
|
336 |
+
chatDir,
|
337 |
+
chat,
|
338 |
+
uniqueGenStartTimes,
|
339 |
+
) {
|
340 |
+
let filepath = path.join(chatDir, chat);
|
341 |
+
let lines = readAndParseFile(filepath);
|
342 |
+
|
343 |
+
let totalGenTime = 0;
|
344 |
+
let userWordCount = 0;
|
345 |
+
let nonUserWordCount = 0;
|
346 |
+
let nonUserMsgCount = 0;
|
347 |
+
let userMsgCount = 0;
|
348 |
+
let totalSwipeCount = 0;
|
349 |
+
let firstChatTime = new Date('9999-12-31T23:59:59.999Z').getTime();
|
350 |
+
|
351 |
+
for (let line of lines) {
|
352 |
+
if (line.length) {
|
353 |
+
try {
|
354 |
+
let json = JSON.parse(line);
|
355 |
+
if (json.mes) {
|
356 |
+
let hash = crypto
|
357 |
+
.createHash('sha256')
|
358 |
+
.update(json.mes)
|
359 |
+
.digest('hex');
|
360 |
+
if (uniqueGenStartTimes.has(hash)) {
|
361 |
+
continue;
|
362 |
+
}
|
363 |
+
if (hash) {
|
364 |
+
uniqueGenStartTimes.add(hash);
|
365 |
+
}
|
366 |
+
}
|
367 |
+
|
368 |
+
if (json.gen_started && json.gen_finished) {
|
369 |
+
let genTime = calculateGenTime(
|
370 |
+
json.gen_started,
|
371 |
+
json.gen_finished,
|
372 |
+
);
|
373 |
+
totalGenTime += genTime;
|
374 |
+
|
375 |
+
if (json.swipes && !json.swipe_info) {
|
376 |
+
// If there are swipes but no swipe_info, estimate the genTime
|
377 |
+
totalGenTime += genTime * json.swipes.length;
|
378 |
+
}
|
379 |
+
}
|
380 |
+
|
381 |
+
if (json.mes) {
|
382 |
+
let wordCount = countWordsInString(json.mes);
|
383 |
+
json.is_user
|
384 |
+
? (userWordCount += wordCount)
|
385 |
+
: (nonUserWordCount += wordCount);
|
386 |
+
json.is_user ? userMsgCount++ : nonUserMsgCount++;
|
387 |
+
}
|
388 |
+
|
389 |
+
if (json.swipes && json.swipes.length > 1) {
|
390 |
+
totalSwipeCount += json.swipes.length - 1; // Subtract 1 to not count the first swipe
|
391 |
+
for (let i = 1; i < json.swipes.length; i++) {
|
392 |
+
// Start from the second swipe
|
393 |
+
let swipeText = json.swipes[i];
|
394 |
+
|
395 |
+
let wordCount = countWordsInString(swipeText);
|
396 |
+
json.is_user
|
397 |
+
? (userWordCount += wordCount)
|
398 |
+
: (nonUserWordCount += wordCount);
|
399 |
+
json.is_user ? userMsgCount++ : nonUserMsgCount++;
|
400 |
+
}
|
401 |
+
}
|
402 |
+
|
403 |
+
if (json.swipe_info && json.swipe_info.length > 1) {
|
404 |
+
for (let i = 1; i < json.swipe_info.length; i++) {
|
405 |
+
// Start from the second swipe
|
406 |
+
let swipe = json.swipe_info[i];
|
407 |
+
if (swipe.gen_started && swipe.gen_finished) {
|
408 |
+
totalGenTime += calculateGenTime(
|
409 |
+
swipe.gen_started,
|
410 |
+
swipe.gen_finished,
|
411 |
+
);
|
412 |
+
}
|
413 |
+
}
|
414 |
+
}
|
415 |
+
|
416 |
+
// If this is the first user message, set the first chat time
|
417 |
+
if (json.is_user) {
|
418 |
+
//get min between firstChatTime and timestampToMoment(json.send_date)
|
419 |
+
firstChatTime = Math.min(timestampToMoment(json.send_date), firstChatTime);
|
420 |
+
}
|
421 |
+
} catch (error) {
|
422 |
+
console.error(`Error parsing line ${line}: ${error}`);
|
423 |
+
}
|
424 |
+
}
|
425 |
+
}
|
426 |
+
return {
|
427 |
+
totalGenTime,
|
428 |
+
userWordCount,
|
429 |
+
nonUserWordCount,
|
430 |
+
userMsgCount,
|
431 |
+
nonUserMsgCount,
|
432 |
+
totalSwipeCount,
|
433 |
+
firstChatTime,
|
434 |
+
};
|
435 |
+
}
|
436 |
+
|
437 |
+
const router = express.Router();
|
438 |
+
|
439 |
+
/**
|
440 |
+
* Handle a POST request to get the stats object
|
441 |
+
*/
|
442 |
+
router.post('/get', jsonParser, function (request, response) {
|
443 |
+
const stats = STATS.get(request.user.profile.handle) || {};
|
444 |
+
response.send(stats);
|
445 |
+
});
|
446 |
+
|
447 |
+
/**
|
448 |
+
* Triggers the recreation of statistics from chat files.
|
449 |
+
*/
|
450 |
+
router.post('/recreate', jsonParser, async function (request, response) {
|
451 |
+
try {
|
452 |
+
await recreateStats(request.user.profile.handle, request.user.directories.chats, request.user.directories.characters);
|
453 |
+
return response.sendStatus(200);
|
454 |
+
} catch (error) {
|
455 |
+
console.error(error);
|
456 |
+
return response.sendStatus(500);
|
457 |
+
}
|
458 |
+
});
|
459 |
+
|
460 |
+
/**
|
461 |
+
* Handle a POST request to update the stats object
|
462 |
+
*/
|
463 |
+
router.post('/update', jsonParser, function (request, response) {
|
464 |
+
if (!request.body) return response.sendStatus(400);
|
465 |
+
setCharStats(request.user.profile.handle, request.body);
|
466 |
+
return response.sendStatus(200);
|
467 |
+
});
|
468 |
+
|
469 |
+
module.exports = {
|
470 |
+
router,
|
471 |
+
recreateStats,
|
472 |
+
init,
|
473 |
+
onExit,
|
474 |
+
};
|
src/endpoints/themes.js
ADDED
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const express = require('express');
|
2 |
+
const path = require('path');
|
3 |
+
const fs = require('fs');
|
4 |
+
const sanitize = require('sanitize-filename');
|
5 |
+
const writeFileAtomicSync = require('write-file-atomic').sync;
|
6 |
+
const { jsonParser } = require('../express-common');
|
7 |
+
|
8 |
+
const router = express.Router();
|
9 |
+
|
10 |
+
router.post('/save', jsonParser, (request, response) => {
|
11 |
+
if (!request.body || !request.body.name) {
|
12 |
+
return response.sendStatus(400);
|
13 |
+
}
|
14 |
+
|
15 |
+
const filename = path.join(request.user.directories.themes, sanitize(request.body.name) + '.json');
|
16 |
+
writeFileAtomicSync(filename, JSON.stringify(request.body, null, 4), 'utf8');
|
17 |
+
|
18 |
+
return response.sendStatus(200);
|
19 |
+
});
|
20 |
+
|
21 |
+
router.post('/delete', jsonParser, function (request, response) {
|
22 |
+
if (!request.body || !request.body.name) {
|
23 |
+
return response.sendStatus(400);
|
24 |
+
}
|
25 |
+
|
26 |
+
try {
|
27 |
+
const filename = path.join(request.user.directories.themes, sanitize(request.body.name) + '.json');
|
28 |
+
if (!fs.existsSync(filename)) {
|
29 |
+
console.error('Theme file not found:', filename);
|
30 |
+
return response.sendStatus(404);
|
31 |
+
}
|
32 |
+
fs.rmSync(filename);
|
33 |
+
return response.sendStatus(200);
|
34 |
+
} catch (error) {
|
35 |
+
console.error(error);
|
36 |
+
return response.sendStatus(500);
|
37 |
+
}
|
38 |
+
});
|
39 |
+
|
40 |
+
module.exports = { router };
|
src/endpoints/thumbnails.js
ADDED
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const fs = require('fs');
|
2 |
+
const fsPromises = require('fs').promises;
|
3 |
+
const path = require('path');
|
4 |
+
const mime = require('mime-types');
|
5 |
+
const express = require('express');
|
6 |
+
const sanitize = require('sanitize-filename');
|
7 |
+
const jimp = require('jimp');
|
8 |
+
const writeFileAtomicSync = require('write-file-atomic').sync;
|
9 |
+
const { getAllUserHandles, getUserDirectories } = require('../users');
|
10 |
+
const { getConfigValue } = require('../util');
|
11 |
+
const { jsonParser } = require('../express-common');
|
12 |
+
|
13 |
+
const thumbnailsDisabled = getConfigValue('disableThumbnails', false);
|
14 |
+
const quality = getConfigValue('thumbnailsQuality', 95);
|
15 |
+
const pngFormat = getConfigValue('avatarThumbnailsPng', false);
|
16 |
+
|
17 |
+
/**
|
18 |
+
* Gets a path to thumbnail folder based on the type.
|
19 |
+
* @param {import('../users').UserDirectoryList} directories User directories
|
20 |
+
* @param {'bg' | 'avatar'} type Thumbnail type
|
21 |
+
* @returns {string} Path to the thumbnails folder
|
22 |
+
*/
|
23 |
+
function getThumbnailFolder(directories, type) {
|
24 |
+
let thumbnailFolder;
|
25 |
+
|
26 |
+
switch (type) {
|
27 |
+
case 'bg':
|
28 |
+
thumbnailFolder = directories.thumbnailsBg;
|
29 |
+
break;
|
30 |
+
case 'avatar':
|
31 |
+
thumbnailFolder = directories.thumbnailsAvatar;
|
32 |
+
break;
|
33 |
+
}
|
34 |
+
|
35 |
+
return thumbnailFolder;
|
36 |
+
}
|
37 |
+
|
38 |
+
/**
|
39 |
+
* Gets a path to the original images folder based on the type.
|
40 |
+
* @param {import('../users').UserDirectoryList} directories User directories
|
41 |
+
* @param {'bg' | 'avatar'} type Thumbnail type
|
42 |
+
* @returns {string} Path to the original images folder
|
43 |
+
*/
|
44 |
+
function getOriginalFolder(directories, type) {
|
45 |
+
let originalFolder;
|
46 |
+
|
47 |
+
switch (type) {
|
48 |
+
case 'bg':
|
49 |
+
originalFolder = directories.backgrounds;
|
50 |
+
break;
|
51 |
+
case 'avatar':
|
52 |
+
originalFolder = directories.characters;
|
53 |
+
break;
|
54 |
+
}
|
55 |
+
|
56 |
+
return originalFolder;
|
57 |
+
}
|
58 |
+
|
59 |
+
/**
|
60 |
+
* Removes the generated thumbnail from the disk.
|
61 |
+
* @param {import('../users').UserDirectoryList} directories User directories
|
62 |
+
* @param {'bg' | 'avatar'} type Type of the thumbnail
|
63 |
+
* @param {string} file Name of the file
|
64 |
+
*/
|
65 |
+
function invalidateThumbnail(directories, type, file) {
|
66 |
+
const folder = getThumbnailFolder(directories, type);
|
67 |
+
if (folder === undefined) throw new Error('Invalid thumbnail type');
|
68 |
+
|
69 |
+
const pathToThumbnail = path.join(folder, file);
|
70 |
+
|
71 |
+
if (fs.existsSync(pathToThumbnail)) {
|
72 |
+
fs.rmSync(pathToThumbnail);
|
73 |
+
}
|
74 |
+
}
|
75 |
+
|
76 |
+
/**
|
77 |
+
* Generates a thumbnail for the given file.
|
78 |
+
* @param {import('../users').UserDirectoryList} directories User directories
|
79 |
+
* @param {'bg' | 'avatar'} type Type of the thumbnail
|
80 |
+
* @param {string} file Name of the file
|
81 |
+
* @returns
|
82 |
+
*/
|
83 |
+
async function generateThumbnail(directories, type, file) {
|
84 |
+
let thumbnailFolder = getThumbnailFolder(directories, type);
|
85 |
+
let originalFolder = getOriginalFolder(directories, type);
|
86 |
+
if (thumbnailFolder === undefined || originalFolder === undefined) throw new Error('Invalid thumbnail type');
|
87 |
+
|
88 |
+
const pathToCachedFile = path.join(thumbnailFolder, file);
|
89 |
+
const pathToOriginalFile = path.join(originalFolder, file);
|
90 |
+
|
91 |
+
const cachedFileExists = fs.existsSync(pathToCachedFile);
|
92 |
+
const originalFileExists = fs.existsSync(pathToOriginalFile);
|
93 |
+
|
94 |
+
// to handle cases when original image was updated after thumb creation
|
95 |
+
let shouldRegenerate = false;
|
96 |
+
|
97 |
+
if (cachedFileExists && originalFileExists) {
|
98 |
+
const originalStat = fs.statSync(pathToOriginalFile);
|
99 |
+
const cachedStat = fs.statSync(pathToCachedFile);
|
100 |
+
|
101 |
+
if (originalStat.mtimeMs > cachedStat.ctimeMs) {
|
102 |
+
//console.log('Original file changed. Regenerating thumbnail...');
|
103 |
+
shouldRegenerate = true;
|
104 |
+
}
|
105 |
+
}
|
106 |
+
|
107 |
+
if (cachedFileExists && !shouldRegenerate) {
|
108 |
+
return pathToCachedFile;
|
109 |
+
}
|
110 |
+
|
111 |
+
if (!originalFileExists) {
|
112 |
+
return null;
|
113 |
+
}
|
114 |
+
|
115 |
+
const imageSizes = { 'bg': [160, 90], 'avatar': [96, 144] };
|
116 |
+
const mySize = imageSizes[type];
|
117 |
+
|
118 |
+
try {
|
119 |
+
let buffer;
|
120 |
+
|
121 |
+
try {
|
122 |
+
const image = await jimp.read(pathToOriginalFile);
|
123 |
+
const imgType = type == 'avatar' && pngFormat ? 'image/png' : 'image/jpeg';
|
124 |
+
buffer = await image.cover(mySize[0], mySize[1]).quality(quality).getBufferAsync(imgType);
|
125 |
+
}
|
126 |
+
catch (inner) {
|
127 |
+
console.warn(`Thumbnailer can not process the image: ${pathToOriginalFile}. Using original size`);
|
128 |
+
buffer = fs.readFileSync(pathToOriginalFile);
|
129 |
+
}
|
130 |
+
|
131 |
+
writeFileAtomicSync(pathToCachedFile, buffer);
|
132 |
+
}
|
133 |
+
catch (outer) {
|
134 |
+
return null;
|
135 |
+
}
|
136 |
+
|
137 |
+
return pathToCachedFile;
|
138 |
+
}
|
139 |
+
|
140 |
+
/**
|
141 |
+
* Ensures that the thumbnail cache for backgrounds is valid.
|
142 |
+
* @returns {Promise<void>} Promise that resolves when the cache is validated
|
143 |
+
*/
|
144 |
+
async function ensureThumbnailCache() {
|
145 |
+
const userHandles = await getAllUserHandles();
|
146 |
+
for (const handle of userHandles) {
|
147 |
+
const directories = getUserDirectories(handle);
|
148 |
+
const cacheFiles = fs.readdirSync(directories.thumbnailsBg);
|
149 |
+
|
150 |
+
// files exist, all ok
|
151 |
+
if (cacheFiles.length) {
|
152 |
+
return;
|
153 |
+
}
|
154 |
+
|
155 |
+
console.log('Generating thumbnails cache. Please wait...');
|
156 |
+
|
157 |
+
const bgFiles = fs.readdirSync(directories.backgrounds);
|
158 |
+
const tasks = [];
|
159 |
+
|
160 |
+
for (const file of bgFiles) {
|
161 |
+
tasks.push(generateThumbnail(directories, 'bg', file));
|
162 |
+
}
|
163 |
+
|
164 |
+
await Promise.all(tasks);
|
165 |
+
console.log(`Done! Generated: ${bgFiles.length} preview images`);
|
166 |
+
}
|
167 |
+
}
|
168 |
+
|
169 |
+
const router = express.Router();
|
170 |
+
|
171 |
+
// Important: This route must be mounted as '/thumbnail'. It is used in the client code and saved to chat files.
|
172 |
+
router.get('/', jsonParser, async function (request, response) {
|
173 |
+
try{
|
174 |
+
if (typeof request.query.file !== 'string' || typeof request.query.type !== 'string') {
|
175 |
+
return response.sendStatus(400);
|
176 |
+
}
|
177 |
+
|
178 |
+
const type = request.query.type;
|
179 |
+
const file = sanitize(request.query.file);
|
180 |
+
|
181 |
+
if (!type || !file) {
|
182 |
+
return response.sendStatus(400);
|
183 |
+
}
|
184 |
+
|
185 |
+
if (!(type == 'bg' || type == 'avatar')) {
|
186 |
+
return response.sendStatus(400);
|
187 |
+
}
|
188 |
+
|
189 |
+
if (sanitize(file) !== file) {
|
190 |
+
console.error('Malicious filename prevented');
|
191 |
+
return response.sendStatus(403);
|
192 |
+
}
|
193 |
+
|
194 |
+
if (thumbnailsDisabled) {
|
195 |
+
const folder = getOriginalFolder(request.user.directories, type);
|
196 |
+
|
197 |
+
if (folder === undefined) {
|
198 |
+
return response.sendStatus(400);
|
199 |
+
}
|
200 |
+
|
201 |
+
const pathToOriginalFile = path.join(folder, file);
|
202 |
+
if (!fs.existsSync(pathToOriginalFile)) {
|
203 |
+
return response.sendStatus(404);
|
204 |
+
}
|
205 |
+
const contentType = mime.lookup(pathToOriginalFile) || 'image/png';
|
206 |
+
const originalFile = await fsPromises.readFile(pathToOriginalFile);
|
207 |
+
response.setHeader('Content-Type', contentType);
|
208 |
+
return response.send(originalFile);
|
209 |
+
}
|
210 |
+
|
211 |
+
const pathToCachedFile = await generateThumbnail(request.user.directories, type, file);
|
212 |
+
|
213 |
+
if (!pathToCachedFile) {
|
214 |
+
return response.sendStatus(404);
|
215 |
+
}
|
216 |
+
|
217 |
+
if (!fs.existsSync(pathToCachedFile)) {
|
218 |
+
return response.sendStatus(404);
|
219 |
+
}
|
220 |
+
|
221 |
+
const contentType = mime.lookup(pathToCachedFile) || 'image/jpeg';
|
222 |
+
const cachedFile = await fsPromises.readFile(pathToCachedFile);
|
223 |
+
response.setHeader('Content-Type', contentType);
|
224 |
+
return response.send(cachedFile);
|
225 |
+
} catch (error) {
|
226 |
+
console.error('Failed getting thumbnail', error);
|
227 |
+
return response.sendStatus(500);
|
228 |
+
}
|
229 |
+
});
|
230 |
+
|
231 |
+
module.exports = {
|
232 |
+
invalidateThumbnail,
|
233 |
+
ensureThumbnailCache,
|
234 |
+
router,
|
235 |
+
};
|
src/endpoints/tokenizers.js
ADDED
@@ -0,0 +1,885 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const fs = require('fs');
|
2 |
+
const path = require('path');
|
3 |
+
const express = require('express');
|
4 |
+
const { SentencePieceProcessor } = require('@agnai/sentencepiece-js');
|
5 |
+
const tiktoken = require('tiktoken');
|
6 |
+
const { Tokenizer } = require('@agnai/web-tokenizers');
|
7 |
+
const { convertClaudePrompt, convertGooglePrompt } = require('../prompt-converters');
|
8 |
+
const { readSecret, SECRET_KEYS } = require('./secrets');
|
9 |
+
const { TEXTGEN_TYPES } = require('../constants');
|
10 |
+
const { jsonParser } = require('../express-common');
|
11 |
+
const { setAdditionalHeaders } = require('../additional-headers');
|
12 |
+
|
13 |
+
const API_MAKERSUITE = 'https://generativelanguage.googleapis.com';
|
14 |
+
|
15 |
+
/**
|
16 |
+
* @typedef { (req: import('express').Request, res: import('express').Response) => Promise<any> } TokenizationHandler
|
17 |
+
*/
|
18 |
+
|
19 |
+
/**
|
20 |
+
* @type {{[key: string]: import('tiktoken').Tiktoken}} Tokenizers cache
|
21 |
+
*/
|
22 |
+
const tokenizersCache = {};
|
23 |
+
|
24 |
+
/**
|
25 |
+
* @type {string[]}
|
26 |
+
*/
|
27 |
+
const TEXT_COMPLETION_MODELS = [
|
28 |
+
'gpt-3.5-turbo-instruct',
|
29 |
+
'gpt-3.5-turbo-instruct-0914',
|
30 |
+
'text-davinci-003',
|
31 |
+
'text-davinci-002',
|
32 |
+
'text-davinci-001',
|
33 |
+
'text-curie-001',
|
34 |
+
'text-babbage-001',
|
35 |
+
'text-ada-001',
|
36 |
+
'code-davinci-002',
|
37 |
+
'code-davinci-001',
|
38 |
+
'code-cushman-002',
|
39 |
+
'code-cushman-001',
|
40 |
+
'text-davinci-edit-001',
|
41 |
+
'code-davinci-edit-001',
|
42 |
+
'text-embedding-ada-002',
|
43 |
+
'text-similarity-davinci-001',
|
44 |
+
'text-similarity-curie-001',
|
45 |
+
'text-similarity-babbage-001',
|
46 |
+
'text-similarity-ada-001',
|
47 |
+
'text-search-davinci-doc-001',
|
48 |
+
'text-search-curie-doc-001',
|
49 |
+
'text-search-babbage-doc-001',
|
50 |
+
'text-search-ada-doc-001',
|
51 |
+
'code-search-babbage-code-001',
|
52 |
+
'code-search-ada-code-001',
|
53 |
+
];
|
54 |
+
|
55 |
+
const CHARS_PER_TOKEN = 3.35;
|
56 |
+
|
57 |
+
/**
|
58 |
+
* Sentencepiece tokenizer for tokenizing text.
|
59 |
+
*/
|
60 |
+
class SentencePieceTokenizer {
|
61 |
+
/**
|
62 |
+
* @type {import('@agnai/sentencepiece-js').SentencePieceProcessor} Sentencepiece tokenizer instance
|
63 |
+
*/
|
64 |
+
#instance;
|
65 |
+
/**
|
66 |
+
* @type {string} Path to the tokenizer model
|
67 |
+
*/
|
68 |
+
#model;
|
69 |
+
|
70 |
+
/**
|
71 |
+
* Creates a new Sentencepiece tokenizer.
|
72 |
+
* @param {string} model Path to the tokenizer model
|
73 |
+
*/
|
74 |
+
constructor(model) {
|
75 |
+
this.#model = model;
|
76 |
+
}
|
77 |
+
|
78 |
+
/**
|
79 |
+
* Gets the Sentencepiece tokenizer instance.
|
80 |
+
* @returns {Promise<import('@agnai/sentencepiece-js').SentencePieceProcessor|null>} Sentencepiece tokenizer instance
|
81 |
+
*/
|
82 |
+
async get() {
|
83 |
+
if (this.#instance) {
|
84 |
+
return this.#instance;
|
85 |
+
}
|
86 |
+
|
87 |
+
try {
|
88 |
+
this.#instance = new SentencePieceProcessor();
|
89 |
+
await this.#instance.load(this.#model);
|
90 |
+
console.log('Instantiated the tokenizer for', path.parse(this.#model).name);
|
91 |
+
return this.#instance;
|
92 |
+
} catch (error) {
|
93 |
+
console.error('Sentencepiece tokenizer failed to load: ' + this.#model, error);
|
94 |
+
return null;
|
95 |
+
}
|
96 |
+
}
|
97 |
+
}
|
98 |
+
|
99 |
+
/**
|
100 |
+
* Web tokenizer for tokenizing text.
|
101 |
+
*/
|
102 |
+
class WebTokenizer {
|
103 |
+
/**
|
104 |
+
* @type {Tokenizer} Web tokenizer instance
|
105 |
+
*/
|
106 |
+
#instance;
|
107 |
+
/**
|
108 |
+
* @type {string} Path to the tokenizer model
|
109 |
+
*/
|
110 |
+
#model;
|
111 |
+
|
112 |
+
/**
|
113 |
+
* Creates a new Web tokenizer.
|
114 |
+
* @param {string} model Path to the tokenizer model
|
115 |
+
*/
|
116 |
+
constructor(model) {
|
117 |
+
this.#model = model;
|
118 |
+
}
|
119 |
+
|
120 |
+
/**
|
121 |
+
* Gets the Web tokenizer instance.
|
122 |
+
* @returns {Promise<Tokenizer|null>} Web tokenizer instance
|
123 |
+
*/
|
124 |
+
async get() {
|
125 |
+
if (this.#instance) {
|
126 |
+
return this.#instance;
|
127 |
+
}
|
128 |
+
|
129 |
+
try {
|
130 |
+
const arrayBuffer = fs.readFileSync(this.#model).buffer;
|
131 |
+
this.#instance = await Tokenizer.fromJSON(arrayBuffer);
|
132 |
+
console.log('Instantiated the tokenizer for', path.parse(this.#model).name);
|
133 |
+
return this.#instance;
|
134 |
+
} catch (error) {
|
135 |
+
console.error('Web tokenizer failed to load: ' + this.#model, error);
|
136 |
+
return null;
|
137 |
+
}
|
138 |
+
}
|
139 |
+
}
|
140 |
+
|
141 |
+
const spp_llama = new SentencePieceTokenizer('src/tokenizers/llama.model');
|
142 |
+
const spp_nerd = new SentencePieceTokenizer('src/tokenizers/nerdstash.model');
|
143 |
+
const spp_nerd_v2 = new SentencePieceTokenizer('src/tokenizers/nerdstash_v2.model');
|
144 |
+
const spp_mistral = new SentencePieceTokenizer('src/tokenizers/mistral.model');
|
145 |
+
const spp_yi = new SentencePieceTokenizer('src/tokenizers/yi.model');
|
146 |
+
const spp_gemma = new SentencePieceTokenizer('src/tokenizers/gemma.model');
|
147 |
+
const claude_tokenizer = new WebTokenizer('src/tokenizers/claude.json');
|
148 |
+
const llama3_tokenizer = new WebTokenizer('src/tokenizers/llama3.json');
|
149 |
+
|
150 |
+
const sentencepieceTokenizers = [
|
151 |
+
'llama',
|
152 |
+
'nerdstash',
|
153 |
+
'nerdstash_v2',
|
154 |
+
'mistral',
|
155 |
+
'yi',
|
156 |
+
'gemma',
|
157 |
+
];
|
158 |
+
|
159 |
+
/**
|
160 |
+
* Gets the Sentencepiece tokenizer by the model name.
|
161 |
+
* @param {string} model Sentencepiece model name
|
162 |
+
* @returns {SentencePieceTokenizer|null} Sentencepiece tokenizer
|
163 |
+
*/
|
164 |
+
function getSentencepiceTokenizer(model) {
|
165 |
+
if (model.includes('llama')) {
|
166 |
+
return spp_llama;
|
167 |
+
}
|
168 |
+
|
169 |
+
if (model.includes('nerdstash')) {
|
170 |
+
return spp_nerd;
|
171 |
+
}
|
172 |
+
|
173 |
+
if (model.includes('mistral')) {
|
174 |
+
return spp_mistral;
|
175 |
+
}
|
176 |
+
|
177 |
+
if (model.includes('nerdstash_v2')) {
|
178 |
+
return spp_nerd_v2;
|
179 |
+
}
|
180 |
+
|
181 |
+
if (model.includes('yi')) {
|
182 |
+
return spp_yi;
|
183 |
+
}
|
184 |
+
|
185 |
+
if (model.includes('gemma')) {
|
186 |
+
return spp_gemma;
|
187 |
+
}
|
188 |
+
|
189 |
+
return null;
|
190 |
+
}
|
191 |
+
|
192 |
+
/**
|
193 |
+
* Counts the token ids for the given text using the Sentencepiece tokenizer.
|
194 |
+
* @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer
|
195 |
+
* @param {string} text Text to tokenize
|
196 |
+
* @returns { Promise<{ids: number[], count: number}> } Tokenization result
|
197 |
+
*/
|
198 |
+
async function countSentencepieceTokens(tokenizer, text) {
|
199 |
+
const instance = await tokenizer?.get();
|
200 |
+
|
201 |
+
// Fallback to strlen estimation
|
202 |
+
if (!instance) {
|
203 |
+
return {
|
204 |
+
ids: [],
|
205 |
+
count: Math.ceil(text.length / CHARS_PER_TOKEN),
|
206 |
+
};
|
207 |
+
}
|
208 |
+
|
209 |
+
let cleaned = text; // cleanText(text); <-- cleaning text can result in an incorrect tokenization
|
210 |
+
|
211 |
+
let ids = instance.encodeIds(cleaned);
|
212 |
+
return {
|
213 |
+
ids,
|
214 |
+
count: ids.length,
|
215 |
+
};
|
216 |
+
}
|
217 |
+
|
218 |
+
/**
|
219 |
+
* Counts the tokens in the given array of objects using the Sentencepiece tokenizer.
|
220 |
+
* @param {SentencePieceTokenizer} tokenizer
|
221 |
+
* @param {object[]} array Array of objects to tokenize
|
222 |
+
* @returns {Promise<number>} Number of tokens
|
223 |
+
*/
|
224 |
+
async function countSentencepieceArrayTokens(tokenizer, array) {
|
225 |
+
const jsonBody = array.flatMap(x => Object.values(x)).join('\n\n');
|
226 |
+
const result = await countSentencepieceTokens(tokenizer, jsonBody);
|
227 |
+
const num_tokens = result.count;
|
228 |
+
return num_tokens;
|
229 |
+
}
|
230 |
+
|
231 |
+
async function getTiktokenChunks(tokenizer, ids) {
|
232 |
+
const decoder = new TextDecoder();
|
233 |
+
const chunks = [];
|
234 |
+
|
235 |
+
for (let i = 0; i < ids.length; i++) {
|
236 |
+
const id = ids[i];
|
237 |
+
const chunkTextBytes = await tokenizer.decode(new Uint32Array([id]));
|
238 |
+
const chunkText = decoder.decode(chunkTextBytes);
|
239 |
+
chunks.push(chunkText);
|
240 |
+
}
|
241 |
+
|
242 |
+
return chunks;
|
243 |
+
}
|
244 |
+
|
245 |
+
/**
|
246 |
+
* Gets the token chunks for the given token IDs using the Web tokenizer.
|
247 |
+
* @param {Tokenizer} tokenizer Web tokenizer instance
|
248 |
+
* @param {number[]} ids Token IDs
|
249 |
+
* @returns {string[]} Token chunks
|
250 |
+
*/
|
251 |
+
function getWebTokenizersChunks(tokenizer, ids) {
|
252 |
+
const chunks = [];
|
253 |
+
|
254 |
+
for (let i = 0, lastProcessed = 0; i < ids.length; i++) {
|
255 |
+
const chunkIds = ids.slice(lastProcessed, i + 1);
|
256 |
+
const chunkText = tokenizer.decode(new Int32Array(chunkIds));
|
257 |
+
if (chunkText === '�') {
|
258 |
+
continue;
|
259 |
+
}
|
260 |
+
chunks.push(chunkText);
|
261 |
+
lastProcessed = i + 1;
|
262 |
+
}
|
263 |
+
|
264 |
+
return chunks;
|
265 |
+
}
|
266 |
+
|
267 |
+
/**
|
268 |
+
* Gets the tokenizer model by the model name.
|
269 |
+
* @param {string} requestModel Models to use for tokenization
|
270 |
+
* @returns {string} Tokenizer model to use
|
271 |
+
*/
|
272 |
+
function getTokenizerModel(requestModel) {
|
273 |
+
if (requestModel.includes('gpt-4o')) {
|
274 |
+
return 'gpt-4o';
|
275 |
+
}
|
276 |
+
|
277 |
+
if (requestModel.includes('chatgpt-4o-latest')) {
|
278 |
+
return 'gpt-4o';
|
279 |
+
}
|
280 |
+
|
281 |
+
if (requestModel.includes('gpt-4-32k')) {
|
282 |
+
return 'gpt-4-32k';
|
283 |
+
}
|
284 |
+
|
285 |
+
if (requestModel.includes('gpt-4')) {
|
286 |
+
return 'gpt-4';
|
287 |
+
}
|
288 |
+
|
289 |
+
if (requestModel.includes('gpt-3.5-turbo-0301')) {
|
290 |
+
return 'gpt-3.5-turbo-0301';
|
291 |
+
}
|
292 |
+
|
293 |
+
if (requestModel.includes('gpt-3.5-turbo')) {
|
294 |
+
return 'gpt-3.5-turbo';
|
295 |
+
}
|
296 |
+
|
297 |
+
if (TEXT_COMPLETION_MODELS.includes(requestModel)) {
|
298 |
+
return requestModel;
|
299 |
+
}
|
300 |
+
|
301 |
+
if (requestModel.includes('claude')) {
|
302 |
+
return 'claude';
|
303 |
+
}
|
304 |
+
|
305 |
+
if (requestModel.includes('llama3') || requestModel.includes('llama-3')) {
|
306 |
+
return 'llama3';
|
307 |
+
}
|
308 |
+
|
309 |
+
if (requestModel.includes('llama')) {
|
310 |
+
return 'llama';
|
311 |
+
}
|
312 |
+
|
313 |
+
if (requestModel.includes('mistral')) {
|
314 |
+
return 'mistral';
|
315 |
+
}
|
316 |
+
|
317 |
+
if (requestModel.includes('yi')) {
|
318 |
+
return 'yi';
|
319 |
+
}
|
320 |
+
|
321 |
+
if (requestModel.includes('gemma') || requestModel.includes('gemini')) {
|
322 |
+
return 'gemma';
|
323 |
+
}
|
324 |
+
|
325 |
+
// default
|
326 |
+
return 'gpt-3.5-turbo';
|
327 |
+
}
|
328 |
+
|
329 |
+
function getTiktokenTokenizer(model) {
|
330 |
+
if (tokenizersCache[model]) {
|
331 |
+
return tokenizersCache[model];
|
332 |
+
}
|
333 |
+
|
334 |
+
const tokenizer = tiktoken.encoding_for_model(model);
|
335 |
+
console.log('Instantiated the tokenizer for', model);
|
336 |
+
tokenizersCache[model] = tokenizer;
|
337 |
+
return tokenizer;
|
338 |
+
}
|
339 |
+
|
340 |
+
/**
|
341 |
+
* Counts the tokens for the given messages using the WebTokenizer and Claude prompt conversion.
|
342 |
+
* @param {Tokenizer} tokenizer Web tokenizer
|
343 |
+
* @param {object[]} messages Array of messages
|
344 |
+
* @returns {number} Number of tokens
|
345 |
+
*/
|
346 |
+
function countWebTokenizerTokens(tokenizer, messages) {
|
347 |
+
// Should be fine if we use the old conversion method instead of the messages API one i think?
|
348 |
+
const convertedPrompt = convertClaudePrompt(messages, false, '', false, false, '', false);
|
349 |
+
|
350 |
+
// Fallback to strlen estimation
|
351 |
+
if (!tokenizer) {
|
352 |
+
return Math.ceil(convertedPrompt.length / CHARS_PER_TOKEN);
|
353 |
+
}
|
354 |
+
|
355 |
+
const count = tokenizer.encode(convertedPrompt).length;
|
356 |
+
return count;
|
357 |
+
}
|
358 |
+
|
359 |
+
/**
|
360 |
+
* Creates an API handler for encoding Sentencepiece tokens.
|
361 |
+
* @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer
|
362 |
+
* @returns {TokenizationHandler} Handler function
|
363 |
+
*/
|
364 |
+
function createSentencepieceEncodingHandler(tokenizer) {
|
365 |
+
/**
|
366 |
+
* Request handler for encoding Sentencepiece tokens.
|
367 |
+
* @param {import('express').Request} request
|
368 |
+
* @param {import('express').Response} response
|
369 |
+
*/
|
370 |
+
return async function (request, response) {
|
371 |
+
try {
|
372 |
+
if (!request.body) {
|
373 |
+
return response.sendStatus(400);
|
374 |
+
}
|
375 |
+
|
376 |
+
const text = request.body.text || '';
|
377 |
+
const instance = await tokenizer?.get();
|
378 |
+
const { ids, count } = await countSentencepieceTokens(tokenizer, text);
|
379 |
+
const chunks = instance?.encodePieces(text);
|
380 |
+
return response.send({ ids, count, chunks });
|
381 |
+
} catch (error) {
|
382 |
+
console.log(error);
|
383 |
+
return response.send({ ids: [], count: 0, chunks: [] });
|
384 |
+
}
|
385 |
+
};
|
386 |
+
}
|
387 |
+
|
388 |
+
/**
|
389 |
+
* Creates an API handler for decoding Sentencepiece tokens.
|
390 |
+
* @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer
|
391 |
+
* @returns {TokenizationHandler} Handler function
|
392 |
+
*/
|
393 |
+
function createSentencepieceDecodingHandler(tokenizer) {
|
394 |
+
/**
|
395 |
+
* Request handler for decoding Sentencepiece tokens.
|
396 |
+
* @param {import('express').Request} request
|
397 |
+
* @param {import('express').Response} response
|
398 |
+
*/
|
399 |
+
return async function (request, response) {
|
400 |
+
try {
|
401 |
+
if (!request.body) {
|
402 |
+
return response.sendStatus(400);
|
403 |
+
}
|
404 |
+
|
405 |
+
const ids = request.body.ids || [];
|
406 |
+
const instance = await tokenizer?.get();
|
407 |
+
if (!instance) throw new Error('Failed to load the Sentencepiece tokenizer');
|
408 |
+
const ops = ids.map(id => instance.decodeIds([id]));
|
409 |
+
const chunks = await Promise.all(ops);
|
410 |
+
const text = chunks.join('');
|
411 |
+
return response.send({ text, chunks });
|
412 |
+
} catch (error) {
|
413 |
+
console.log(error);
|
414 |
+
return response.send({ text: '', chunks: [] });
|
415 |
+
}
|
416 |
+
};
|
417 |
+
}
|
418 |
+
|
419 |
+
/**
|
420 |
+
* Creates an API handler for encoding Tiktoken tokens.
|
421 |
+
* @param {string} modelId Tiktoken model ID
|
422 |
+
* @returns {TokenizationHandler} Handler function
|
423 |
+
*/
|
424 |
+
function createTiktokenEncodingHandler(modelId) {
|
425 |
+
/**
|
426 |
+
* Request handler for encoding Tiktoken tokens.
|
427 |
+
* @param {import('express').Request} request
|
428 |
+
* @param {import('express').Response} response
|
429 |
+
*/
|
430 |
+
return async function (request, response) {
|
431 |
+
try {
|
432 |
+
if (!request.body) {
|
433 |
+
return response.sendStatus(400);
|
434 |
+
}
|
435 |
+
|
436 |
+
const text = request.body.text || '';
|
437 |
+
const tokenizer = getTiktokenTokenizer(modelId);
|
438 |
+
const tokens = Object.values(tokenizer.encode(text));
|
439 |
+
const chunks = await getTiktokenChunks(tokenizer, tokens);
|
440 |
+
return response.send({ ids: tokens, count: tokens.length, chunks });
|
441 |
+
} catch (error) {
|
442 |
+
console.log(error);
|
443 |
+
return response.send({ ids: [], count: 0, chunks: [] });
|
444 |
+
}
|
445 |
+
};
|
446 |
+
}
|
447 |
+
|
448 |
+
/**
|
449 |
+
* Creates an API handler for decoding Tiktoken tokens.
|
450 |
+
* @param {string} modelId Tiktoken model ID
|
451 |
+
* @returns {TokenizationHandler} Handler function
|
452 |
+
*/
|
453 |
+
function createTiktokenDecodingHandler(modelId) {
|
454 |
+
/**
|
455 |
+
* Request handler for decoding Tiktoken tokens.
|
456 |
+
* @param {import('express').Request} request
|
457 |
+
* @param {import('express').Response} response
|
458 |
+
*/
|
459 |
+
return async function (request, response) {
|
460 |
+
try {
|
461 |
+
if (!request.body) {
|
462 |
+
return response.sendStatus(400);
|
463 |
+
}
|
464 |
+
|
465 |
+
const ids = request.body.ids || [];
|
466 |
+
const tokenizer = getTiktokenTokenizer(modelId);
|
467 |
+
const textBytes = tokenizer.decode(new Uint32Array(ids));
|
468 |
+
const text = new TextDecoder().decode(textBytes);
|
469 |
+
return response.send({ text });
|
470 |
+
} catch (error) {
|
471 |
+
console.log(error);
|
472 |
+
return response.send({ text: '' });
|
473 |
+
}
|
474 |
+
};
|
475 |
+
}
|
476 |
+
|
477 |
+
/**
|
478 |
+
* Creates an API handler for encoding WebTokenizer tokens.
|
479 |
+
* @param {WebTokenizer} tokenizer WebTokenizer instance
|
480 |
+
* @returns {TokenizationHandler} Handler function
|
481 |
+
*/
|
482 |
+
function createWebTokenizerEncodingHandler(tokenizer) {
|
483 |
+
/**
|
484 |
+
* Request handler for encoding WebTokenizer tokens.
|
485 |
+
* @param {import('express').Request} request
|
486 |
+
* @param {import('express').Response} response
|
487 |
+
*/
|
488 |
+
return async function (request, response) {
|
489 |
+
try {
|
490 |
+
if (!request.body) {
|
491 |
+
return response.sendStatus(400);
|
492 |
+
}
|
493 |
+
|
494 |
+
const text = request.body.text || '';
|
495 |
+
const instance = await tokenizer?.get();
|
496 |
+
if (!instance) throw new Error('Failed to load the Web tokenizer');
|
497 |
+
const tokens = Array.from(instance.encode(text));
|
498 |
+
const chunks = getWebTokenizersChunks(instance, tokens);
|
499 |
+
return response.send({ ids: tokens, count: tokens.length, chunks });
|
500 |
+
} catch (error) {
|
501 |
+
console.log(error);
|
502 |
+
return response.send({ ids: [], count: 0, chunks: [] });
|
503 |
+
}
|
504 |
+
};
|
505 |
+
}
|
506 |
+
|
507 |
+
/**
|
508 |
+
* Creates an API handler for decoding WebTokenizer tokens.
|
509 |
+
* @param {WebTokenizer} tokenizer WebTokenizer instance
|
510 |
+
* @returns {TokenizationHandler} Handler function
|
511 |
+
*/
|
512 |
+
function createWebTokenizerDecodingHandler(tokenizer) {
|
513 |
+
/**
|
514 |
+
* Request handler for decoding WebTokenizer tokens.
|
515 |
+
* @param {import('express').Request} request
|
516 |
+
* @param {import('express').Response} response
|
517 |
+
* @returns {Promise<any>}
|
518 |
+
*/
|
519 |
+
return async function (request, response) {
|
520 |
+
try {
|
521 |
+
if (!request.body) {
|
522 |
+
return response.sendStatus(400);
|
523 |
+
}
|
524 |
+
|
525 |
+
const ids = request.body.ids || [];
|
526 |
+
const instance = await tokenizer?.get();
|
527 |
+
if (!instance) throw new Error('Failed to load the Web tokenizer');
|
528 |
+
const chunks = getWebTokenizersChunks(instance, ids);
|
529 |
+
const text = instance.decode(new Int32Array(ids));
|
530 |
+
return response.send({ text, chunks });
|
531 |
+
} catch (error) {
|
532 |
+
console.log(error);
|
533 |
+
return response.send({ text: '', chunks: [] });
|
534 |
+
}
|
535 |
+
};
|
536 |
+
}
|
537 |
+
|
538 |
+
const router = express.Router();
|
539 |
+
|
540 |
+
router.post('/ai21/count', jsonParser, async function (req, res) {
|
541 |
+
if (!req.body) return res.sendStatus(400);
|
542 |
+
const key = readSecret(req.user.directories, SECRET_KEYS.AI21);
|
543 |
+
const options = {
|
544 |
+
method: 'POST',
|
545 |
+
headers: {
|
546 |
+
accept: 'application/json',
|
547 |
+
'content-type': 'application/json',
|
548 |
+
Authorization: `Bearer ${key}`,
|
549 |
+
},
|
550 |
+
body: JSON.stringify({ text: req.body[0].content }),
|
551 |
+
};
|
552 |
+
|
553 |
+
try {
|
554 |
+
const response = await fetch('https://api.ai21.com/studio/v1/tokenize', options);
|
555 |
+
const data = await response.json();
|
556 |
+
return res.send({ 'token_count': data?.tokens?.length || 0 });
|
557 |
+
} catch (err) {
|
558 |
+
console.error(err);
|
559 |
+
return res.send({ 'token_count': 0 });
|
560 |
+
}
|
561 |
+
});
|
562 |
+
|
563 |
+
router.post('/google/count', jsonParser, async function (req, res) {
|
564 |
+
if (!req.body) return res.sendStatus(400);
|
565 |
+
const options = {
|
566 |
+
method: 'POST',
|
567 |
+
headers: {
|
568 |
+
accept: 'application/json',
|
569 |
+
'content-type': 'application/json',
|
570 |
+
},
|
571 |
+
body: JSON.stringify({ contents: convertGooglePrompt(req.body, String(req.query.model)).contents }),
|
572 |
+
};
|
573 |
+
try {
|
574 |
+
const reverseProxy = req.query.reverse_proxy?.toString() || '';
|
575 |
+
const proxyPassword = req.query.proxy_password?.toString() || '';
|
576 |
+
const apiKey = reverseProxy ? proxyPassword : readSecret(req.user.directories, SECRET_KEYS.MAKERSUITE);
|
577 |
+
const apiUrl = new URL(reverseProxy || API_MAKERSUITE);
|
578 |
+
const response = await fetch(`${apiUrl.origin}/v1beta/models/${req.query.model}:countTokens?key=${apiKey}`, options);
|
579 |
+
const data = await response.json();
|
580 |
+
return res.send({ 'token_count': data?.totalTokens || 0 });
|
581 |
+
} catch (err) {
|
582 |
+
console.error(err);
|
583 |
+
return res.send({ 'token_count': 0 });
|
584 |
+
}
|
585 |
+
});
|
586 |
+
|
587 |
+
router.post('/llama/encode', jsonParser, createSentencepieceEncodingHandler(spp_llama));
|
588 |
+
router.post('/nerdstash/encode', jsonParser, createSentencepieceEncodingHandler(spp_nerd));
|
589 |
+
router.post('/nerdstash_v2/encode', jsonParser, createSentencepieceEncodingHandler(spp_nerd_v2));
|
590 |
+
router.post('/mistral/encode', jsonParser, createSentencepieceEncodingHandler(spp_mistral));
|
591 |
+
router.post('/yi/encode', jsonParser, createSentencepieceEncodingHandler(spp_yi));
|
592 |
+
router.post('/gemma/encode', jsonParser, createSentencepieceEncodingHandler(spp_gemma));
|
593 |
+
router.post('/gpt2/encode', jsonParser, createTiktokenEncodingHandler('gpt2'));
|
594 |
+
router.post('/claude/encode', jsonParser, createWebTokenizerEncodingHandler(claude_tokenizer));
|
595 |
+
router.post('/llama3/encode', jsonParser, createWebTokenizerEncodingHandler(llama3_tokenizer));
|
596 |
+
router.post('/llama/decode', jsonParser, createSentencepieceDecodingHandler(spp_llama));
|
597 |
+
router.post('/nerdstash/decode', jsonParser, createSentencepieceDecodingHandler(spp_nerd));
|
598 |
+
router.post('/nerdstash_v2/decode', jsonParser, createSentencepieceDecodingHandler(spp_nerd_v2));
|
599 |
+
router.post('/mistral/decode', jsonParser, createSentencepieceDecodingHandler(spp_mistral));
|
600 |
+
router.post('/yi/decode', jsonParser, createSentencepieceDecodingHandler(spp_yi));
|
601 |
+
router.post('/gemma/decode', jsonParser, createSentencepieceDecodingHandler(spp_gemma));
|
602 |
+
router.post('/gpt2/decode', jsonParser, createTiktokenDecodingHandler('gpt2'));
|
603 |
+
router.post('/claude/decode', jsonParser, createWebTokenizerDecodingHandler(claude_tokenizer));
|
604 |
+
router.post('/llama3/decode', jsonParser, createWebTokenizerDecodingHandler(llama3_tokenizer));
|
605 |
+
|
606 |
+
router.post('/openai/encode', jsonParser, async function (req, res) {
|
607 |
+
try {
|
608 |
+
const queryModel = String(req.query.model || '');
|
609 |
+
|
610 |
+
if (queryModel.includes('llama3') || queryModel.includes('llama-3')) {
|
611 |
+
const handler = createWebTokenizerEncodingHandler(llama3_tokenizer);
|
612 |
+
return handler(req, res);
|
613 |
+
}
|
614 |
+
|
615 |
+
if (queryModel.includes('llama')) {
|
616 |
+
const handler = createSentencepieceEncodingHandler(spp_llama);
|
617 |
+
return handler(req, res);
|
618 |
+
}
|
619 |
+
|
620 |
+
if (queryModel.includes('mistral')) {
|
621 |
+
const handler = createSentencepieceEncodingHandler(spp_mistral);
|
622 |
+
return handler(req, res);
|
623 |
+
}
|
624 |
+
|
625 |
+
if (queryModel.includes('yi')) {
|
626 |
+
const handler = createSentencepieceEncodingHandler(spp_yi);
|
627 |
+
return handler(req, res);
|
628 |
+
}
|
629 |
+
|
630 |
+
if (queryModel.includes('claude')) {
|
631 |
+
const handler = createWebTokenizerEncodingHandler(claude_tokenizer);
|
632 |
+
return handler(req, res);
|
633 |
+
}
|
634 |
+
|
635 |
+
if (queryModel.includes('gemma') || queryModel.includes('gemini')) {
|
636 |
+
const handler = createSentencepieceEncodingHandler(spp_gemma);
|
637 |
+
return handler(req, res);
|
638 |
+
}
|
639 |
+
|
640 |
+
const model = getTokenizerModel(queryModel);
|
641 |
+
const handler = createTiktokenEncodingHandler(model);
|
642 |
+
return handler(req, res);
|
643 |
+
} catch (error) {
|
644 |
+
console.log(error);
|
645 |
+
return res.send({ ids: [], count: 0, chunks: [] });
|
646 |
+
}
|
647 |
+
});
|
648 |
+
|
649 |
+
router.post('/openai/decode', jsonParser, async function (req, res) {
|
650 |
+
try {
|
651 |
+
const queryModel = String(req.query.model || '');
|
652 |
+
|
653 |
+
if (queryModel.includes('llama3') || queryModel.includes('llama-3')) {
|
654 |
+
const handler = createWebTokenizerDecodingHandler(llama3_tokenizer);
|
655 |
+
return handler(req, res);
|
656 |
+
}
|
657 |
+
|
658 |
+
if (queryModel.includes('llama')) {
|
659 |
+
const handler = createSentencepieceDecodingHandler(spp_llama);
|
660 |
+
return handler(req, res);
|
661 |
+
}
|
662 |
+
|
663 |
+
if (queryModel.includes('mistral')) {
|
664 |
+
const handler = createSentencepieceDecodingHandler(spp_mistral);
|
665 |
+
return handler(req, res);
|
666 |
+
}
|
667 |
+
|
668 |
+
if (queryModel.includes('yi')) {
|
669 |
+
const handler = createSentencepieceDecodingHandler(spp_yi);
|
670 |
+
return handler(req, res);
|
671 |
+
}
|
672 |
+
|
673 |
+
if (queryModel.includes('claude')) {
|
674 |
+
const handler = createWebTokenizerDecodingHandler(claude_tokenizer);
|
675 |
+
return handler(req, res);
|
676 |
+
}
|
677 |
+
|
678 |
+
if (queryModel.includes('gemma') || queryModel.includes('gemini')) {
|
679 |
+
const handler = createSentencepieceDecodingHandler(spp_gemma);
|
680 |
+
return handler(req, res);
|
681 |
+
}
|
682 |
+
|
683 |
+
const model = getTokenizerModel(queryModel);
|
684 |
+
const handler = createTiktokenDecodingHandler(model);
|
685 |
+
return handler(req, res);
|
686 |
+
} catch (error) {
|
687 |
+
console.log(error);
|
688 |
+
return res.send({ text: '' });
|
689 |
+
}
|
690 |
+
});
|
691 |
+
|
692 |
+
router.post('/openai/count', jsonParser, async function (req, res) {
|
693 |
+
try {
|
694 |
+
if (!req.body) return res.sendStatus(400);
|
695 |
+
|
696 |
+
let num_tokens = 0;
|
697 |
+
const queryModel = String(req.query.model || '');
|
698 |
+
const model = getTokenizerModel(queryModel);
|
699 |
+
|
700 |
+
if (model === 'claude') {
|
701 |
+
const instance = await claude_tokenizer.get();
|
702 |
+
if (!instance) throw new Error('Failed to load the Claude tokenizer');
|
703 |
+
num_tokens = countWebTokenizerTokens(instance, req.body);
|
704 |
+
return res.send({ 'token_count': num_tokens });
|
705 |
+
}
|
706 |
+
|
707 |
+
if (model === 'llama3' || model === 'llama-3') {
|
708 |
+
const instance = await llama3_tokenizer.get();
|
709 |
+
if (!instance) throw new Error('Failed to load the Llama3 tokenizer');
|
710 |
+
num_tokens = countWebTokenizerTokens(instance, req.body);
|
711 |
+
return res.send({ 'token_count': num_tokens });
|
712 |
+
}
|
713 |
+
|
714 |
+
if (model === 'llama') {
|
715 |
+
num_tokens = await countSentencepieceArrayTokens(spp_llama, req.body);
|
716 |
+
return res.send({ 'token_count': num_tokens });
|
717 |
+
}
|
718 |
+
|
719 |
+
if (model === 'mistral') {
|
720 |
+
num_tokens = await countSentencepieceArrayTokens(spp_mistral, req.body);
|
721 |
+
return res.send({ 'token_count': num_tokens });
|
722 |
+
}
|
723 |
+
|
724 |
+
if (model === 'yi') {
|
725 |
+
num_tokens = await countSentencepieceArrayTokens(spp_yi, req.body);
|
726 |
+
return res.send({ 'token_count': num_tokens });
|
727 |
+
}
|
728 |
+
|
729 |
+
if (model === 'gemma' || model === 'gemini') {
|
730 |
+
num_tokens = await countSentencepieceArrayTokens(spp_gemma, req.body);
|
731 |
+
return res.send({ 'token_count': num_tokens });
|
732 |
+
}
|
733 |
+
|
734 |
+
const tokensPerName = queryModel.includes('gpt-3.5-turbo-0301') ? -1 : 1;
|
735 |
+
const tokensPerMessage = queryModel.includes('gpt-3.5-turbo-0301') ? 4 : 3;
|
736 |
+
const tokensPadding = 3;
|
737 |
+
|
738 |
+
const tokenizer = getTiktokenTokenizer(model);
|
739 |
+
|
740 |
+
for (const msg of req.body) {
|
741 |
+
try {
|
742 |
+
num_tokens += tokensPerMessage;
|
743 |
+
for (const [key, value] of Object.entries(msg)) {
|
744 |
+
num_tokens += tokenizer.encode(value).length;
|
745 |
+
if (key == 'name') {
|
746 |
+
num_tokens += tokensPerName;
|
747 |
+
}
|
748 |
+
}
|
749 |
+
} catch {
|
750 |
+
console.warn('Error tokenizing message:', msg);
|
751 |
+
}
|
752 |
+
}
|
753 |
+
num_tokens += tokensPadding;
|
754 |
+
|
755 |
+
// NB: Since 2023-10-14, the GPT-3.5 Turbo 0301 model shoves in 7-9 extra tokens to every message.
|
756 |
+
// More details: https://community.openai.com/t/gpt-3-5-turbo-0301-showing-different-behavior-suddenly/431326/14
|
757 |
+
if (queryModel.includes('gpt-3.5-turbo-0301')) {
|
758 |
+
num_tokens += 9;
|
759 |
+
}
|
760 |
+
|
761 |
+
// not needed for cached tokenizers
|
762 |
+
//tokenizer.free();
|
763 |
+
|
764 |
+
res.send({ 'token_count': num_tokens });
|
765 |
+
} catch (error) {
|
766 |
+
console.error('An error counting tokens, using fallback estimation method', error);
|
767 |
+
const jsonBody = JSON.stringify(req.body);
|
768 |
+
const num_tokens = Math.ceil(jsonBody.length / CHARS_PER_TOKEN);
|
769 |
+
res.send({ 'token_count': num_tokens });
|
770 |
+
}
|
771 |
+
});
|
772 |
+
|
773 |
+
router.post('/remote/kobold/count', jsonParser, async function (request, response) {
|
774 |
+
if (!request.body) {
|
775 |
+
return response.sendStatus(400);
|
776 |
+
}
|
777 |
+
const text = String(request.body.text) || '';
|
778 |
+
const baseUrl = String(request.body.url);
|
779 |
+
|
780 |
+
try {
|
781 |
+
const args = {
|
782 |
+
method: 'POST',
|
783 |
+
body: JSON.stringify({ 'prompt': text }),
|
784 |
+
headers: { 'Content-Type': 'application/json' },
|
785 |
+
};
|
786 |
+
|
787 |
+
let url = String(baseUrl).replace(/\/$/, '');
|
788 |
+
url += '/extra/tokencount';
|
789 |
+
|
790 |
+
const result = await fetch(url, args);
|
791 |
+
|
792 |
+
if (!result.ok) {
|
793 |
+
console.log(`API returned error: ${result.status} ${result.statusText}`);
|
794 |
+
return response.send({ error: true });
|
795 |
+
}
|
796 |
+
|
797 |
+
const data = await result.json();
|
798 |
+
const count = data['value'];
|
799 |
+
const ids = data['ids'] ?? [];
|
800 |
+
return response.send({ count, ids });
|
801 |
+
} catch (error) {
|
802 |
+
console.log(error);
|
803 |
+
return response.send({ error: true });
|
804 |
+
}
|
805 |
+
});
|
806 |
+
|
807 |
+
router.post('/remote/textgenerationwebui/encode', jsonParser, async function (request, response) {
|
808 |
+
if (!request.body) {
|
809 |
+
return response.sendStatus(400);
|
810 |
+
}
|
811 |
+
const text = String(request.body.text) || '';
|
812 |
+
const baseUrl = String(request.body.url);
|
813 |
+
const legacyApi = Boolean(request.body.legacy_api);
|
814 |
+
const vllmModel = String(request.body.vllm_model) || '';
|
815 |
+
|
816 |
+
try {
|
817 |
+
const args = {
|
818 |
+
method: 'POST',
|
819 |
+
headers: { 'Content-Type': 'application/json' },
|
820 |
+
};
|
821 |
+
|
822 |
+
setAdditionalHeaders(request, args, baseUrl);
|
823 |
+
|
824 |
+
// Convert to string + remove trailing slash + /v1 suffix
|
825 |
+
let url = String(baseUrl).replace(/\/$/, '').replace(/\/v1$/, '');
|
826 |
+
|
827 |
+
if (legacyApi) {
|
828 |
+
url += '/v1/token-count';
|
829 |
+
args.body = JSON.stringify({ 'prompt': text });
|
830 |
+
} else {
|
831 |
+
switch (request.body.api_type) {
|
832 |
+
case TEXTGEN_TYPES.TABBY:
|
833 |
+
url += '/v1/token/encode';
|
834 |
+
args.body = JSON.stringify({ 'text': text });
|
835 |
+
break;
|
836 |
+
case TEXTGEN_TYPES.KOBOLDCPP:
|
837 |
+
url += '/api/extra/tokencount';
|
838 |
+
args.body = JSON.stringify({ 'prompt': text });
|
839 |
+
break;
|
840 |
+
case TEXTGEN_TYPES.LLAMACPP:
|
841 |
+
url += '/tokenize';
|
842 |
+
args.body = JSON.stringify({ 'content': text });
|
843 |
+
break;
|
844 |
+
case TEXTGEN_TYPES.VLLM:
|
845 |
+
url += '/tokenize';
|
846 |
+
args.body = JSON.stringify({ 'model': vllmModel, 'prompt': text });
|
847 |
+
break;
|
848 |
+
case TEXTGEN_TYPES.APHRODITE:
|
849 |
+
url += '/v1/tokenize';
|
850 |
+
args.body = JSON.stringify({ 'prompt': text });
|
851 |
+
break;
|
852 |
+
default:
|
853 |
+
url += '/v1/internal/encode';
|
854 |
+
args.body = JSON.stringify({ 'text': text });
|
855 |
+
break;
|
856 |
+
}
|
857 |
+
}
|
858 |
+
|
859 |
+
const result = await fetch(url, args);
|
860 |
+
|
861 |
+
if (!result.ok) {
|
862 |
+
console.log(`API returned error: ${result.status} ${result.statusText}`);
|
863 |
+
return response.send({ error: true });
|
864 |
+
}
|
865 |
+
|
866 |
+
const data = await result.json();
|
867 |
+
const count = legacyApi ? data?.results[0]?.tokens : (data?.length ?? data?.count ?? data?.value ?? data?.tokens?.length);
|
868 |
+
const ids = legacyApi ? [] : (data?.tokens ?? data?.ids ?? []);
|
869 |
+
|
870 |
+
return response.send({ count, ids });
|
871 |
+
} catch (error) {
|
872 |
+
console.log(error);
|
873 |
+
return response.send({ error: true });
|
874 |
+
}
|
875 |
+
});
|
876 |
+
|
877 |
+
module.exports = {
|
878 |
+
TEXT_COMPLETION_MODELS,
|
879 |
+
getTokenizerModel,
|
880 |
+
getTiktokenTokenizer,
|
881 |
+
countWebTokenizerTokens,
|
882 |
+
getSentencepiceTokenizer,
|
883 |
+
sentencepieceTokenizers,
|
884 |
+
router,
|
885 |
+
};
|
src/endpoints/translate.js
ADDED
@@ -0,0 +1,398 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const fetch = require('node-fetch').default;
|
2 |
+
const https = require('https');
|
3 |
+
const express = require('express');
|
4 |
+
const iconv = require('iconv-lite');
|
5 |
+
const { readSecret, SECRET_KEYS } = require('./secrets');
|
6 |
+
const { getConfigValue, uuidv4 } = require('../util');
|
7 |
+
const { jsonParser } = require('../express-common');
|
8 |
+
|
9 |
+
const DEEPLX_URL_DEFAULT = 'http://127.0.0.1:1188/translate';
|
10 |
+
const ONERING_URL_DEFAULT = 'http://127.0.0.1:4990/translate';
|
11 |
+
|
12 |
+
const router = express.Router();
|
13 |
+
|
14 |
+
router.post('/libre', jsonParser, async (request, response) => {
|
15 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.LIBRE);
|
16 |
+
const url = readSecret(request.user.directories, SECRET_KEYS.LIBRE_URL);
|
17 |
+
|
18 |
+
if (!url) {
|
19 |
+
console.log('LibreTranslate URL is not configured.');
|
20 |
+
return response.sendStatus(400);
|
21 |
+
}
|
22 |
+
|
23 |
+
if (request.body.lang === 'zh-CN') {
|
24 |
+
request.body.lang = 'zh';
|
25 |
+
}
|
26 |
+
|
27 |
+
if (request.body.lang === 'zh-TW') {
|
28 |
+
request.body.lang = 'zt';
|
29 |
+
}
|
30 |
+
|
31 |
+
const text = request.body.text;
|
32 |
+
const lang = request.body.lang;
|
33 |
+
|
34 |
+
if (!text || !lang) {
|
35 |
+
return response.sendStatus(400);
|
36 |
+
}
|
37 |
+
|
38 |
+
console.log('Input text: ' + text);
|
39 |
+
|
40 |
+
try {
|
41 |
+
const result = await fetch(url, {
|
42 |
+
method: 'POST',
|
43 |
+
body: JSON.stringify({
|
44 |
+
q: text,
|
45 |
+
source: 'auto',
|
46 |
+
target: lang,
|
47 |
+
format: 'text',
|
48 |
+
api_key: key,
|
49 |
+
}),
|
50 |
+
headers: { 'Content-Type': 'application/json' },
|
51 |
+
});
|
52 |
+
|
53 |
+
if (!result.ok) {
|
54 |
+
const error = await result.text();
|
55 |
+
console.log('LibreTranslate error: ', result.statusText, error);
|
56 |
+
return response.sendStatus(result.status);
|
57 |
+
}
|
58 |
+
|
59 |
+
const json = await result.json();
|
60 |
+
console.log('Translated text: ' + json.translatedText);
|
61 |
+
|
62 |
+
return response.send(json.translatedText);
|
63 |
+
} catch (error) {
|
64 |
+
console.log('Translation error: ' + error.message);
|
65 |
+
return response.sendStatus(500);
|
66 |
+
}
|
67 |
+
});
|
68 |
+
|
69 |
+
router.post('/google', jsonParser, async (request, response) => {
|
70 |
+
try {
|
71 |
+
const { generateRequestUrl, normaliseResponse } = require('google-translate-api-browser');
|
72 |
+
const text = request.body.text;
|
73 |
+
const lang = request.body.lang;
|
74 |
+
|
75 |
+
if (!text || !lang) {
|
76 |
+
return response.sendStatus(400);
|
77 |
+
}
|
78 |
+
|
79 |
+
console.log('Input text: ' + text);
|
80 |
+
|
81 |
+
const url = generateRequestUrl(text, { to: lang });
|
82 |
+
|
83 |
+
https.get(url, (resp) => {
|
84 |
+
const data = [];
|
85 |
+
|
86 |
+
resp.on('data', (chunk) => {
|
87 |
+
data.push(chunk);
|
88 |
+
});
|
89 |
+
|
90 |
+
resp.on('end', () => {
|
91 |
+
try {
|
92 |
+
const decodedData = iconv.decode(Buffer.concat(data), 'utf-8');
|
93 |
+
const result = normaliseResponse(JSON.parse(decodedData));
|
94 |
+
console.log('Translated text: ' + result.text);
|
95 |
+
response.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
96 |
+
return response.send(result.text);
|
97 |
+
} catch (error) {
|
98 |
+
console.log('Translation error', error);
|
99 |
+
return response.sendStatus(500);
|
100 |
+
}
|
101 |
+
});
|
102 |
+
}).on('error', (err) => {
|
103 |
+
console.log('Translation error: ' + err.message);
|
104 |
+
return response.sendStatus(500);
|
105 |
+
});
|
106 |
+
} catch (error) {
|
107 |
+
console.log('Translation error', error);
|
108 |
+
return response.sendStatus(500);
|
109 |
+
}
|
110 |
+
});
|
111 |
+
|
112 |
+
router.post('/yandex', jsonParser, async (request, response) => {
|
113 |
+
const chunks = request.body.chunks;
|
114 |
+
const lang = request.body.lang;
|
115 |
+
|
116 |
+
if (!chunks || !lang) {
|
117 |
+
return response.sendStatus(400);
|
118 |
+
}
|
119 |
+
|
120 |
+
// reconstruct original text to log
|
121 |
+
let inputText = '';
|
122 |
+
|
123 |
+
const params = new URLSearchParams();
|
124 |
+
for (const chunk of chunks) {
|
125 |
+
params.append('text', chunk);
|
126 |
+
inputText += chunk;
|
127 |
+
}
|
128 |
+
params.append('lang', lang);
|
129 |
+
const ucid = uuidv4().replaceAll('-', '');
|
130 |
+
|
131 |
+
console.log('Input text: ' + inputText);
|
132 |
+
|
133 |
+
try {
|
134 |
+
const result = await fetch(`https://translate.yandex.net/api/v1/tr.json/translate?ucid=${ucid}&srv=android&format=text`, {
|
135 |
+
method: 'POST',
|
136 |
+
body: params,
|
137 |
+
headers: {
|
138 |
+
'Content-Type': 'application/x-www-form-urlencoded',
|
139 |
+
},
|
140 |
+
timeout: 0,
|
141 |
+
});
|
142 |
+
|
143 |
+
if (!result.ok) {
|
144 |
+
const error = await result.text();
|
145 |
+
console.log('Yandex error: ', result.statusText, error);
|
146 |
+
return response.sendStatus(500);
|
147 |
+
}
|
148 |
+
|
149 |
+
const json = await result.json();
|
150 |
+
const translated = json.text.join();
|
151 |
+
console.log('Translated text: ' + translated);
|
152 |
+
|
153 |
+
return response.send(translated);
|
154 |
+
} catch (error) {
|
155 |
+
console.log('Translation error: ' + error.message);
|
156 |
+
return response.sendStatus(500);
|
157 |
+
}
|
158 |
+
});
|
159 |
+
|
160 |
+
router.post('/lingva', jsonParser, async (request, response) => {
|
161 |
+
try {
|
162 |
+
const baseUrl = readSecret(request.user.directories, SECRET_KEYS.LINGVA_URL);
|
163 |
+
|
164 |
+
if (!baseUrl) {
|
165 |
+
console.log('Lingva URL is not configured.');
|
166 |
+
return response.sendStatus(400);
|
167 |
+
}
|
168 |
+
|
169 |
+
const text = request.body.text;
|
170 |
+
const lang = request.body.lang;
|
171 |
+
|
172 |
+
if (!text || !lang) {
|
173 |
+
return response.sendStatus(400);
|
174 |
+
}
|
175 |
+
|
176 |
+
console.log('Input text: ' + text);
|
177 |
+
const url = `${baseUrl}/auto/${lang}/${encodeURIComponent(text)}`;
|
178 |
+
|
179 |
+
https.get(url, (resp) => {
|
180 |
+
let data = '';
|
181 |
+
|
182 |
+
resp.on('data', (chunk) => {
|
183 |
+
data += chunk;
|
184 |
+
});
|
185 |
+
|
186 |
+
resp.on('end', () => {
|
187 |
+
try {
|
188 |
+
const result = JSON.parse(data);
|
189 |
+
console.log('Translated text: ' + result.translation);
|
190 |
+
return response.send(result.translation);
|
191 |
+
} catch (error) {
|
192 |
+
console.log('Translation error', error);
|
193 |
+
return response.sendStatus(500);
|
194 |
+
}
|
195 |
+
});
|
196 |
+
}).on('error', (err) => {
|
197 |
+
console.log('Translation error: ' + err.message);
|
198 |
+
return response.sendStatus(500);
|
199 |
+
});
|
200 |
+
} catch (error) {
|
201 |
+
console.log('Translation error', error);
|
202 |
+
return response.sendStatus(500);
|
203 |
+
}
|
204 |
+
});
|
205 |
+
|
206 |
+
router.post('/deepl', jsonParser, async (request, response) => {
|
207 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.DEEPL);
|
208 |
+
|
209 |
+
if (!key) {
|
210 |
+
console.log('DeepL key is not configured.');
|
211 |
+
return response.sendStatus(400);
|
212 |
+
}
|
213 |
+
|
214 |
+
if (request.body.lang === 'zh-CN' || request.body.lang === 'zh-TW') {
|
215 |
+
request.body.lang = 'ZH';
|
216 |
+
}
|
217 |
+
|
218 |
+
const text = request.body.text;
|
219 |
+
const lang = request.body.lang;
|
220 |
+
const formality = getConfigValue('deepl.formality', 'default');
|
221 |
+
|
222 |
+
if (!text || !lang) {
|
223 |
+
return response.sendStatus(400);
|
224 |
+
}
|
225 |
+
|
226 |
+
console.log('Input text: ' + text);
|
227 |
+
|
228 |
+
const params = new URLSearchParams();
|
229 |
+
params.append('text', text);
|
230 |
+
params.append('target_lang', lang);
|
231 |
+
|
232 |
+
if (['de', 'fr', 'it', 'es', 'nl', 'ja', 'ru'].includes(lang)) {
|
233 |
+
// We don't specify a Portuguese variant, so ignore formality for it.
|
234 |
+
params.append('formality', formality);
|
235 |
+
}
|
236 |
+
|
237 |
+
try {
|
238 |
+
const result = await fetch('https://api-free.deepl.com/v2/translate', {
|
239 |
+
method: 'POST',
|
240 |
+
body: params,
|
241 |
+
headers: {
|
242 |
+
'Accept': 'application/json',
|
243 |
+
'Authorization': `DeepL-Auth-Key ${key}`,
|
244 |
+
'Content-Type': 'application/x-www-form-urlencoded',
|
245 |
+
},
|
246 |
+
timeout: 0,
|
247 |
+
});
|
248 |
+
|
249 |
+
if (!result.ok) {
|
250 |
+
const error = await result.text();
|
251 |
+
console.log('DeepL error: ', result.statusText, error);
|
252 |
+
return response.sendStatus(result.status);
|
253 |
+
}
|
254 |
+
|
255 |
+
const json = await result.json();
|
256 |
+
console.log('Translated text: ' + json.translations[0].text);
|
257 |
+
|
258 |
+
return response.send(json.translations[0].text);
|
259 |
+
} catch (error) {
|
260 |
+
console.log('Translation error: ' + error.message);
|
261 |
+
return response.sendStatus(500);
|
262 |
+
}
|
263 |
+
});
|
264 |
+
|
265 |
+
router.post('/onering', jsonParser, async (request, response) => {
|
266 |
+
const secretUrl = readSecret(request.user.directories, SECRET_KEYS.ONERING_URL);
|
267 |
+
const url = secretUrl || ONERING_URL_DEFAULT;
|
268 |
+
|
269 |
+
if (!url) {
|
270 |
+
console.log('OneRing URL is not configured.');
|
271 |
+
return response.sendStatus(400);
|
272 |
+
}
|
273 |
+
|
274 |
+
if (!secretUrl && url === ONERING_URL_DEFAULT) {
|
275 |
+
console.log('OneRing URL is using default value.', ONERING_URL_DEFAULT);
|
276 |
+
}
|
277 |
+
|
278 |
+
const text = request.body.text;
|
279 |
+
const from_lang = request.body.from_lang;
|
280 |
+
const to_lang = request.body.to_lang;
|
281 |
+
|
282 |
+
if (!text || !from_lang || !to_lang) {
|
283 |
+
return response.sendStatus(400);
|
284 |
+
}
|
285 |
+
|
286 |
+
const params = new URLSearchParams();
|
287 |
+
params.append('text', text);
|
288 |
+
params.append('from_lang', from_lang);
|
289 |
+
params.append('to_lang', to_lang);
|
290 |
+
|
291 |
+
console.log('Input text: ' + text);
|
292 |
+
|
293 |
+
try {
|
294 |
+
const fetchUrl = new URL(url);
|
295 |
+
fetchUrl.search = params.toString();
|
296 |
+
|
297 |
+
const result = await fetch(fetchUrl, {
|
298 |
+
method: 'GET',
|
299 |
+
timeout: 0,
|
300 |
+
});
|
301 |
+
|
302 |
+
if (!result.ok) {
|
303 |
+
const error = await result.text();
|
304 |
+
console.log('OneRing error: ', result.statusText, error);
|
305 |
+
return response.sendStatus(result.status);
|
306 |
+
}
|
307 |
+
|
308 |
+
const data = await result.json();
|
309 |
+
console.log('Translated text: ' + data.result);
|
310 |
+
|
311 |
+
return response.send(data.result);
|
312 |
+
} catch (error) {
|
313 |
+
console.log('Translation error: ' + error.message);
|
314 |
+
return response.sendStatus(500);
|
315 |
+
}
|
316 |
+
});
|
317 |
+
|
318 |
+
router.post('/deeplx', jsonParser, async (request, response) => {
|
319 |
+
const secretUrl = readSecret(request.user.directories, SECRET_KEYS.DEEPLX_URL);
|
320 |
+
const url = secretUrl || DEEPLX_URL_DEFAULT;
|
321 |
+
|
322 |
+
if (!url) {
|
323 |
+
console.log('DeepLX URL is not configured.');
|
324 |
+
return response.sendStatus(400);
|
325 |
+
}
|
326 |
+
|
327 |
+
if (!secretUrl && url === DEEPLX_URL_DEFAULT) {
|
328 |
+
console.log('DeepLX URL is using default value.', DEEPLX_URL_DEFAULT);
|
329 |
+
}
|
330 |
+
|
331 |
+
const text = request.body.text;
|
332 |
+
let lang = request.body.lang;
|
333 |
+
if (request.body.lang === 'zh-CN' || request.body.lang === 'zh-TW') {
|
334 |
+
lang = 'ZH';
|
335 |
+
}
|
336 |
+
|
337 |
+
if (!text || !lang) {
|
338 |
+
return response.sendStatus(400);
|
339 |
+
}
|
340 |
+
|
341 |
+
console.log('Input text: ' + text);
|
342 |
+
|
343 |
+
try {
|
344 |
+
const result = await fetch(url, {
|
345 |
+
method: 'POST',
|
346 |
+
body: JSON.stringify({
|
347 |
+
text: text,
|
348 |
+
source_lang: 'auto',
|
349 |
+
target_lang: lang,
|
350 |
+
}),
|
351 |
+
headers: {
|
352 |
+
'Accept': 'application/json',
|
353 |
+
'Content-Type': 'application/json',
|
354 |
+
},
|
355 |
+
timeout: 0,
|
356 |
+
});
|
357 |
+
|
358 |
+
if (!result.ok) {
|
359 |
+
const error = await result.text();
|
360 |
+
console.log('DeepLX error: ', result.statusText, error);
|
361 |
+
return response.sendStatus(result.status);
|
362 |
+
}
|
363 |
+
|
364 |
+
const json = await result.json();
|
365 |
+
console.log('Translated text: ' + json.data);
|
366 |
+
|
367 |
+
return response.send(json.data);
|
368 |
+
} catch (error) {
|
369 |
+
console.log('DeepLX translation error: ' + error.message);
|
370 |
+
return response.sendStatus(500);
|
371 |
+
}
|
372 |
+
});
|
373 |
+
|
374 |
+
router.post('/bing', jsonParser, async (request, response) => {
|
375 |
+
const bingTranslateApi = require('bing-translate-api');
|
376 |
+
const text = request.body.text;
|
377 |
+
let lang = request.body.lang;
|
378 |
+
|
379 |
+
if (request.body.lang === 'zh-CN') {
|
380 |
+
lang = 'zh-Hans';
|
381 |
+
}
|
382 |
+
|
383 |
+
if (!text || !lang) {
|
384 |
+
return response.sendStatus(400);
|
385 |
+
}
|
386 |
+
|
387 |
+
console.log('Input text: ' + text);
|
388 |
+
|
389 |
+
bingTranslateApi.translate(text, null, lang).then(result => {
|
390 |
+
console.log('Translated text: ' + result.translation);
|
391 |
+
return response.send(result.translation);
|
392 |
+
}).catch(err => {
|
393 |
+
console.log('Translation error: ' + err.message);
|
394 |
+
return response.sendStatus(500);
|
395 |
+
});
|
396 |
+
});
|
397 |
+
|
398 |
+
module.exports = { router };
|
src/endpoints/users-admin.js
ADDED
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const fsPromises = require('fs').promises;
|
2 |
+
const storage = require('node-persist');
|
3 |
+
const express = require('express');
|
4 |
+
const lodash = require('lodash');
|
5 |
+
const { jsonParser } = require('../express-common');
|
6 |
+
const { checkForNewContent } = require('./content-manager');
|
7 |
+
const {
|
8 |
+
KEY_PREFIX,
|
9 |
+
toKey,
|
10 |
+
requireAdminMiddleware,
|
11 |
+
getUserAvatar,
|
12 |
+
getAllUserHandles,
|
13 |
+
getPasswordSalt,
|
14 |
+
getPasswordHash,
|
15 |
+
getUserDirectories,
|
16 |
+
ensurePublicDirectoriesExist,
|
17 |
+
} = require('../users');
|
18 |
+
const { DEFAULT_USER } = require('../constants');
|
19 |
+
|
20 |
+
const router = express.Router();
|
21 |
+
|
22 |
+
router.post('/get', requireAdminMiddleware, jsonParser, async (_request, response) => {
|
23 |
+
try {
|
24 |
+
/** @type {import('../users').User[]} */
|
25 |
+
const users = await storage.values(x => x.key.startsWith(KEY_PREFIX));
|
26 |
+
|
27 |
+
/** @type {Promise<import('../users').UserViewModel>[]} */
|
28 |
+
const viewModelPromises = users
|
29 |
+
.map(user => new Promise(resolve => {
|
30 |
+
getUserAvatar(user.handle).then(avatar =>
|
31 |
+
resolve({
|
32 |
+
handle: user.handle,
|
33 |
+
name: user.name,
|
34 |
+
avatar: avatar,
|
35 |
+
admin: user.admin,
|
36 |
+
enabled: user.enabled,
|
37 |
+
created: user.created,
|
38 |
+
password: !!user.password,
|
39 |
+
}),
|
40 |
+
);
|
41 |
+
}));
|
42 |
+
|
43 |
+
const viewModels = await Promise.all(viewModelPromises);
|
44 |
+
viewModels.sort((x, y) => (x.created ?? 0) - (y.created ?? 0));
|
45 |
+
return response.json(viewModels);
|
46 |
+
} catch (error) {
|
47 |
+
console.error('User list failed:', error);
|
48 |
+
return response.sendStatus(500);
|
49 |
+
}
|
50 |
+
});
|
51 |
+
|
52 |
+
router.post('/disable', requireAdminMiddleware, jsonParser, async (request, response) => {
|
53 |
+
try {
|
54 |
+
if (!request.body.handle) {
|
55 |
+
console.log('Disable user failed: Missing required fields');
|
56 |
+
return response.status(400).json({ error: 'Missing required fields' });
|
57 |
+
}
|
58 |
+
|
59 |
+
if (request.body.handle === request.user.profile.handle) {
|
60 |
+
console.log('Disable user failed: Cannot disable yourself');
|
61 |
+
return response.status(400).json({ error: 'Cannot disable yourself' });
|
62 |
+
}
|
63 |
+
|
64 |
+
/** @type {import('../users').User} */
|
65 |
+
const user = await storage.getItem(toKey(request.body.handle));
|
66 |
+
|
67 |
+
if (!user) {
|
68 |
+
console.log('Disable user failed: User not found');
|
69 |
+
return response.status(404).json({ error: 'User not found' });
|
70 |
+
}
|
71 |
+
|
72 |
+
user.enabled = false;
|
73 |
+
await storage.setItem(toKey(request.body.handle), user);
|
74 |
+
return response.sendStatus(204);
|
75 |
+
} catch (error) {
|
76 |
+
console.error('User disable failed:', error);
|
77 |
+
return response.sendStatus(500);
|
78 |
+
}
|
79 |
+
});
|
80 |
+
|
81 |
+
router.post('/enable', requireAdminMiddleware, jsonParser, async (request, response) => {
|
82 |
+
try {
|
83 |
+
if (!request.body.handle) {
|
84 |
+
console.log('Enable user failed: Missing required fields');
|
85 |
+
return response.status(400).json({ error: 'Missing required fields' });
|
86 |
+
}
|
87 |
+
|
88 |
+
/** @type {import('../users').User} */
|
89 |
+
const user = await storage.getItem(toKey(request.body.handle));
|
90 |
+
|
91 |
+
if (!user) {
|
92 |
+
console.log('Enable user failed: User not found');
|
93 |
+
return response.status(404).json({ error: 'User not found' });
|
94 |
+
}
|
95 |
+
|
96 |
+
user.enabled = true;
|
97 |
+
await storage.setItem(toKey(request.body.handle), user);
|
98 |
+
return response.sendStatus(204);
|
99 |
+
} catch (error) {
|
100 |
+
console.error('User enable failed:', error);
|
101 |
+
return response.sendStatus(500);
|
102 |
+
}
|
103 |
+
});
|
104 |
+
|
105 |
+
router.post('/promote', requireAdminMiddleware, jsonParser, async (request, response) => {
|
106 |
+
try {
|
107 |
+
if (!request.body.handle) {
|
108 |
+
console.log('Promote user failed: Missing required fields');
|
109 |
+
return response.status(400).json({ error: 'Missing required fields' });
|
110 |
+
}
|
111 |
+
|
112 |
+
/** @type {import('../users').User} */
|
113 |
+
const user = await storage.getItem(toKey(request.body.handle));
|
114 |
+
|
115 |
+
if (!user) {
|
116 |
+
console.log('Promote user failed: User not found');
|
117 |
+
return response.status(404).json({ error: 'User not found' });
|
118 |
+
}
|
119 |
+
|
120 |
+
user.admin = true;
|
121 |
+
await storage.setItem(toKey(request.body.handle), user);
|
122 |
+
return response.sendStatus(204);
|
123 |
+
} catch (error) {
|
124 |
+
console.error('User promote failed:', error);
|
125 |
+
return response.sendStatus(500);
|
126 |
+
}
|
127 |
+
});
|
128 |
+
|
129 |
+
router.post('/demote', requireAdminMiddleware, jsonParser, async (request, response) => {
|
130 |
+
try {
|
131 |
+
if (!request.body.handle) {
|
132 |
+
console.log('Demote user failed: Missing required fields');
|
133 |
+
return response.status(400).json({ error: 'Missing required fields' });
|
134 |
+
}
|
135 |
+
|
136 |
+
if (request.body.handle === request.user.profile.handle) {
|
137 |
+
console.log('Demote user failed: Cannot demote yourself');
|
138 |
+
return response.status(400).json({ error: 'Cannot demote yourself' });
|
139 |
+
}
|
140 |
+
|
141 |
+
/** @type {import('../users').User} */
|
142 |
+
const user = await storage.getItem(toKey(request.body.handle));
|
143 |
+
|
144 |
+
if (!user) {
|
145 |
+
console.log('Demote user failed: User not found');
|
146 |
+
return response.status(404).json({ error: 'User not found' });
|
147 |
+
}
|
148 |
+
|
149 |
+
user.admin = false;
|
150 |
+
await storage.setItem(toKey(request.body.handle), user);
|
151 |
+
return response.sendStatus(204);
|
152 |
+
} catch (error) {
|
153 |
+
console.error('User demote failed:', error);
|
154 |
+
return response.sendStatus(500);
|
155 |
+
}
|
156 |
+
});
|
157 |
+
|
158 |
+
router.post('/create', requireAdminMiddleware, jsonParser, async (request, response) => {
|
159 |
+
try {
|
160 |
+
if (!request.body.handle || !request.body.name) {
|
161 |
+
console.log('Create user failed: Missing required fields');
|
162 |
+
return response.status(400).json({ error: 'Missing required fields' });
|
163 |
+
}
|
164 |
+
|
165 |
+
const handles = await getAllUserHandles();
|
166 |
+
const handle = lodash.kebabCase(String(request.body.handle).toLowerCase().trim());
|
167 |
+
|
168 |
+
if (!handle) {
|
169 |
+
console.log('Create user failed: Invalid handle');
|
170 |
+
return response.status(400).json({ error: 'Invalid handle' });
|
171 |
+
}
|
172 |
+
|
173 |
+
if (handles.some(x => x === handle)) {
|
174 |
+
console.log('Create user failed: User with that handle already exists');
|
175 |
+
return response.status(409).json({ error: 'User already exists' });
|
176 |
+
}
|
177 |
+
|
178 |
+
const salt = getPasswordSalt();
|
179 |
+
const password = request.body.password ? getPasswordHash(request.body.password, salt) : '';
|
180 |
+
|
181 |
+
const newUser = {
|
182 |
+
handle: handle,
|
183 |
+
name: request.body.name || 'Anonymous',
|
184 |
+
created: Date.now(),
|
185 |
+
password: password,
|
186 |
+
salt: salt,
|
187 |
+
admin: !!request.body.admin,
|
188 |
+
enabled: true,
|
189 |
+
};
|
190 |
+
|
191 |
+
await storage.setItem(toKey(handle), newUser);
|
192 |
+
|
193 |
+
// Create user directories
|
194 |
+
console.log('Creating data directories for', newUser.handle);
|
195 |
+
await ensurePublicDirectoriesExist();
|
196 |
+
const directories = getUserDirectories(newUser.handle);
|
197 |
+
await checkForNewContent([directories]);
|
198 |
+
return response.json({ handle: newUser.handle });
|
199 |
+
} catch (error) {
|
200 |
+
console.error('User create failed:', error);
|
201 |
+
return response.sendStatus(500);
|
202 |
+
}
|
203 |
+
});
|
204 |
+
|
205 |
+
router.post('/delete', requireAdminMiddleware, jsonParser, async (request, response) => {
|
206 |
+
try {
|
207 |
+
if (!request.body.handle) {
|
208 |
+
console.log('Delete user failed: Missing required fields');
|
209 |
+
return response.status(400).json({ error: 'Missing required fields' });
|
210 |
+
}
|
211 |
+
|
212 |
+
if (request.body.handle === request.user.profile.handle) {
|
213 |
+
console.log('Delete user failed: Cannot delete yourself');
|
214 |
+
return response.status(400).json({ error: 'Cannot delete yourself' });
|
215 |
+
}
|
216 |
+
|
217 |
+
if (request.body.handle === DEFAULT_USER.handle) {
|
218 |
+
console.log('Delete user failed: Cannot delete default user');
|
219 |
+
return response.status(400).json({ error: 'Sorry, but the default user cannot be deleted. It is required as a fallback.' });
|
220 |
+
}
|
221 |
+
|
222 |
+
await storage.removeItem(toKey(request.body.handle));
|
223 |
+
|
224 |
+
if (request.body.purge) {
|
225 |
+
const directories = getUserDirectories(request.body.handle);
|
226 |
+
console.log('Deleting data directories for', request.body.handle);
|
227 |
+
await fsPromises.rm(directories.root, { recursive: true, force: true });
|
228 |
+
}
|
229 |
+
|
230 |
+
return response.sendStatus(204);
|
231 |
+
} catch (error) {
|
232 |
+
console.error('User delete failed:', error);
|
233 |
+
return response.sendStatus(500);
|
234 |
+
}
|
235 |
+
});
|
236 |
+
|
237 |
+
router.post('/slugify', requireAdminMiddleware, jsonParser, async (request, response) => {
|
238 |
+
try {
|
239 |
+
if (!request.body.text) {
|
240 |
+
console.log('Slugify failed: Missing required fields');
|
241 |
+
return response.status(400).json({ error: 'Missing required fields' });
|
242 |
+
}
|
243 |
+
|
244 |
+
const text = lodash.kebabCase(String(request.body.text).toLowerCase().trim());
|
245 |
+
|
246 |
+
return response.send(text);
|
247 |
+
} catch (error) {
|
248 |
+
console.error('Slugify failed:', error);
|
249 |
+
return response.sendStatus(500);
|
250 |
+
}
|
251 |
+
});
|
252 |
+
|
253 |
+
module.exports = {
|
254 |
+
router,
|
255 |
+
};
|
src/endpoints/users-private.js
ADDED
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const path = require('path');
|
2 |
+
const fsPromises = require('fs').promises;
|
3 |
+
const storage = require('node-persist');
|
4 |
+
const express = require('express');
|
5 |
+
const crypto = require('crypto');
|
6 |
+
const { jsonParser } = require('../express-common');
|
7 |
+
const { getUserAvatar, toKey, getPasswordHash, getPasswordSalt, createBackupArchive, ensurePublicDirectoriesExist, toAvatarKey } = require('../users');
|
8 |
+
const { SETTINGS_FILE } = require('../constants');
|
9 |
+
const contentManager = require('./content-manager');
|
10 |
+
const { color, Cache } = require('../util');
|
11 |
+
const { checkForNewContent } = require('./content-manager');
|
12 |
+
|
13 |
+
const RESET_CACHE = new Cache(5 * 60 * 1000);
|
14 |
+
|
15 |
+
const router = express.Router();
|
16 |
+
|
17 |
+
router.post('/logout', async (request, response) => {
|
18 |
+
try {
|
19 |
+
if (!request.session) {
|
20 |
+
console.error('Session not available');
|
21 |
+
return response.sendStatus(500);
|
22 |
+
}
|
23 |
+
|
24 |
+
request.session.handle = null;
|
25 |
+
return response.sendStatus(204);
|
26 |
+
} catch (error) {
|
27 |
+
console.error(error);
|
28 |
+
return response.sendStatus(500);
|
29 |
+
}
|
30 |
+
});
|
31 |
+
|
32 |
+
router.get('/me', async (request, response) => {
|
33 |
+
try {
|
34 |
+
if (!request.user) {
|
35 |
+
return response.sendStatus(403);
|
36 |
+
}
|
37 |
+
|
38 |
+
const user = request.user.profile;
|
39 |
+
const viewModel = {
|
40 |
+
handle: user.handle,
|
41 |
+
name: user.name,
|
42 |
+
avatar: await getUserAvatar(user.handle),
|
43 |
+
admin: user.admin,
|
44 |
+
password: !!user.password,
|
45 |
+
created: user.created,
|
46 |
+
};
|
47 |
+
|
48 |
+
return response.json(viewModel);
|
49 |
+
} catch (error) {
|
50 |
+
console.error(error);
|
51 |
+
return response.sendStatus(500);
|
52 |
+
}
|
53 |
+
});
|
54 |
+
|
55 |
+
router.post('/change-avatar', jsonParser, async (request, response) => {
|
56 |
+
try {
|
57 |
+
if (!request.body.handle) {
|
58 |
+
console.log('Change avatar failed: Missing required fields');
|
59 |
+
return response.status(400).json({ error: 'Missing required fields' });
|
60 |
+
}
|
61 |
+
|
62 |
+
if (request.body.handle !== request.user.profile.handle && !request.user.profile.admin) {
|
63 |
+
console.log('Change avatar failed: Unauthorized');
|
64 |
+
return response.status(403).json({ error: 'Unauthorized' });
|
65 |
+
}
|
66 |
+
|
67 |
+
// Avatar is not a data URL or not an empty string
|
68 |
+
if (!request.body.avatar.startsWith('data:image/') && request.body.avatar !== '') {
|
69 |
+
console.log('Change avatar failed: Invalid data URL');
|
70 |
+
return response.status(400).json({ error: 'Invalid data URL' });
|
71 |
+
}
|
72 |
+
|
73 |
+
/** @type {import('../users').User} */
|
74 |
+
const user = await storage.getItem(toKey(request.body.handle));
|
75 |
+
|
76 |
+
if (!user) {
|
77 |
+
console.log('Change avatar failed: User not found');
|
78 |
+
return response.status(404).json({ error: 'User not found' });
|
79 |
+
}
|
80 |
+
|
81 |
+
await storage.setItem(toAvatarKey(request.body.handle), request.body.avatar);
|
82 |
+
|
83 |
+
return response.sendStatus(204);
|
84 |
+
} catch (error) {
|
85 |
+
console.error(error);
|
86 |
+
return response.sendStatus(500);
|
87 |
+
}
|
88 |
+
});
|
89 |
+
|
90 |
+
router.post('/change-password', jsonParser, async (request, response) => {
|
91 |
+
try {
|
92 |
+
if (!request.body.handle) {
|
93 |
+
console.log('Change password failed: Missing required fields');
|
94 |
+
return response.status(400).json({ error: 'Missing required fields' });
|
95 |
+
}
|
96 |
+
|
97 |
+
if (request.body.handle !== request.user.profile.handle && !request.user.profile.admin) {
|
98 |
+
console.log('Change password failed: Unauthorized');
|
99 |
+
return response.status(403).json({ error: 'Unauthorized' });
|
100 |
+
}
|
101 |
+
|
102 |
+
/** @type {import('../users').User} */
|
103 |
+
const user = await storage.getItem(toKey(request.body.handle));
|
104 |
+
|
105 |
+
if (!user) {
|
106 |
+
console.log('Change password failed: User not found');
|
107 |
+
return response.status(404).json({ error: 'User not found' });
|
108 |
+
}
|
109 |
+
|
110 |
+
if (!user.enabled) {
|
111 |
+
console.log('Change password failed: User is disabled');
|
112 |
+
return response.status(403).json({ error: 'User is disabled' });
|
113 |
+
}
|
114 |
+
|
115 |
+
if (!request.user.profile.admin && user.password && user.password !== getPasswordHash(request.body.oldPassword, user.salt)) {
|
116 |
+
console.log('Change password failed: Incorrect password');
|
117 |
+
return response.status(403).json({ error: 'Incorrect password' });
|
118 |
+
}
|
119 |
+
|
120 |
+
if (request.body.newPassword) {
|
121 |
+
const salt = getPasswordSalt();
|
122 |
+
user.password = getPasswordHash(request.body.newPassword, salt);
|
123 |
+
user.salt = salt;
|
124 |
+
} else {
|
125 |
+
user.password = '';
|
126 |
+
user.salt = '';
|
127 |
+
}
|
128 |
+
|
129 |
+
await storage.setItem(toKey(request.body.handle), user);
|
130 |
+
return response.sendStatus(204);
|
131 |
+
} catch (error) {
|
132 |
+
console.error(error);
|
133 |
+
return response.sendStatus(500);
|
134 |
+
}
|
135 |
+
});
|
136 |
+
|
137 |
+
router.post('/backup', jsonParser, async (request, response) => {
|
138 |
+
try {
|
139 |
+
const handle = request.body.handle;
|
140 |
+
|
141 |
+
if (!handle) {
|
142 |
+
console.log('Backup failed: Missing required fields');
|
143 |
+
return response.status(400).json({ error: 'Missing required fields' });
|
144 |
+
}
|
145 |
+
|
146 |
+
if (handle !== request.user.profile.handle && !request.user.profile.admin) {
|
147 |
+
console.log('Backup failed: Unauthorized');
|
148 |
+
return response.status(403).json({ error: 'Unauthorized' });
|
149 |
+
}
|
150 |
+
|
151 |
+
await createBackupArchive(handle, response);
|
152 |
+
} catch (error) {
|
153 |
+
console.error('Backup failed', error);
|
154 |
+
return response.sendStatus(500);
|
155 |
+
}
|
156 |
+
});
|
157 |
+
|
158 |
+
router.post('/reset-settings', jsonParser, async (request, response) => {
|
159 |
+
try {
|
160 |
+
const password = request.body.password;
|
161 |
+
|
162 |
+
if (request.user.profile.password && request.user.profile.password !== getPasswordHash(password, request.user.profile.salt)) {
|
163 |
+
console.log('Reset settings failed: Incorrect password');
|
164 |
+
return response.status(403).json({ error: 'Incorrect password' });
|
165 |
+
}
|
166 |
+
|
167 |
+
const pathToFile = path.join(request.user.directories.root, SETTINGS_FILE);
|
168 |
+
await fsPromises.rm(pathToFile, { force: true });
|
169 |
+
await contentManager.checkForNewContent([request.user.directories], [contentManager.CONTENT_TYPES.SETTINGS]);
|
170 |
+
|
171 |
+
return response.sendStatus(204);
|
172 |
+
} catch (error) {
|
173 |
+
console.error('Reset settings failed', error);
|
174 |
+
return response.sendStatus(500);
|
175 |
+
}
|
176 |
+
});
|
177 |
+
|
178 |
+
router.post('/change-name', jsonParser, async (request, response) => {
|
179 |
+
try {
|
180 |
+
if (!request.body.name || !request.body.handle) {
|
181 |
+
console.log('Change name failed: Missing required fields');
|
182 |
+
return response.status(400).json({ error: 'Missing required fields' });
|
183 |
+
}
|
184 |
+
|
185 |
+
if (request.body.handle !== request.user.profile.handle && !request.user.profile.admin) {
|
186 |
+
console.log('Change name failed: Unauthorized');
|
187 |
+
return response.status(403).json({ error: 'Unauthorized' });
|
188 |
+
}
|
189 |
+
|
190 |
+
/** @type {import('../users').User} */
|
191 |
+
const user = await storage.getItem(toKey(request.body.handle));
|
192 |
+
|
193 |
+
if (!user) {
|
194 |
+
console.log('Change name failed: User not found');
|
195 |
+
return response.status(404).json({ error: 'User not found' });
|
196 |
+
}
|
197 |
+
|
198 |
+
user.name = request.body.name;
|
199 |
+
await storage.setItem(toKey(request.body.handle), user);
|
200 |
+
|
201 |
+
return response.sendStatus(204);
|
202 |
+
} catch (error) {
|
203 |
+
console.error('Change name failed', error);
|
204 |
+
return response.sendStatus(500);
|
205 |
+
}
|
206 |
+
});
|
207 |
+
|
208 |
+
router.post('/reset-step1', jsonParser, async (request, response) => {
|
209 |
+
try {
|
210 |
+
const resetCode = String(crypto.randomInt(1000, 9999));
|
211 |
+
console.log();
|
212 |
+
console.log(color.magenta(`${request.user.profile.name}, your account reset code is: `) + color.red(resetCode));
|
213 |
+
console.log();
|
214 |
+
RESET_CACHE.set(request.user.profile.handle, resetCode);
|
215 |
+
return response.sendStatus(204);
|
216 |
+
} catch (error) {
|
217 |
+
console.error('Recover step 1 failed:', error);
|
218 |
+
return response.sendStatus(500);
|
219 |
+
}
|
220 |
+
});
|
221 |
+
|
222 |
+
router.post('/reset-step2', jsonParser, async (request, response) => {
|
223 |
+
try {
|
224 |
+
if (!request.body.code) {
|
225 |
+
console.log('Recover step 2 failed: Missing required fields');
|
226 |
+
return response.status(400).json({ error: 'Missing required fields' });
|
227 |
+
}
|
228 |
+
|
229 |
+
if (request.user.profile.password && request.user.profile.password !== getPasswordHash(request.body.password, request.user.profile.salt)) {
|
230 |
+
console.log('Recover step 2 failed: Incorrect password');
|
231 |
+
return response.status(400).json({ error: 'Incorrect password' });
|
232 |
+
}
|
233 |
+
|
234 |
+
const code = RESET_CACHE.get(request.user.profile.handle);
|
235 |
+
|
236 |
+
if (!code || code !== request.body.code) {
|
237 |
+
console.log('Recover step 2 failed: Incorrect code');
|
238 |
+
return response.status(400).json({ error: 'Incorrect code' });
|
239 |
+
}
|
240 |
+
|
241 |
+
console.log('Resetting account data:', request.user.profile.handle);
|
242 |
+
await fsPromises.rm(request.user.directories.root, { recursive: true, force: true });
|
243 |
+
|
244 |
+
await ensurePublicDirectoriesExist();
|
245 |
+
await checkForNewContent([request.user.directories]);
|
246 |
+
|
247 |
+
RESET_CACHE.remove(request.user.profile.handle);
|
248 |
+
return response.sendStatus(204);
|
249 |
+
} catch (error) {
|
250 |
+
console.error('Recover step 2 failed:', error);
|
251 |
+
return response.sendStatus(500);
|
252 |
+
}
|
253 |
+
});
|
254 |
+
|
255 |
+
module.exports = {
|
256 |
+
router,
|
257 |
+
};
|
src/endpoints/users-public.js
ADDED
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const crypto = require('crypto');
|
2 |
+
const storage = require('node-persist');
|
3 |
+
const express = require('express');
|
4 |
+
const { RateLimiterMemory, RateLimiterRes } = require('rate-limiter-flexible');
|
5 |
+
const { jsonParser, getIpFromRequest } = require('../express-common');
|
6 |
+
const { color, Cache, getConfigValue } = require('../util');
|
7 |
+
const { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt } = require('../users');
|
8 |
+
|
9 |
+
const DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false);
|
10 |
+
const MFA_CACHE = new Cache(5 * 60 * 1000);
|
11 |
+
|
12 |
+
const router = express.Router();
|
13 |
+
const loginLimiter = new RateLimiterMemory({
|
14 |
+
points: 5,
|
15 |
+
duration: 60,
|
16 |
+
});
|
17 |
+
const recoverLimiter = new RateLimiterMemory({
|
18 |
+
points: 5,
|
19 |
+
duration: 300,
|
20 |
+
});
|
21 |
+
|
22 |
+
router.post('/list', async (_request, response) => {
|
23 |
+
try {
|
24 |
+
if (DISCREET_LOGIN) {
|
25 |
+
return response.sendStatus(204);
|
26 |
+
}
|
27 |
+
|
28 |
+
/** @type {import('../users').User[]} */
|
29 |
+
const users = await storage.values(x => x.key.startsWith(KEY_PREFIX));
|
30 |
+
|
31 |
+
/** @type {Promise<import('../users').UserViewModel>[]} */
|
32 |
+
const viewModelPromises = users
|
33 |
+
.filter(x => x.enabled)
|
34 |
+
.map(user => new Promise(async (resolve) => {
|
35 |
+
getUserAvatar(user.handle).then(avatar =>
|
36 |
+
resolve({
|
37 |
+
handle: user.handle,
|
38 |
+
name: user.name,
|
39 |
+
created: user.created,
|
40 |
+
avatar: avatar,
|
41 |
+
password: !!user.password,
|
42 |
+
}),
|
43 |
+
);
|
44 |
+
}));
|
45 |
+
|
46 |
+
const viewModels = await Promise.all(viewModelPromises);
|
47 |
+
viewModels.sort((x, y) => (x.created ?? 0) - (y.created ?? 0));
|
48 |
+
return response.json(viewModels);
|
49 |
+
} catch (error) {
|
50 |
+
console.error('User list failed:', error);
|
51 |
+
return response.sendStatus(500);
|
52 |
+
}
|
53 |
+
});
|
54 |
+
|
55 |
+
router.post('/login', jsonParser, async (request, response) => {
|
56 |
+
try {
|
57 |
+
if (!request.body.handle) {
|
58 |
+
console.log('Login failed: Missing required fields');
|
59 |
+
return response.status(400).json({ error: 'Missing required fields' });
|
60 |
+
}
|
61 |
+
|
62 |
+
const ip = getIpFromRequest(request);
|
63 |
+
await loginLimiter.consume(ip);
|
64 |
+
|
65 |
+
/** @type {import('../users').User} */
|
66 |
+
const user = await storage.getItem(toKey(request.body.handle));
|
67 |
+
|
68 |
+
if (!user) {
|
69 |
+
console.log('Login failed: User not found');
|
70 |
+
return response.status(403).json({ error: 'Incorrect credentials' });
|
71 |
+
}
|
72 |
+
|
73 |
+
if (!user.enabled) {
|
74 |
+
console.log('Login failed: User is disabled');
|
75 |
+
return response.status(403).json({ error: 'User is disabled' });
|
76 |
+
}
|
77 |
+
|
78 |
+
if (user.password && user.password !== getPasswordHash(request.body.password, user.salt)) {
|
79 |
+
console.log('Login failed: Incorrect password');
|
80 |
+
return response.status(403).json({ error: 'Incorrect credentials' });
|
81 |
+
}
|
82 |
+
|
83 |
+
if (!request.session) {
|
84 |
+
console.error('Session not available');
|
85 |
+
return response.sendStatus(500);
|
86 |
+
}
|
87 |
+
|
88 |
+
await loginLimiter.delete(ip);
|
89 |
+
request.session.handle = user.handle;
|
90 |
+
console.log('Login successful:', user.handle, request.session);
|
91 |
+
return response.json({ handle: user.handle });
|
92 |
+
} catch (error) {
|
93 |
+
if (error instanceof RateLimiterRes) {
|
94 |
+
console.log('Login failed: Rate limited from', getIpFromRequest(request));
|
95 |
+
return response.status(429).send({ error: 'Too many attempts. Try again later or recover your password.' });
|
96 |
+
}
|
97 |
+
|
98 |
+
console.error('Login failed:', error);
|
99 |
+
return response.sendStatus(500);
|
100 |
+
}
|
101 |
+
});
|
102 |
+
|
103 |
+
router.post('/recover-step1', jsonParser, async (request, response) => {
|
104 |
+
try {
|
105 |
+
if (!request.body.handle) {
|
106 |
+
console.log('Recover step 1 failed: Missing required fields');
|
107 |
+
return response.status(400).json({ error: 'Missing required fields' });
|
108 |
+
}
|
109 |
+
|
110 |
+
const ip = getIpFromRequest(request);
|
111 |
+
await recoverLimiter.consume(ip);
|
112 |
+
|
113 |
+
/** @type {import('../users').User} */
|
114 |
+
const user = await storage.getItem(toKey(request.body.handle));
|
115 |
+
|
116 |
+
if (!user) {
|
117 |
+
console.log('Recover step 1 failed: User not found');
|
118 |
+
return response.status(404).json({ error: 'User not found' });
|
119 |
+
}
|
120 |
+
|
121 |
+
if (!user.enabled) {
|
122 |
+
console.log('Recover step 1 failed: User is disabled');
|
123 |
+
return response.status(403).json({ error: 'User is disabled' });
|
124 |
+
}
|
125 |
+
|
126 |
+
const mfaCode = String(crypto.randomInt(1000, 9999));
|
127 |
+
console.log();
|
128 |
+
console.log(color.blue(`${user.name}, your password recovery code is: `) + color.magenta(mfaCode));
|
129 |
+
console.log();
|
130 |
+
MFA_CACHE.set(user.handle, mfaCode);
|
131 |
+
return response.sendStatus(204);
|
132 |
+
} catch (error) {
|
133 |
+
if (error instanceof RateLimiterRes) {
|
134 |
+
console.log('Recover step 1 failed: Rate limited from', getIpFromRequest(request));
|
135 |
+
return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });
|
136 |
+
}
|
137 |
+
|
138 |
+
console.error('Recover step 1 failed:', error);
|
139 |
+
return response.sendStatus(500);
|
140 |
+
}
|
141 |
+
});
|
142 |
+
|
143 |
+
router.post('/recover-step2', jsonParser, async (request, response) => {
|
144 |
+
try {
|
145 |
+
if (!request.body.handle || !request.body.code) {
|
146 |
+
console.log('Recover step 2 failed: Missing required fields');
|
147 |
+
return response.status(400).json({ error: 'Missing required fields' });
|
148 |
+
}
|
149 |
+
|
150 |
+
/** @type {import('../users').User} */
|
151 |
+
const user = await storage.getItem(toKey(request.body.handle));
|
152 |
+
const ip = getIpFromRequest(request);
|
153 |
+
|
154 |
+
if (!user) {
|
155 |
+
console.log('Recover step 2 failed: User not found');
|
156 |
+
return response.status(404).json({ error: 'User not found' });
|
157 |
+
}
|
158 |
+
|
159 |
+
if (!user.enabled) {
|
160 |
+
console.log('Recover step 2 failed: User is disabled');
|
161 |
+
return response.status(403).json({ error: 'User is disabled' });
|
162 |
+
}
|
163 |
+
|
164 |
+
const mfaCode = MFA_CACHE.get(user.handle);
|
165 |
+
|
166 |
+
if (request.body.code !== mfaCode) {
|
167 |
+
await recoverLimiter.consume(ip);
|
168 |
+
console.log('Recover step 2 failed: Incorrect code');
|
169 |
+
return response.status(403).json({ error: 'Incorrect code' });
|
170 |
+
}
|
171 |
+
|
172 |
+
if (request.body.newPassword) {
|
173 |
+
const salt = getPasswordSalt();
|
174 |
+
user.password = getPasswordHash(request.body.newPassword, salt);
|
175 |
+
user.salt = salt;
|
176 |
+
await storage.setItem(toKey(user.handle), user);
|
177 |
+
} else {
|
178 |
+
user.password = '';
|
179 |
+
user.salt = '';
|
180 |
+
await storage.setItem(toKey(user.handle), user);
|
181 |
+
}
|
182 |
+
|
183 |
+
await recoverLimiter.delete(ip);
|
184 |
+
MFA_CACHE.remove(user.handle);
|
185 |
+
return response.sendStatus(204);
|
186 |
+
} catch (error) {
|
187 |
+
if (error instanceof RateLimiterRes) {
|
188 |
+
console.log('Recover step 2 failed: Rate limited from', getIpFromRequest(request));
|
189 |
+
return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });
|
190 |
+
}
|
191 |
+
|
192 |
+
console.error('Recover step 2 failed:', error);
|
193 |
+
return response.sendStatus(500);
|
194 |
+
}
|
195 |
+
});
|
196 |
+
|
197 |
+
module.exports = {
|
198 |
+
router,
|
199 |
+
};
|
src/endpoints/vectors.js
ADDED
@@ -0,0 +1,491 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const vectra = require('vectra');
|
2 |
+
const path = require('path');
|
3 |
+
const fs = require('fs');
|
4 |
+
const express = require('express');
|
5 |
+
const sanitize = require('sanitize-filename');
|
6 |
+
const { jsonParser } = require('../express-common');
|
7 |
+
|
8 |
+
// Don't forget to add new sources to the SOURCES array
|
9 |
+
const SOURCES = [
|
10 |
+
'transformers',
|
11 |
+
'mistral',
|
12 |
+
'openai',
|
13 |
+
'extras',
|
14 |
+
'palm',
|
15 |
+
'togetherai',
|
16 |
+
'nomicai',
|
17 |
+
'cohere',
|
18 |
+
'ollama',
|
19 |
+
'llamacpp',
|
20 |
+
'vllm',
|
21 |
+
];
|
22 |
+
|
23 |
+
/**
|
24 |
+
* Gets the vector for the given text from the given source.
|
25 |
+
* @param {string} source - The source of the vector
|
26 |
+
* @param {Object} sourceSettings - Settings for the source, if it needs any
|
27 |
+
* @param {string} text - The text to get the vector for
|
28 |
+
* @param {boolean} isQuery - If the text is a query for embedding search
|
29 |
+
* @param {import('../users').UserDirectoryList} directories - The directories object for the user
|
30 |
+
* @returns {Promise<number[]>} - The vector for the text
|
31 |
+
*/
|
32 |
+
async function getVector(source, sourceSettings, text, isQuery, directories) {
|
33 |
+
switch (source) {
|
34 |
+
case 'nomicai':
|
35 |
+
return require('../vectors/nomicai-vectors').getNomicAIVector(text, source, directories);
|
36 |
+
case 'togetherai':
|
37 |
+
case 'mistral':
|
38 |
+
case 'openai':
|
39 |
+
return require('../vectors/openai-vectors').getOpenAIVector(text, source, directories, sourceSettings.model);
|
40 |
+
case 'transformers':
|
41 |
+
return require('../vectors/embedding').getTransformersVector(text);
|
42 |
+
case 'extras':
|
43 |
+
return require('../vectors/extras-vectors').getExtrasVector(text, sourceSettings.extrasUrl, sourceSettings.extrasKey);
|
44 |
+
case 'palm':
|
45 |
+
return require('../vectors/makersuite-vectors').getMakerSuiteVector(text, directories);
|
46 |
+
case 'cohere':
|
47 |
+
return require('../vectors/cohere-vectors').getCohereVector(text, isQuery, directories, sourceSettings.model);
|
48 |
+
case 'llamacpp':
|
49 |
+
return require('../vectors/llamacpp-vectors').getLlamaCppVector(text, sourceSettings.apiUrl, directories);
|
50 |
+
case 'vllm':
|
51 |
+
return require('../vectors/vllm-vectors').getVllmVector(text, sourceSettings.apiUrl, sourceSettings.model, directories);
|
52 |
+
case 'ollama':
|
53 |
+
return require('../vectors/ollama-vectors').getOllamaVector(text, sourceSettings.apiUrl, sourceSettings.model, sourceSettings.keep, directories);
|
54 |
+
}
|
55 |
+
|
56 |
+
throw new Error(`Unknown vector source ${source}`);
|
57 |
+
}
|
58 |
+
|
59 |
+
/**
|
60 |
+
* Gets the vector for the given text batch from the given source.
|
61 |
+
* @param {string} source - The source of the vector
|
62 |
+
* @param {Object} sourceSettings - Settings for the source, if it needs any
|
63 |
+
* @param {string[]} texts - The array of texts to get the vector for
|
64 |
+
* @param {boolean} isQuery - If the text is a query for embedding search
|
65 |
+
* @param {import('../users').UserDirectoryList} directories - The directories object for the user
|
66 |
+
* @returns {Promise<number[][]>} - The array of vectors for the texts
|
67 |
+
*/
|
68 |
+
async function getBatchVector(source, sourceSettings, texts, isQuery, directories) {
|
69 |
+
const batchSize = 10;
|
70 |
+
const batches = Array(Math.ceil(texts.length / batchSize)).fill(undefined).map((_, i) => texts.slice(i * batchSize, i * batchSize + batchSize));
|
71 |
+
|
72 |
+
let results = [];
|
73 |
+
for (let batch of batches) {
|
74 |
+
switch (source) {
|
75 |
+
case 'nomicai':
|
76 |
+
results.push(...await require('../vectors/nomicai-vectors').getNomicAIBatchVector(batch, source, directories));
|
77 |
+
break;
|
78 |
+
case 'togetherai':
|
79 |
+
case 'mistral':
|
80 |
+
case 'openai':
|
81 |
+
results.push(...await require('../vectors/openai-vectors').getOpenAIBatchVector(batch, source, directories, sourceSettings.model));
|
82 |
+
break;
|
83 |
+
case 'transformers':
|
84 |
+
results.push(...await require('../vectors/embedding').getTransformersBatchVector(batch));
|
85 |
+
break;
|
86 |
+
case 'extras':
|
87 |
+
results.push(...await require('../vectors/extras-vectors').getExtrasBatchVector(batch, sourceSettings.extrasUrl, sourceSettings.extrasKey));
|
88 |
+
break;
|
89 |
+
case 'palm':
|
90 |
+
results.push(...await require('../vectors/makersuite-vectors').getMakerSuiteBatchVector(batch, directories));
|
91 |
+
break;
|
92 |
+
case 'cohere':
|
93 |
+
results.push(...await require('../vectors/cohere-vectors').getCohereBatchVector(batch, isQuery, directories, sourceSettings.model));
|
94 |
+
break;
|
95 |
+
case 'llamacpp':
|
96 |
+
results.push(...await require('../vectors/llamacpp-vectors').getLlamaCppBatchVector(batch, sourceSettings.apiUrl, directories));
|
97 |
+
break;
|
98 |
+
case 'vllm':
|
99 |
+
results.push(...await require('../vectors/vllm-vectors').getVllmBatchVector(batch, sourceSettings.apiUrl, sourceSettings.model, directories));
|
100 |
+
break;
|
101 |
+
case 'ollama':
|
102 |
+
results.push(...await require('../vectors/ollama-vectors').getOllamaBatchVector(batch, sourceSettings.apiUrl, sourceSettings.model, sourceSettings.keep, directories));
|
103 |
+
break;
|
104 |
+
default:
|
105 |
+
throw new Error(`Unknown vector source ${source}`);
|
106 |
+
}
|
107 |
+
}
|
108 |
+
|
109 |
+
return results;
|
110 |
+
}
|
111 |
+
|
112 |
+
/**
|
113 |
+
* Gets the index for the vector collection
|
114 |
+
* @param {import('../users').UserDirectoryList} directories - User directories
|
115 |
+
* @param {string} collectionId - The collection ID
|
116 |
+
* @param {string} source - The source of the vector
|
117 |
+
* @param {boolean} create - Whether to create the index if it doesn't exist
|
118 |
+
* @returns {Promise<vectra.LocalIndex>} - The index for the collection
|
119 |
+
*/
|
120 |
+
async function getIndex(directories, collectionId, source, create = true) {
|
121 |
+
const pathToFile = path.join(directories.vectors, sanitize(source), sanitize(collectionId));
|
122 |
+
const store = new vectra.LocalIndex(pathToFile);
|
123 |
+
|
124 |
+
if (create && !await store.isIndexCreated()) {
|
125 |
+
await store.createIndex();
|
126 |
+
}
|
127 |
+
|
128 |
+
return store;
|
129 |
+
}
|
130 |
+
|
131 |
+
/**
|
132 |
+
* Inserts items into the vector collection
|
133 |
+
* @param {import('../users').UserDirectoryList} directories - User directories
|
134 |
+
* @param {string} collectionId - The collection ID
|
135 |
+
* @param {string} source - The source of the vector
|
136 |
+
* @param {Object} sourceSettings - Settings for the source, if it needs any
|
137 |
+
* @param {{ hash: number; text: string; index: number; }[]} items - The items to insert
|
138 |
+
*/
|
139 |
+
async function insertVectorItems(directories, collectionId, source, sourceSettings, items) {
|
140 |
+
const store = await getIndex(directories, collectionId, source);
|
141 |
+
|
142 |
+
await store.beginUpdate();
|
143 |
+
|
144 |
+
const vectors = await getBatchVector(source, sourceSettings, items.map(x => x.text), false, directories);
|
145 |
+
|
146 |
+
for (let i = 0; i < items.length; i++) {
|
147 |
+
const item = items[i];
|
148 |
+
const vector = vectors[i];
|
149 |
+
await store.upsertItem({ vector: vector, metadata: { hash: item.hash, text: item.text, index: item.index } });
|
150 |
+
}
|
151 |
+
|
152 |
+
await store.endUpdate();
|
153 |
+
}
|
154 |
+
|
155 |
+
/**
|
156 |
+
* Gets the hashes of the items in the vector collection
|
157 |
+
* @param {import('../users').UserDirectoryList} directories - User directories
|
158 |
+
* @param {string} collectionId - The collection ID
|
159 |
+
* @param {string} source - The source of the vector
|
160 |
+
* @returns {Promise<number[]>} - The hashes of the items in the collection
|
161 |
+
*/
|
162 |
+
async function getSavedHashes(directories, collectionId, source) {
|
163 |
+
const store = await getIndex(directories, collectionId, source);
|
164 |
+
|
165 |
+
const items = await store.listItems();
|
166 |
+
const hashes = items.map(x => Number(x.metadata.hash));
|
167 |
+
|
168 |
+
return hashes;
|
169 |
+
}
|
170 |
+
|
171 |
+
/**
|
172 |
+
* Deletes items from the vector collection by hash
|
173 |
+
* @param {import('../users').UserDirectoryList} directories - User directories
|
174 |
+
* @param {string} collectionId - The collection ID
|
175 |
+
* @param {string} source - The source of the vector
|
176 |
+
* @param {number[]} hashes - The hashes of the items to delete
|
177 |
+
*/
|
178 |
+
async function deleteVectorItems(directories, collectionId, source, hashes) {
|
179 |
+
const store = await getIndex(directories, collectionId, source);
|
180 |
+
const items = await store.listItemsByMetadata({ hash: { '$in': hashes } });
|
181 |
+
|
182 |
+
await store.beginUpdate();
|
183 |
+
|
184 |
+
for (const item of items) {
|
185 |
+
await store.deleteItem(item.id);
|
186 |
+
}
|
187 |
+
|
188 |
+
await store.endUpdate();
|
189 |
+
}
|
190 |
+
|
191 |
+
/**
|
192 |
+
* Gets the hashes of the items in the vector collection that match the search text
|
193 |
+
* @param {import('../users').UserDirectoryList} directories - User directories
|
194 |
+
* @param {string} collectionId - The collection ID
|
195 |
+
* @param {string} source - The source of the vector
|
196 |
+
* @param {Object} sourceSettings - Settings for the source, if it needs any
|
197 |
+
* @param {string} searchText - The text to search for
|
198 |
+
* @param {number} topK - The number of results to return
|
199 |
+
* @param {number} threshold - The threshold for the search
|
200 |
+
* @returns {Promise<{hashes: number[], metadata: object[]}>} - The metadata of the items that match the search text
|
201 |
+
*/
|
202 |
+
async function queryCollection(directories, collectionId, source, sourceSettings, searchText, topK, threshold) {
|
203 |
+
const store = await getIndex(directories, collectionId, source);
|
204 |
+
const vector = await getVector(source, sourceSettings, searchText, true, directories);
|
205 |
+
|
206 |
+
const result = await store.queryItems(vector, topK);
|
207 |
+
const metadata = result.filter(x => x.score >= threshold).map(x => x.item.metadata);
|
208 |
+
const hashes = result.map(x => Number(x.item.metadata.hash));
|
209 |
+
return { metadata, hashes };
|
210 |
+
}
|
211 |
+
|
212 |
+
/**
|
213 |
+
* Queries multiple collections for the given search queries. Returns the overall top K results.
|
214 |
+
* @param {import('../users').UserDirectoryList} directories - User directories
|
215 |
+
* @param {string[]} collectionIds - The collection IDs to query
|
216 |
+
* @param {string} source - The source of the vector
|
217 |
+
* @param {Object} sourceSettings - Settings for the source, if it needs any
|
218 |
+
* @param {string} searchText - The text to search for
|
219 |
+
* @param {number} topK - The number of results to return
|
220 |
+
* @param {number} threshold - The threshold for the search
|
221 |
+
*
|
222 |
+
* @returns {Promise<Record<string, { hashes: number[], metadata: object[] }>>} - The top K results from each collection
|
223 |
+
*/
|
224 |
+
async function multiQueryCollection(directories, collectionIds, source, sourceSettings, searchText, topK, threshold) {
|
225 |
+
const vector = await getVector(source, sourceSettings, searchText, true, directories);
|
226 |
+
const results = [];
|
227 |
+
|
228 |
+
for (const collectionId of collectionIds) {
|
229 |
+
const store = await getIndex(directories, collectionId, source);
|
230 |
+
const result = await store.queryItems(vector, topK);
|
231 |
+
results.push(...result.map(result => ({ collectionId, result })));
|
232 |
+
}
|
233 |
+
|
234 |
+
// Sort results by descending similarity, apply threshold, and take top K
|
235 |
+
const sortedResults = results
|
236 |
+
.sort((a, b) => b.result.score - a.result.score)
|
237 |
+
.filter(x => x.result.score >= threshold)
|
238 |
+
.slice(0, topK);
|
239 |
+
|
240 |
+
/**
|
241 |
+
* Group the results by collection ID
|
242 |
+
* @type {Record<string, { hashes: number[], metadata: object[] }>}
|
243 |
+
*/
|
244 |
+
const groupedResults = {};
|
245 |
+
for (const result of sortedResults) {
|
246 |
+
if (!groupedResults[result.collectionId]) {
|
247 |
+
groupedResults[result.collectionId] = { hashes: [], metadata: [] };
|
248 |
+
}
|
249 |
+
|
250 |
+
groupedResults[result.collectionId].hashes.push(Number(result.result.item.metadata.hash));
|
251 |
+
groupedResults[result.collectionId].metadata.push(result.result.item.metadata);
|
252 |
+
}
|
253 |
+
|
254 |
+
return groupedResults;
|
255 |
+
}
|
256 |
+
|
257 |
+
/**
|
258 |
+
* Extracts settings for the vectorization sources from the HTTP request headers.
|
259 |
+
* @param {string} source - Which source to extract settings for.
|
260 |
+
* @param {object} request - The HTTP request object.
|
261 |
+
* @returns {object} - An object that can be used as `sourceSettings` in functions that take that parameter.
|
262 |
+
*/
|
263 |
+
function getSourceSettings(source, request) {
|
264 |
+
if (source === 'togetherai') {
|
265 |
+
const model = String(request.headers['x-togetherai-model']);
|
266 |
+
|
267 |
+
return {
|
268 |
+
model: model,
|
269 |
+
};
|
270 |
+
} else if (source === 'openai') {
|
271 |
+
const model = String(request.headers['x-openai-model']);
|
272 |
+
|
273 |
+
return {
|
274 |
+
model: model,
|
275 |
+
};
|
276 |
+
} else if (source === 'cohere') {
|
277 |
+
const model = String(request.headers['x-cohere-model']);
|
278 |
+
|
279 |
+
return {
|
280 |
+
model: model,
|
281 |
+
};
|
282 |
+
} else if (source === 'llamacpp') {
|
283 |
+
const apiUrl = String(request.headers['x-llamacpp-url']);
|
284 |
+
|
285 |
+
return {
|
286 |
+
apiUrl: apiUrl,
|
287 |
+
};
|
288 |
+
} else if (source === 'vllm') {
|
289 |
+
const apiUrl = String(request.headers['x-vllm-url']);
|
290 |
+
const model = String(request.headers['x-vllm-model']);
|
291 |
+
|
292 |
+
return {
|
293 |
+
apiUrl: apiUrl,
|
294 |
+
model: model,
|
295 |
+
};
|
296 |
+
} else if (source === 'ollama') {
|
297 |
+
const apiUrl = String(request.headers['x-ollama-url']);
|
298 |
+
const model = String(request.headers['x-ollama-model']);
|
299 |
+
const keep = Boolean(request.headers['x-ollama-keep']);
|
300 |
+
|
301 |
+
return {
|
302 |
+
apiUrl: apiUrl,
|
303 |
+
model: model,
|
304 |
+
keep: keep,
|
305 |
+
};
|
306 |
+
} else {
|
307 |
+
// Extras API settings to connect to the Extras embeddings provider
|
308 |
+
let extrasUrl = '';
|
309 |
+
let extrasKey = '';
|
310 |
+
if (source === 'extras') {
|
311 |
+
extrasUrl = String(request.headers['x-extras-url']);
|
312 |
+
extrasKey = String(request.headers['x-extras-key']);
|
313 |
+
}
|
314 |
+
|
315 |
+
return {
|
316 |
+
extrasUrl: extrasUrl,
|
317 |
+
extrasKey: extrasKey,
|
318 |
+
};
|
319 |
+
}
|
320 |
+
}
|
321 |
+
|
322 |
+
/**
|
323 |
+
* Performs a request to regenerate the index if it is corrupted.
|
324 |
+
* @param {import('express').Request} req Express request object
|
325 |
+
* @param {import('express').Response} res Express response object
|
326 |
+
* @param {Error} error Error object
|
327 |
+
* @returns {Promise<any>} Promise
|
328 |
+
*/
|
329 |
+
async function regenerateCorruptedIndexErrorHandler(req, res, error) {
|
330 |
+
if (error instanceof SyntaxError && !req.query.regenerated) {
|
331 |
+
const collectionId = String(req.body.collectionId);
|
332 |
+
const source = String(req.body.source) || 'transformers';
|
333 |
+
|
334 |
+
if (collectionId && source) {
|
335 |
+
const index = await getIndex(req.user.directories, collectionId, source, false);
|
336 |
+
const exists = await index.isIndexCreated();
|
337 |
+
|
338 |
+
if (exists) {
|
339 |
+
const path = index.folderPath;
|
340 |
+
console.error(`Corrupted index detected at ${path}, regenerating...`);
|
341 |
+
await index.deleteIndex();
|
342 |
+
return res.redirect(307, req.originalUrl + '?regenerated=true');
|
343 |
+
}
|
344 |
+
}
|
345 |
+
}
|
346 |
+
|
347 |
+
console.error(error);
|
348 |
+
return res.sendStatus(500);
|
349 |
+
}
|
350 |
+
|
351 |
+
const router = express.Router();
|
352 |
+
|
353 |
+
router.post('/query', jsonParser, async (req, res) => {
|
354 |
+
try {
|
355 |
+
if (!req.body.collectionId || !req.body.searchText) {
|
356 |
+
return res.sendStatus(400);
|
357 |
+
}
|
358 |
+
|
359 |
+
const collectionId = String(req.body.collectionId);
|
360 |
+
const searchText = String(req.body.searchText);
|
361 |
+
const topK = Number(req.body.topK) || 10;
|
362 |
+
const threshold = Number(req.body.threshold) || 0.0;
|
363 |
+
const source = String(req.body.source) || 'transformers';
|
364 |
+
const sourceSettings = getSourceSettings(source, req);
|
365 |
+
|
366 |
+
const results = await queryCollection(req.user.directories, collectionId, source, sourceSettings, searchText, topK, threshold);
|
367 |
+
return res.json(results);
|
368 |
+
} catch (error) {
|
369 |
+
return regenerateCorruptedIndexErrorHandler(req, res, error);
|
370 |
+
}
|
371 |
+
});
|
372 |
+
|
373 |
+
router.post('/query-multi', jsonParser, async (req, res) => {
|
374 |
+
try {
|
375 |
+
if (!Array.isArray(req.body.collectionIds) || !req.body.searchText) {
|
376 |
+
return res.sendStatus(400);
|
377 |
+
}
|
378 |
+
|
379 |
+
const collectionIds = req.body.collectionIds.map(x => String(x));
|
380 |
+
const searchText = String(req.body.searchText);
|
381 |
+
const topK = Number(req.body.topK) || 10;
|
382 |
+
const threshold = Number(req.body.threshold) || 0.0;
|
383 |
+
const source = String(req.body.source) || 'transformers';
|
384 |
+
const sourceSettings = getSourceSettings(source, req);
|
385 |
+
|
386 |
+
const results = await multiQueryCollection(req.user.directories, collectionIds, source, sourceSettings, searchText, topK, threshold);
|
387 |
+
return res.json(results);
|
388 |
+
} catch (error) {
|
389 |
+
return regenerateCorruptedIndexErrorHandler(req, res, error);
|
390 |
+
}
|
391 |
+
});
|
392 |
+
|
393 |
+
router.post('/insert', jsonParser, async (req, res) => {
|
394 |
+
try {
|
395 |
+
if (!Array.isArray(req.body.items) || !req.body.collectionId) {
|
396 |
+
return res.sendStatus(400);
|
397 |
+
}
|
398 |
+
|
399 |
+
const collectionId = String(req.body.collectionId);
|
400 |
+
const items = req.body.items.map(x => ({ hash: x.hash, text: x.text, index: x.index }));
|
401 |
+
const source = String(req.body.source) || 'transformers';
|
402 |
+
const sourceSettings = getSourceSettings(source, req);
|
403 |
+
|
404 |
+
await insertVectorItems(req.user.directories, collectionId, source, sourceSettings, items);
|
405 |
+
return res.sendStatus(200);
|
406 |
+
} catch (error) {
|
407 |
+
return regenerateCorruptedIndexErrorHandler(req, res, error);
|
408 |
+
}
|
409 |
+
});
|
410 |
+
|
411 |
+
router.post('/list', jsonParser, async (req, res) => {
|
412 |
+
try {
|
413 |
+
if (!req.body.collectionId) {
|
414 |
+
return res.sendStatus(400);
|
415 |
+
}
|
416 |
+
|
417 |
+
const collectionId = String(req.body.collectionId);
|
418 |
+
const source = String(req.body.source) || 'transformers';
|
419 |
+
|
420 |
+
const hashes = await getSavedHashes(req.user.directories, collectionId, source);
|
421 |
+
return res.json(hashes);
|
422 |
+
} catch (error) {
|
423 |
+
return regenerateCorruptedIndexErrorHandler(req, res, error);
|
424 |
+
}
|
425 |
+
});
|
426 |
+
|
427 |
+
router.post('/delete', jsonParser, async (req, res) => {
|
428 |
+
try {
|
429 |
+
if (!Array.isArray(req.body.hashes) || !req.body.collectionId) {
|
430 |
+
return res.sendStatus(400);
|
431 |
+
}
|
432 |
+
|
433 |
+
const collectionId = String(req.body.collectionId);
|
434 |
+
const hashes = req.body.hashes.map(x => Number(x));
|
435 |
+
const source = String(req.body.source) || 'transformers';
|
436 |
+
|
437 |
+
await deleteVectorItems(req.user.directories, collectionId, source, hashes);
|
438 |
+
return res.sendStatus(200);
|
439 |
+
} catch (error) {
|
440 |
+
return regenerateCorruptedIndexErrorHandler(req, res, error);
|
441 |
+
}
|
442 |
+
});
|
443 |
+
|
444 |
+
router.post('/purge-all', jsonParser, async (req, res) => {
|
445 |
+
try {
|
446 |
+
for (const source of SOURCES) {
|
447 |
+
const sourcePath = path.join(req.user.directories.vectors, sanitize(source));
|
448 |
+
if (!fs.existsSync(sourcePath)) {
|
449 |
+
continue;
|
450 |
+
}
|
451 |
+
await fs.promises.rm(sourcePath, { recursive: true });
|
452 |
+
console.log(`Deleted vector source store at ${sourcePath}`);
|
453 |
+
}
|
454 |
+
|
455 |
+
return res.sendStatus(200);
|
456 |
+
} catch (error) {
|
457 |
+
console.error(error);
|
458 |
+
return res.sendStatus(500);
|
459 |
+
}
|
460 |
+
});
|
461 |
+
|
462 |
+
router.post('/purge', jsonParser, async (req, res) => {
|
463 |
+
try {
|
464 |
+
if (!req.body.collectionId) {
|
465 |
+
return res.sendStatus(400);
|
466 |
+
}
|
467 |
+
|
468 |
+
const collectionId = String(req.body.collectionId);
|
469 |
+
|
470 |
+
for (const source of SOURCES) {
|
471 |
+
const index = await getIndex(req.user.directories, collectionId, source, false);
|
472 |
+
|
473 |
+
const exists = await index.isIndexCreated();
|
474 |
+
|
475 |
+
if (!exists) {
|
476 |
+
continue;
|
477 |
+
}
|
478 |
+
|
479 |
+
const path = index.folderPath;
|
480 |
+
await index.deleteIndex();
|
481 |
+
console.log(`Deleted vector index at ${path}`);
|
482 |
+
}
|
483 |
+
|
484 |
+
return res.sendStatus(200);
|
485 |
+
} catch (error) {
|
486 |
+
console.error(error);
|
487 |
+
return res.sendStatus(500);
|
488 |
+
}
|
489 |
+
});
|
490 |
+
|
491 |
+
module.exports = { router };
|
src/endpoints/worldinfo.js
ADDED
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const fs = require('fs');
|
2 |
+
const path = require('path');
|
3 |
+
const express = require('express');
|
4 |
+
const sanitize = require('sanitize-filename');
|
5 |
+
const writeFileAtomicSync = require('write-file-atomic').sync;
|
6 |
+
|
7 |
+
const { jsonParser, urlencodedParser } = require('../express-common');
|
8 |
+
|
9 |
+
/**
|
10 |
+
* Reads a World Info file and returns its contents
|
11 |
+
* @param {import('../users').UserDirectoryList} directories User directories
|
12 |
+
* @param {string} worldInfoName Name of the World Info file
|
13 |
+
* @param {boolean} allowDummy If true, returns an empty object if the file doesn't exist
|
14 |
+
* @returns {object} World Info file contents
|
15 |
+
*/
|
16 |
+
function readWorldInfoFile(directories, worldInfoName, allowDummy) {
|
17 |
+
const dummyObject = allowDummy ? { entries: {} } : null;
|
18 |
+
|
19 |
+
if (!worldInfoName) {
|
20 |
+
return dummyObject;
|
21 |
+
}
|
22 |
+
|
23 |
+
const filename = `${worldInfoName}.json`;
|
24 |
+
const pathToWorldInfo = path.join(directories.worlds, filename);
|
25 |
+
|
26 |
+
if (!fs.existsSync(pathToWorldInfo)) {
|
27 |
+
console.log(`World info file ${filename} doesn't exist.`);
|
28 |
+
return dummyObject;
|
29 |
+
}
|
30 |
+
|
31 |
+
const worldInfoText = fs.readFileSync(pathToWorldInfo, 'utf8');
|
32 |
+
const worldInfo = JSON.parse(worldInfoText);
|
33 |
+
return worldInfo;
|
34 |
+
}
|
35 |
+
|
36 |
+
const router = express.Router();
|
37 |
+
|
38 |
+
router.post('/get', jsonParser, (request, response) => {
|
39 |
+
if (!request.body?.name) {
|
40 |
+
return response.sendStatus(400);
|
41 |
+
}
|
42 |
+
|
43 |
+
const file = readWorldInfoFile(request.user.directories, request.body.name, true);
|
44 |
+
|
45 |
+
return response.send(file);
|
46 |
+
});
|
47 |
+
|
48 |
+
router.post('/delete', jsonParser, (request, response) => {
|
49 |
+
if (!request.body?.name) {
|
50 |
+
return response.sendStatus(400);
|
51 |
+
}
|
52 |
+
|
53 |
+
const worldInfoName = request.body.name;
|
54 |
+
const filename = sanitize(`${worldInfoName}.json`);
|
55 |
+
const pathToWorldInfo = path.join(request.user.directories.worlds, filename);
|
56 |
+
|
57 |
+
if (!fs.existsSync(pathToWorldInfo)) {
|
58 |
+
throw new Error(`World info file ${filename} doesn't exist.`);
|
59 |
+
}
|
60 |
+
|
61 |
+
fs.rmSync(pathToWorldInfo);
|
62 |
+
|
63 |
+
return response.sendStatus(200);
|
64 |
+
});
|
65 |
+
|
66 |
+
router.post('/import', urlencodedParser, (request, response) => {
|
67 |
+
if (!request.file) return response.sendStatus(400);
|
68 |
+
|
69 |
+
const filename = `${path.parse(sanitize(request.file.originalname)).name}.json`;
|
70 |
+
|
71 |
+
let fileContents = null;
|
72 |
+
|
73 |
+
if (request.body.convertedData) {
|
74 |
+
fileContents = request.body.convertedData;
|
75 |
+
} else {
|
76 |
+
const pathToUpload = path.join(request.file.destination, request.file.filename);
|
77 |
+
fileContents = fs.readFileSync(pathToUpload, 'utf8');
|
78 |
+
fs.unlinkSync(pathToUpload);
|
79 |
+
}
|
80 |
+
|
81 |
+
try {
|
82 |
+
const worldContent = JSON.parse(fileContents);
|
83 |
+
if (!('entries' in worldContent)) {
|
84 |
+
throw new Error('File must contain a world info entries list');
|
85 |
+
}
|
86 |
+
} catch (err) {
|
87 |
+
return response.status(400).send('Is not a valid world info file');
|
88 |
+
}
|
89 |
+
|
90 |
+
const pathToNewFile = path.join(request.user.directories.worlds, filename);
|
91 |
+
const worldName = path.parse(pathToNewFile).name;
|
92 |
+
|
93 |
+
if (!worldName) {
|
94 |
+
return response.status(400).send('World file must have a name');
|
95 |
+
}
|
96 |
+
|
97 |
+
writeFileAtomicSync(pathToNewFile, fileContents);
|
98 |
+
return response.send({ name: worldName });
|
99 |
+
});
|
100 |
+
|
101 |
+
router.post('/edit', jsonParser, (request, response) => {
|
102 |
+
if (!request.body) {
|
103 |
+
return response.sendStatus(400);
|
104 |
+
}
|
105 |
+
|
106 |
+
if (!request.body.name) {
|
107 |
+
return response.status(400).send('World file must have a name');
|
108 |
+
}
|
109 |
+
|
110 |
+
try {
|
111 |
+
if (!('entries' in request.body.data)) {
|
112 |
+
throw new Error('World info must contain an entries list');
|
113 |
+
}
|
114 |
+
} catch (err) {
|
115 |
+
return response.status(400).send('Is not a valid world info file');
|
116 |
+
}
|
117 |
+
|
118 |
+
const filename = `${sanitize(request.body.name)}.json`;
|
119 |
+
const pathToFile = path.join(request.user.directories.worlds, filename);
|
120 |
+
|
121 |
+
writeFileAtomicSync(pathToFile, JSON.stringify(request.body.data, null, 4));
|
122 |
+
|
123 |
+
return response.send({ ok: true });
|
124 |
+
});
|
125 |
+
|
126 |
+
module.exports = { router, readWorldInfoFile };
|
src/express-common.js
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const express = require('express');
|
2 |
+
const ipaddr = require('ipaddr.js');
|
3 |
+
|
4 |
+
// Instantiate parser middleware here with application-level size limits
|
5 |
+
const jsonParser = express.json({ limit: '200mb' });
|
6 |
+
const urlencodedParser = express.urlencoded({ extended: true, limit: '200mb' });
|
7 |
+
|
8 |
+
/**
|
9 |
+
* Gets the IP address of the client from the request object.
|
10 |
+
* @param {import('express'.Request)} req Request object
|
11 |
+
* @returns {string} IP address of the client
|
12 |
+
*/
|
13 |
+
function getIpFromRequest(req) {
|
14 |
+
let clientIp = req.connection.remoteAddress;
|
15 |
+
let ip = ipaddr.parse(clientIp);
|
16 |
+
// Check if the IP address is IPv4-mapped IPv6 address
|
17 |
+
if (ip.kind() === 'ipv6' && ip instanceof ipaddr.IPv6 && ip.isIPv4MappedAddress()) {
|
18 |
+
const ipv4 = ip.toIPv4Address().toString();
|
19 |
+
clientIp = ipv4;
|
20 |
+
} else {
|
21 |
+
clientIp = ip;
|
22 |
+
clientIp = clientIp.toString();
|
23 |
+
}
|
24 |
+
return clientIp;
|
25 |
+
}
|
26 |
+
|
27 |
+
|
28 |
+
module.exports = { jsonParser, urlencodedParser, getIpFromRequest };
|
src/middleware/basicAuth.js
ADDED
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* When applied, this middleware will ensure the request contains the required header for basic authentication and only
|
3 |
+
* allow access to the endpoint after successful authentication.
|
4 |
+
*/
|
5 |
+
const { getConfig } = require('../util.js');
|
6 |
+
|
7 |
+
const unauthorizedResponse = (res) => {
|
8 |
+
res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"');
|
9 |
+
return res.status(401).send('Authentication required');
|
10 |
+
};
|
11 |
+
|
12 |
+
const basicAuthMiddleware = function (request, response, callback) {
|
13 |
+
const config = getConfig();
|
14 |
+
const authHeader = request.headers.authorization;
|
15 |
+
|
16 |
+
if (!authHeader) {
|
17 |
+
return unauthorizedResponse(response);
|
18 |
+
}
|
19 |
+
|
20 |
+
const [scheme, credentials] = authHeader.split(' ');
|
21 |
+
|
22 |
+
if (scheme !== 'Basic' || !credentials) {
|
23 |
+
return unauthorizedResponse(response);
|
24 |
+
}
|
25 |
+
|
26 |
+
const [username, password] = Buffer.from(credentials, 'base64')
|
27 |
+
.toString('utf8')
|
28 |
+
.split(':');
|
29 |
+
|
30 |
+
if (username === config.basicAuthUser.username && password === config.basicAuthUser.password) {
|
31 |
+
return callback();
|
32 |
+
} else {
|
33 |
+
return unauthorizedResponse(response);
|
34 |
+
}
|
35 |
+
};
|
36 |
+
|
37 |
+
module.exports = basicAuthMiddleware;
|
src/middleware/multerMonkeyPatch.js
ADDED
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Decodes a file name from Latin1 to UTF-8.
|
3 |
+
* @param {string} str Input string
|
4 |
+
* @returns {string} Decoded file name
|
5 |
+
*/
|
6 |
+
function decodeFileName(str) {
|
7 |
+
return Buffer.from(str, 'latin1').toString('utf-8');
|
8 |
+
}
|
9 |
+
|
10 |
+
/**
|
11 |
+
* Middleware to decode file names from Latin1 to UTF-8.
|
12 |
+
* See: https://github.com/expressjs/multer/issues/1104
|
13 |
+
* @param {import('express').Request} req Request
|
14 |
+
* @param {import('express').Response} _res Response
|
15 |
+
* @param {import('express').NextFunction} next Next middleware
|
16 |
+
*/
|
17 |
+
function multerMonkeyPatch(req, _res, next) {
|
18 |
+
try {
|
19 |
+
if (req.file) {
|
20 |
+
req.file.originalname = decodeFileName(req.file.originalname);
|
21 |
+
}
|
22 |
+
|
23 |
+
next();
|
24 |
+
} catch (error) {
|
25 |
+
console.error('Error in multerMonkeyPatch:', error);
|
26 |
+
next();
|
27 |
+
}
|
28 |
+
}
|
29 |
+
|
30 |
+
module.exports = multerMonkeyPatch;
|
src/middleware/whitelist.js
ADDED
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const path = require('path');
|
2 |
+
const fs = require('fs');
|
3 |
+
const ipMatching = require('ip-matching');
|
4 |
+
|
5 |
+
const { getIpFromRequest } = require('../express-common');
|
6 |
+
const { color, getConfigValue } = require('../util');
|
7 |
+
|
8 |
+
const whitelistPath = path.join(process.cwd(), './whitelist.txt');
|
9 |
+
const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false);
|
10 |
+
let whitelist = getConfigValue('whitelist', []);
|
11 |
+
let knownIPs = new Set();
|
12 |
+
|
13 |
+
if (fs.existsSync(whitelistPath)) {
|
14 |
+
try {
|
15 |
+
let whitelistTxt = fs.readFileSync(whitelistPath, 'utf-8');
|
16 |
+
whitelist = whitelistTxt.split('\n').filter(ip => ip).map(ip => ip.trim());
|
17 |
+
} catch (e) {
|
18 |
+
// Ignore errors that may occur when reading the whitelist (e.g. permissions)
|
19 |
+
}
|
20 |
+
}
|
21 |
+
|
22 |
+
/**
|
23 |
+
* Get the client IP address from the request headers.
|
24 |
+
* @param {import('express').Request} req Express request object
|
25 |
+
* @returns {string|undefined} The client IP address
|
26 |
+
*/
|
27 |
+
function getForwardedIp(req) {
|
28 |
+
if (!enableForwardedWhitelist) {
|
29 |
+
return undefined;
|
30 |
+
}
|
31 |
+
|
32 |
+
// Check if X-Real-IP is available
|
33 |
+
if (req.headers['x-real-ip']) {
|
34 |
+
return req.headers['x-real-ip'].toString();
|
35 |
+
}
|
36 |
+
|
37 |
+
// Check for X-Forwarded-For and parse if available
|
38 |
+
if (req.headers['x-forwarded-for']) {
|
39 |
+
const ipList = req.headers['x-forwarded-for'].toString().split(',').map(ip => ip.trim());
|
40 |
+
return ipList[0];
|
41 |
+
}
|
42 |
+
|
43 |
+
// If none of the headers are available, return undefined
|
44 |
+
return undefined;
|
45 |
+
}
|
46 |
+
|
47 |
+
/**
|
48 |
+
* Returns a middleware function that checks if the client IP is in the whitelist.
|
49 |
+
* @param {boolean} whitelistMode If whitelist mode is enabled via config or command line
|
50 |
+
* @param {boolean} listen If listen mode is enabled via config or command line
|
51 |
+
* @returns {import('express').RequestHandler} The middleware function
|
52 |
+
*/
|
53 |
+
function whitelistMiddleware(whitelistMode, listen) {
|
54 |
+
return function (req, res, next) {
|
55 |
+
const clientIp = getIpFromRequest(req);
|
56 |
+
const forwardedIp = getForwardedIp(req);
|
57 |
+
|
58 |
+
if (listen && !knownIPs.has(clientIp)) {
|
59 |
+
const userAgent = req.headers['user-agent'];
|
60 |
+
console.log(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`));
|
61 |
+
knownIPs.add(clientIp);
|
62 |
+
|
63 |
+
// Write access log
|
64 |
+
const timestamp = new Date().toISOString();
|
65 |
+
const log = `${timestamp} ${clientIp} ${userAgent}\n`;
|
66 |
+
fs.appendFile('access.log', log, (err) => {
|
67 |
+
if (err) {
|
68 |
+
console.error('Failed to write access log:', err);
|
69 |
+
}
|
70 |
+
});
|
71 |
+
}
|
72 |
+
|
73 |
+
//clientIp = req.connection.remoteAddress.split(':').pop();
|
74 |
+
if (whitelistMode === true && !whitelist.some(x => ipMatching.matches(clientIp, ipMatching.getMatch(x)))
|
75 |
+
|| forwardedIp && whitelistMode === true && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x)))
|
76 |
+
) {
|
77 |
+
// Log the connection attempt with real IP address
|
78 |
+
const ipDetails = forwardedIp ? `${clientIp} (forwarded from ${forwardedIp})` : clientIp;
|
79 |
+
console.log(color.red('Forbidden: Connection attempt from ' + ipDetails + '. If you are attempting to connect, please add your IP address in whitelist or disable whitelist mode in config.yaml in root of SillyTavern folder.\n'));
|
80 |
+
return res.status(403).send('<b>Forbidden</b>: Connection attempt from <b>' + ipDetails + '</b>. If you are attempting to connect, please add your IP address in whitelist or disable whitelist mode in config.yaml in root of SillyTavern folder.');
|
81 |
+
}
|
82 |
+
next();
|
83 |
+
};
|
84 |
+
}
|
85 |
+
|
86 |
+
module.exports = whitelistMiddleware;
|
src/plugin-loader.js
ADDED
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const fs = require('fs');
|
2 |
+
const path = require('path');
|
3 |
+
const url = require('url');
|
4 |
+
const express = require('express');
|
5 |
+
const { getConfigValue } = require('./util');
|
6 |
+
const enableServerPlugins = getConfigValue('enableServerPlugins', false);
|
7 |
+
|
8 |
+
/**
|
9 |
+
* Map of loaded plugins.
|
10 |
+
* @type {Map<string, any>}
|
11 |
+
*/
|
12 |
+
const loadedPlugins = new Map();
|
13 |
+
|
14 |
+
/**
|
15 |
+
* Determine if a file is a CommonJS module.
|
16 |
+
* @param {string} file Path to file
|
17 |
+
* @returns {boolean} True if file is a CommonJS module
|
18 |
+
*/
|
19 |
+
const isCommonJS = (file) => path.extname(file) === '.js';
|
20 |
+
|
21 |
+
/**
|
22 |
+
* Determine if a file is an ECMAScript module.
|
23 |
+
* @param {string} file Path to file
|
24 |
+
* @returns {boolean} True if file is an ECMAScript module
|
25 |
+
*/
|
26 |
+
const isESModule = (file) => path.extname(file) === '.mjs';
|
27 |
+
|
28 |
+
/**
|
29 |
+
* Load and initialize server plugins from a directory if they are enabled.
|
30 |
+
* @param {import('express').Express} app Express app
|
31 |
+
* @param {string} pluginsPath Path to plugins directory
|
32 |
+
* @returns {Promise<Function>} Promise that resolves when all plugins are loaded. Resolves to a "cleanup" function to
|
33 |
+
* be called before the server shuts down.
|
34 |
+
*/
|
35 |
+
async function loadPlugins(app, pluginsPath) {
|
36 |
+
const exitHooks = [];
|
37 |
+
const emptyFn = () => {};
|
38 |
+
|
39 |
+
// Server plugins are disabled.
|
40 |
+
if (!enableServerPlugins) {
|
41 |
+
return emptyFn;
|
42 |
+
}
|
43 |
+
|
44 |
+
// Plugins directory does not exist.
|
45 |
+
if (!fs.existsSync(pluginsPath)) {
|
46 |
+
return emptyFn;
|
47 |
+
}
|
48 |
+
|
49 |
+
const files = fs.readdirSync(pluginsPath);
|
50 |
+
|
51 |
+
// No plugins to load.
|
52 |
+
if (files.length === 0) {
|
53 |
+
return emptyFn;
|
54 |
+
}
|
55 |
+
|
56 |
+
for (const file of files) {
|
57 |
+
const pluginFilePath = path.join(pluginsPath, file);
|
58 |
+
|
59 |
+
if (fs.statSync(pluginFilePath).isDirectory()) {
|
60 |
+
await loadFromDirectory(app, pluginFilePath, exitHooks);
|
61 |
+
continue;
|
62 |
+
}
|
63 |
+
|
64 |
+
// Not a JavaScript file.
|
65 |
+
if (!isCommonJS(file) && !isESModule(file)) {
|
66 |
+
continue;
|
67 |
+
}
|
68 |
+
|
69 |
+
await loadFromFile(app, pluginFilePath, exitHooks);
|
70 |
+
}
|
71 |
+
|
72 |
+
// Call all plugin "exit" functions at once and wait for them to finish
|
73 |
+
return () => Promise.all(exitHooks.map(exitFn => exitFn()));
|
74 |
+
}
|
75 |
+
|
76 |
+
async function loadFromDirectory(app, pluginDirectoryPath, exitHooks) {
|
77 |
+
const files = fs.readdirSync(pluginDirectoryPath);
|
78 |
+
|
79 |
+
// No plugins to load.
|
80 |
+
if (files.length === 0) {
|
81 |
+
return;
|
82 |
+
}
|
83 |
+
|
84 |
+
// Plugin is an npm package.
|
85 |
+
const packageJsonFilePath = path.join(pluginDirectoryPath, 'package.json');
|
86 |
+
if (fs.existsSync(packageJsonFilePath)) {
|
87 |
+
if (await loadFromPackage(app, packageJsonFilePath, exitHooks)) {
|
88 |
+
return;
|
89 |
+
}
|
90 |
+
}
|
91 |
+
|
92 |
+
// Plugin is a CommonJS module.
|
93 |
+
const cjsFilePath = path.join(pluginDirectoryPath, 'index.js');
|
94 |
+
if (fs.existsSync(cjsFilePath)) {
|
95 |
+
if (await loadFromFile(app, cjsFilePath, exitHooks)) {
|
96 |
+
return;
|
97 |
+
}
|
98 |
+
}
|
99 |
+
|
100 |
+
// Plugin is an ECMAScript module.
|
101 |
+
const esmFilePath = path.join(pluginDirectoryPath, 'index.mjs');
|
102 |
+
if (fs.existsSync(esmFilePath)) {
|
103 |
+
if (await loadFromFile(app, esmFilePath, exitHooks)) {
|
104 |
+
return;
|
105 |
+
}
|
106 |
+
}
|
107 |
+
}
|
108 |
+
|
109 |
+
/**
|
110 |
+
* Loads and initializes a plugin from an npm package.
|
111 |
+
* @param {import('express').Express} app Express app
|
112 |
+
* @param {string} packageJsonPath Path to package.json file
|
113 |
+
* @param {Array<Function>} exitHooks Array of functions to be run on plugin exit. Will be pushed to if the plugin has
|
114 |
+
* an "exit" function.
|
115 |
+
* @returns {Promise<boolean>} Promise that resolves to true if plugin was loaded successfully
|
116 |
+
*/
|
117 |
+
async function loadFromPackage(app, packageJsonPath, exitHooks) {
|
118 |
+
try {
|
119 |
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
120 |
+
if (packageJson.main) {
|
121 |
+
const pluginFilePath = path.join(path.dirname(packageJsonPath), packageJson.main);
|
122 |
+
return await loadFromFile(app, pluginFilePath, exitHooks);
|
123 |
+
}
|
124 |
+
} catch (error) {
|
125 |
+
console.error(`Failed to load plugin from ${packageJsonPath}: ${error}`);
|
126 |
+
}
|
127 |
+
return false;
|
128 |
+
}
|
129 |
+
|
130 |
+
/**
|
131 |
+
* Loads and initializes a plugin from a file.
|
132 |
+
* @param {import('express').Express} app Express app
|
133 |
+
* @param {string} pluginFilePath Path to plugin directory
|
134 |
+
* @param {Array.<Function>} exitHooks Array of functions to be run on plugin exit. Will be pushed to if the plugin has
|
135 |
+
* an "exit" function.
|
136 |
+
* @returns {Promise<boolean>} Promise that resolves to true if plugin was loaded successfully
|
137 |
+
*/
|
138 |
+
async function loadFromFile(app, pluginFilePath, exitHooks) {
|
139 |
+
try {
|
140 |
+
const fileUrl = url.pathToFileURL(pluginFilePath).toString();
|
141 |
+
const plugin = await import(fileUrl);
|
142 |
+
console.log(`Initializing plugin from ${pluginFilePath}`);
|
143 |
+
return await initPlugin(app, plugin, exitHooks);
|
144 |
+
} catch (error) {
|
145 |
+
console.error(`Failed to load plugin from ${pluginFilePath}: ${error}`);
|
146 |
+
return false;
|
147 |
+
}
|
148 |
+
}
|
149 |
+
|
150 |
+
/**
|
151 |
+
* Check whether a plugin ID is valid (only lowercase alphanumeric, hyphens, and underscores).
|
152 |
+
* @param {string} id The plugin ID to check
|
153 |
+
* @returns {boolean} True if the plugin ID is valid.
|
154 |
+
*/
|
155 |
+
function isValidPluginID(id) {
|
156 |
+
return /^[a-z0-9_-]+$/.test(id);
|
157 |
+
}
|
158 |
+
|
159 |
+
/**
|
160 |
+
* Initializes a plugin module.
|
161 |
+
* @param {import('express').Express} app Express app
|
162 |
+
* @param {any} plugin Plugin module
|
163 |
+
* @param {Array.<Function>} exitHooks Array of functions to be run on plugin exit. Will be pushed to if the plugin has
|
164 |
+
* an "exit" function.
|
165 |
+
* @returns {Promise<boolean>} Promise that resolves to true if plugin was initialized successfully
|
166 |
+
*/
|
167 |
+
async function initPlugin(app, plugin, exitHooks) {
|
168 |
+
const info = plugin.info || plugin.default?.info;
|
169 |
+
if (typeof info !== 'object') {
|
170 |
+
console.error('Failed to load plugin module; plugin info not found');
|
171 |
+
return false;
|
172 |
+
}
|
173 |
+
|
174 |
+
// We don't currently use "name" or "description" but it would be nice to have a UI for listing server plugins, so
|
175 |
+
// require them now just to be safe
|
176 |
+
for (const field of ['id', 'name', 'description']) {
|
177 |
+
if (typeof info[field] !== 'string') {
|
178 |
+
console.error(`Failed to load plugin module; plugin info missing field '${field}'`);
|
179 |
+
return false;
|
180 |
+
}
|
181 |
+
}
|
182 |
+
|
183 |
+
const init = plugin.init || plugin.default?.init;
|
184 |
+
if (typeof init !== 'function') {
|
185 |
+
console.error('Failed to load plugin module; no init function');
|
186 |
+
return false;
|
187 |
+
}
|
188 |
+
|
189 |
+
const { id } = info;
|
190 |
+
|
191 |
+
if (!isValidPluginID(id)) {
|
192 |
+
console.error(`Failed to load plugin module; invalid plugin ID '${id}'`);
|
193 |
+
return false;
|
194 |
+
}
|
195 |
+
|
196 |
+
if (loadedPlugins.has(id)) {
|
197 |
+
console.error(`Failed to load plugin module; plugin ID '${id}' is already in use`);
|
198 |
+
return false;
|
199 |
+
}
|
200 |
+
|
201 |
+
// Allow the plugin to register API routes under /api/plugins/[plugin ID] via a router
|
202 |
+
const router = express.Router();
|
203 |
+
|
204 |
+
await init(router);
|
205 |
+
|
206 |
+
loadedPlugins.set(id, plugin);
|
207 |
+
|
208 |
+
// Add API routes to the app if the plugin registered any
|
209 |
+
if (router.stack.length > 0) {
|
210 |
+
app.use(`/api/plugins/${id}`, router);
|
211 |
+
}
|
212 |
+
|
213 |
+
const exit = plugin.exit || plugin.default?.exit;
|
214 |
+
if (typeof exit === 'function') {
|
215 |
+
exitHooks.push(exit);
|
216 |
+
}
|
217 |
+
|
218 |
+
return true;
|
219 |
+
}
|
220 |
+
|
221 |
+
module.exports = {
|
222 |
+
loadPlugins,
|
223 |
+
};
|
src/polyfill.js
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
if (!Array.prototype.findLastIndex) {
|
2 |
+
Array.prototype.findLastIndex = function (callback, thisArg) {
|
3 |
+
for (let i = this.length - 1; i >= 0; i--) {
|
4 |
+
if (callback.call(thisArg, this[i], i, this)) return i;
|
5 |
+
}
|
6 |
+
return -1;
|
7 |
+
};
|
8 |
+
}
|
9 |
+
|
10 |
+
module.exports = {};
|